175 Commits
Author SHA1 Message Date
yusron.alamsyah.2311 8745b22f07 fix : calender view jadwal dokter 2026-08-03 07:58:47 +07:00
yusron.alamsyah.2311 b0b5200802 fix : page detail akun peserta 2026-07-14 09:16:12 +07:00
Fanrouver fec58f14b5 docs: add Git workflow and branching guide for team reference 2026-07-10 14:06:15 +07:00
Fanrouver bbcaf9826c update skill dan docs 2026-07-10 11:14:36 +07:00
Fanrouver d0eee7d062 feat: implement Grand Paviliun dashboard with specialized room monitoring, queue management, and modular components 2026-07-10 09:42:30 +07:00
Fanrouver 61772105fd feat: implement queue store with clinic API integration, WebSocket sync, and patient data management 2026-07-02 11:39:07 +07:00
Fanrouver e18ed186e1 feat: implement queue management store and administrative interface for patient flow control 2026-06-23 08:24:26 +07:00
yusron.alamsyah.2311 400bfcdcaf feat detail akun 2026-06-19 11:10:51 +07:00
Fanrouver 8b9c4725de feat: implement queue display and management system for loket and clinic modules voice over 2026-06-09 08:51:15 +07:00
Fanrouver f81dd57a16 pindah klinik belum service 2026-06-05 08:21:56 +07:00
Fanrouver 0928e78bec feat: implement proxy routes for APIs, add Pinia stores for loket, clinic, and queue management, and create new pages for admin and anjungan dashboard. 2026-06-03 09:18:48 +07:00
Fanrouver 047b39064d feat: implement queue display system with localized store, composables, and administrative dashboard pages 2026-06-02 09:14:38 +07:00
Fanrouver ffdb88d13c push perbaikan 2026-05-25 08:43:30 +07:00
Fanrouver ec9dac0789 push perbaikan layar informasi info klinik ruang tidak muncul 2026-05-21 10:10:46 +07:00
Fanrouver f622052c67 perbaikan bug tampilan loket 2026-05-20 13:55:15 +07:00
Fanrouver 1446e87bba tambahan subspesialis tapi masih pisah 2026-05-20 08:51:52 +07:00
Fanrouver bc74b98cfd penambahan docker dan lain lain untuk penyesuaian docker 2026-05-19 09:57:46 +07:00
Fanrouver cb3310b44b perbaikan LOAD usage memperingan penggunaan 2026-05-18 09:22:47 +07:00
Fanrouver 2bf00eff3c update hak akses dan perbaikan api pembuatan tiket 2026-04-20 09:42:03 +07:00
Fanrouver 94ff9f55d9 perbaikan ambil tiket 2026-02-24 09:12:48 +07:00
Fanrouver 4342cdcc67 feat: Introduce a new Pinia store for comprehensive patient queue management, including API integration, and add supporting admin and kiosk display pages. 2026-02-19 11:18:36 +07:00
Fanrouver 0c4019c67d feat: Implement Anjungan (Kiosk) module including clinic room selection, various queue pages, patient check-in, and associated admin interfaces. 2026-02-19 08:28:00 +07:00
Fanrouver d03fa632e7 fix: Remove 'x' from wsBaseUrl fallback in Nuxt config. 2026-02-18 14:16:56 +07:00
Fanrouver 7d07a4f00d feat: Implement the core Anjungan queue display system, including clinic room, entry, and counter queues, along with new stores, pages, and API endpoints. 2026-02-18 12:00:30 +07:00
Fanrouver f78bbea4b0 feat: Add patient verification store and configure new ekstrakExpertiseUrl and verificationApiBaseUrl endpoints. 2026-02-18 08:00:29 +07:00
Fanrouver 8c6b3fd5dc feat: Add Admin Loket detail page for managing patient queues, patient actions, and various selection dialogs. 2026-02-13 15:12:43 +07:00
Fanrouver dbc90548e2 feat: Implement comprehensive patient queue management system with Pinia store, API integration, and dedicated admin counter page. 2026-02-13 15:09:01 +07:00
Fanrouver 95ff83027c feat: Add admin counter queue page with patient and current patient cards. 2026-02-13 14:59:00 +07:00
Fanrouver 67a5514654 feat: implement AdminKlinikRuang page for managing patient queues, including processing, calling, filtering, and a global search, alongside a new queueStore. 2026-02-13 14:26:49 +07:00
Fanrouver 4df13cbe70 feat: add comprehensive queue management features for clinic rooms and counters, including admin, kiosk views, and a dedicated store. 2026-02-13 13:07:59 +07:00
Fanrouver a5ab338147 feat: introduce Anjungan pages for clinic/room and counter queues, and a patient check-in page. 2026-02-12 11:20:12 +07:00
bagus-arie05 9022cb103a Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-02-12 11:06:04 +07:00
bagus-arie05 4985aef14a update post API klinik dan loket di queuestore 2026-02-12 11:05:55 +07:00
Fanrouver 0e3ee37fd8 feat: Add core queue management system with Pinia store, WebSocket integration, and dedicated pages for patient check-in and kiosk display. 2026-02-12 10:06:57 +07:00
bagus-arie05 c08941aed3 update fixing status dan pasien selesai ruang 2026-02-12 09:49:05 +07:00
Fanrouver 1fbfa0e590 dasd 2026-02-12 08:30:37 +07:00
Fanrouver 7fd99343bd push update 2026-02-12 08:06:20 +07:00
Fanrouver 23164bcf2d push error missing function 2026-02-11 10:29:19 +07:00
Fanrouver a7a654b72a update WS admin loket dan checkin 2026-02-11 09:34:42 +07:00
bagus-arie05 c02905e954 fix duplication and ticket disappear in adminklinik loket 2026-02-11 08:32:03 +07:00
Fanrouver b6dc252bc9 update bug ws checkin loket 2026-02-10 15:38:23 +07:00
Fanrouver 6af3f58e89 push change sort pasien next process 2026-02-10 12:18:02 +07:00
Fanrouver e686ddad57 fix socket checkin 2026-02-10 10:10:56 +07:00
Fanrouver fb70237a12 push update socket data dan token 2026-02-10 09:51:17 +07:00
Fanrouver 101dc38910 update web socket 2026-02-09 12:26:59 +07:00
bagus-arie05 6696881ee5 update status klinik ruang 2026-02-06 11:01:02 +07:00
Fanrouver 329ac9c3b0 update anjungan tampilan 2026-02-06 10:11:31 +07:00
Fanrouver 93874d16bb update display 2026-02-06 10:05:26 +07:00
bagus-arie05 5d139c8f4a fixing admin loket dan blokade loket yang tidak tersedia 2026-02-06 10:04:06 +07:00
Fanrouver 2838016244 update verif dkk 2026-02-05 12:06:25 +07:00
bagus-arie05 e2a5d43e76 update post api status dan API data pasien ruang 2026-02-05 12:00:33 +07:00
Fanrouver c2d9023418 perbaikan API klinik ruang 2026-02-04 15:08:05 +07:00
Fanrouver cf54ded78c update tampilan layar screen 2026-02-04 10:34:33 +07:00
Fanrouver a29838e2c7 update penambahan API ruang 2026-02-04 08:40:16 +07:00
Fanrouver b327997021 update api klinik ruang 2026-02-03 15:11:55 +07:00
bagus-arie05 efeb42e0fe update logika pemanggilan admin loket by payment 2026-02-03 14:26:10 +07:00
Fanrouver 0428017cac upate session dan ws 2026-02-02 14:47:00 +07:00
Fanrouver c899a71fd8 fix https implementation and checkin 2026-02-02 11:33:19 +07:00
bagus-arie05 93ff2fc3e0 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-02-02 11:07:35 +07:00
bagus-arie05 53a510f23d update status waiting dan update post data pasien 2026-02-02 11:04:28 +07:00
Fanrouver a36aa28de0 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-30 15:11:28 +07:00
Fanrouver 8dd94ed744 ##update fix session dan tampilan screen 2026-01-30 15:11:17 +07:00
bagus-arie05 7ba1c5b7dd update minor display 2026-01-30 14:53:03 +07:00
Fanrouver 507f415710 PUSH UP WOKE UPDATE Api ANTREAN MASUK DAN MINOR CHANGE 2026-01-30 13:40:43 +07:00
bagus-arie05 7c3fc4bd50 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-01-29 09:52:10 +07:00
bagus-arie05 59e42f31b5 update minor anjungan 2026-01-29 09:50:22 +07:00
Fanrouver 0bd5311b6b fix loop dan duplicate data 2026-01-29 09:47:08 +07:00
bagus-arie05 cccefb0775 update fetch api 2026-01-29 08:44:18 +07:00
Fanrouver 2bf38e0f63 fix html error 2026-01-29 08:20:56 +07:00
Fanrouver ae8b06d66d update layout all 2026-01-29 08:17:26 +07:00
bagus-arie05 19633afde1 update queuestore dari API 2026-01-29 08:11:30 +07:00
bagus-arie05 9f0f6a75c9 update master dan ruang based on jenis layanan 2026-01-28 08:59:27 +07:00
Fanrouver f6060451c0 push update admin loket 2026-01-27 15:21:42 +07:00
Fanrouver 083fe3e364 update api loket admin 2026-01-26 14:37:05 +07:00
bagus-arie05 6c0835227d update master klinik ruang 2026-01-26 14:35:30 +07:00
Fanrouver 525322b6f6 update sidebar checkin dan antrian loket 2026-01-26 09:46:38 +07:00
Fanrouver 75a96389fe update API master LOKET dan antrian LOKET 2026-01-23 08:24:14 +07:00
bagus-arie05 8de89cb579 update anjungan 2026-01-23 08:22:42 +07:00
bagus-arie05 e900c5a1c2 update logika penarikan API dan tampilan anjungan 2026-01-22 10:36:28 +07:00
bagus-arie05 2ccb378672 update no rm dan desain 2026-01-22 09:32:43 +07:00
Fanrouver f3b50bd0a3 upade 2026-01-21 16:38:26 +07:00
Fanrouver 9bd4c51d65 perubahan loket antrean 2026-01-21 15:18:35 +07:00
Fanrouver 0542757afa Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-21 14:52:21 +07:00
Fanrouver bb71955576 perubahan update sidebar dashboard monitoring dan antrean loket verifikasi 2026-01-21 14:52:15 +07:00
bagus-arie05 f7a3b20087 update icon header 2026-01-21 13:12:38 +07:00
bagus-arie05 531ca100ac update header 2026-01-21 12:58:50 +07:00
bagus-arie05 fde71116ff update header dan card dialog 2026-01-21 09:56:07 +07:00
bagus-arie05 08750087b1 update API dan header 2026-01-21 08:59:13 +07:00
Fanrouver 9d45071a36 update sidebar dan dshboard 2026-01-21 08:48:08 +07:00
Fanrouver 482294b8b7 update checkin tampilan dan function 2026-01-20 11:48:00 +07:00
Fanrouver c00c18e878 update loket antrean 2026-01-19 15:48:16 +07:00
Fanrouver 81b877bb7f update loket dan monitoring 2026-01-19 15:27:50 +07:00
Fanrouver f2efd8355e update sidebar profile dan loket 2026-01-19 10:14:05 +07:00
bagus-arie05 cbd8f44b50 update flow klinik ruang, dan design consistency 2026-01-19 10:11:09 +07:00
bagus-arie05 00bb95431c update page header 2026-01-19 09:21:30 +07:00
Fanrouver 3db912a11a sidebar change 2026-01-19 08:29:05 +07:00
Fanrouver 928be7ccd1 edit manual checkin 2026-01-15 14:55:16 +07:00
bagus-arie05 96c637618b update logika pemanggilan, sticky konten, tampilan card 2026-01-15 14:53:17 +07:00
Fanrouver 8aa5ab2ded perbaikan generate dan scan tiket 2026-01-15 14:32:21 +07:00
Fanrouver eac6529a77 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-15 10:44:41 +07:00
Fanrouver e99a0ab075 login page dan check in 2026-01-15 10:35:22 +07:00
bagus-arie05 12efe0be17 update admin loket dan tampilan status fast track 2026-01-15 10:29:32 +07:00
bagus-arie05 a75be83d04 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/web-antrean into Antrean-Code 2026-01-15 08:01:52 +07:00
bagus-arie05 a8a55b9e24 update color palette dan konsultasi pasien 2026-01-15 08:01:37 +07:00
Fanrouver ed57e2d194 tampilan antrean masuk 2026-01-15 07:54:42 +07:00
Fanrouver 45ac7a9965 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-14 15:56:59 +07:00
Fanrouver c5b623f1f6 perubahan antrean masuk 2026-01-14 15:56:56 +07:00
bagus-arie05 89c3549e07 update seed data dan fungsi pindah atau konsul klinik 2026-01-14 15:14:07 +07:00
Fanrouver 6c80c08a83 QRcode update 2026-01-14 11:04:47 +07:00
bagus-arie05 f3e90adea3 update warna dan klinik ruang 2026-01-14 08:25:47 +07:00
bagus-arie05 e80032f668 fixing layar loket dan status pasien 2026-01-13 12:12:27 +07:00
Fanrouver 9ccbabadf2 perbaikan total antrean 2026-01-13 11:40:45 +07:00
Fanrouver 0c7b33aa70 checkin tampilan 2026-01-13 11:24:01 +07:00
Fanrouver 0a8e11a28d perbaikan loket 2026-01-13 10:57:45 +07:00
Fanrouver a000ba0905 perbaikan loket 2026-01-13 10:55:55 +07:00
bagus-arie05 3817b2d3f7 update anjungan fast track dan tampilan ruang 2026-01-13 08:19:19 +07:00
Fanrouver 48e4aabd4b push loket perbaikan sedang dilayani 2026-01-12 15:35:28 +07:00
Fanrouver 42fe62cb41 push loket baru 2026-01-12 14:45:29 +07:00
Fanrouver 5c142273aa perubahan tiket dan alur antrean 2026-01-12 11:52:45 +07:00
bagus-arie05 6e7160c935 update layout klinik dan websocket layar klinik 2026-01-12 11:21:54 +07:00
bagus-arie05 27209def4b update klinik ruang dan status fast track 2026-01-12 08:51:12 +07:00
bagus-arie05 9ea8300973 klinik ruang dan update admin loket 2026-01-09 11:08:58 +07:00
Fanrouver 1b7a142162 checkin footers detail 2026-01-09 11:05:40 +07:00
Fanrouver 6d5d565aa7 Perubahan format No Antrean 2026-01-09 10:28:41 +07:00
Fanrouver 863718020b Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-09 08:44:31 +07:00
Fanrouver 22d7205271 update qr funtion 2026-01-09 08:44:27 +07:00
bagus-arie05 676bdc076f update print adminloket, logika klinik ruang, numbering antrian baru klinik ruang 2026-01-08 14:38:37 +07:00
bagus-arie05 14153a2f03 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-08 12:53:44 +07:00
bagus-arie05 ca5913c4e6 update klinik ruang 2026-01-08 12:53:41 +07:00
Fanrouver 8a4bb44354 nav items 2026-01-08 12:50:04 +07:00
Fanrouver aeb3b4c64b Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-08 12:47:36 +07:00
Fanrouver 2d3d589fe6 layar baru antrean klinik dan masuk 2026-01-08 12:44:40 +07:00
bagus-arie05 ed43c69a1d update draft klinik ruang dan cetak anjungan 2026-01-08 12:40:26 +07:00
bagus-arie05 96b10b9e75 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-07 11:35:58 +07:00
bagus-arie05 155e3d9ea7 update flow anjungan,checkin,admin,dan klinik ruang 2026-01-07 11:35:55 +07:00
Fanrouver a68266ca3c env.example try 2026-01-07 10:09:44 +07:00
Fanrouver 9e399c9f64 perbaikan ip 0.0.0.0 2026-01-07 09:44:54 +07:00
Fanrouver ab99ba2fe5 patch parsing JSON error 2026-01-07 07:50:25 +07:00
Fanrouver 21f6b63ce4 perbaikan keycloak dan config 2026-01-06 15:34:11 +07:00
Fanrouver bb12c9a0e9 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-06 14:55:59 +07:00
Fanrouver c2b03f5621 hak akses hapus atur urutan 2026-01-06 14:55:46 +07:00
bagus-arie05 6b38359a90 update anjungan, store management 2026-01-06 14:51:28 +07:00
Fanrouver 71b9de8d21 Hakakses dan verifikasi akun 2026-01-06 14:26:19 +07:00
Fanrouver 7c8ba8d638 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2026-01-05 09:46:14 +07:00
Fanrouver af855cb6c8 env. example 2026-01-05 09:45:05 +07:00
bagus-arie05 0a2453bdab update tampilan dan flow di loket dan master 2026-01-05 09:26:14 +07:00
Fanrouver b5e40c68d2 QRscan implementation 2026-01-05 08:32:59 +07:00
Fanrouver c95da96017 update user login baru dan hakakses 2025-12-18 15:11:41 +07:00
bagus-arie05 dfcd59481c update flow pasien, store & preview screen, source klinik, api dokter 2025-12-18 14:22:15 +07:00
Fanrouver d2a51f3aee update user login baru dan hakakses 2025-12-16 10:42:45 +07:00
bagus-arie05 78de0418e1 update logika dan flow anjungan, master, store 2025-12-16 09:58:25 +07:00
Fanrouver 4c2c68d993 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-12-05 10:35:30 +07:00
Fanrouver 10ef054de6 add new data 2025 05 12 2025-12-05 10:33:40 +07:00
bagus-arie05 9b3fd7b314 update card, update design system, update layout anjungan 2025-12-05 10:21:16 +07:00
bagus-arie05 817bf8f548 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-11-24 13:43:46 +07:00
bagus-arie05 82b98fdfe6 update structure component, layout page admin, konfigurasi dan layout master 2025-11-24 13:42:11 +07:00
Fanrouver 642be0f9df pull new folder 2025-11-10 09:16:04 +07:00
Fanrouver e2cf3d309a Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-11-05 13:01:37 +07:00
Fanrouver 89688e02f3 change on login sidebar and dashboard 2025-11-05 13:00:06 +07:00
bagus-arie05 56fb6b319b update dev server 2025-11-05 11:10:36 +07:00
bagus-arie05 39d064c30c update font dan color 2025-11-05 10:51:35 +07:00
Fanrouver dbb8050dc5 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-30 09:19:02 +07:00
Fanrouver 4960d4659f push update login, dashboard, check in, monitoring, profilpop, verifikasi, dll 2025-10-30 09:08:40 +07:00
Fanrouver 11d2a4671e Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-28 10:22:29 +07:00
Fanrouver 35024b4797 update akbar tampilan login dashboard dan verifikasi 2025-10-28 10:14:00 +07:00
bagus-arie05 d7f3240a82 Update flow anjungan 2025-10-21 08:31:00 +07:00
bagus-arie05 71890ffb4c Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-14 09:13:20 +07:00
bagus-arie05 31e18c5965 update sidebar dan menu layanan penunjang 2025-10-14 08:49:54 +07:00
Fanrouver 0351f9116d change branch to Akbar-antren 2025-10-13 09:25:44 +07:00
arie.bagus.2905 51809468a9 Merge pull request 'Pull request to merge from Akbar's Antrean' from Akbar-antren into Antrean-Code
Reviewed-on: arie.bagus.2905/Web-Antrean#2
2025-10-02 01:51:47 +00:00
Fanrouver 9261df87e0 push update akbar 2025-10-01 15:05:35 +07:00
Fanrouver d735824d57 akbar first commit 2025-10-01 11:28:06 +07:00
Fanrouver 55d8920a82 wednesday 20 august 2025 2025-08-20 15:03:50 +07:00
Fanrouver 81398549e2 wednesday 13 2025 progress klinik, non functionality script 2025-08-13 11:36:20 +07:00
Fanrouver 0ad29ae5fa first Commit on develop branch 2025-08-12 15:11:09 +07:00
280 changed files with 96605 additions and 10149 deletions

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
# 📋 Project Knowledge — Web Antrean (Sistem Antrian Rumah Sakit)
> Dokumen ini berisi knowledge base lengkap, aturan pengkodean, dan workflow wajib untuk project **web-antrean**.
> **WAJIB DIBACA** sebelum melakukan task apapun.
---
## 🚨 ATURAN WAJIB (MANDATORY RULES)
### Rule 1: Selalu Buat Implementation Plan Sebelum Eksekusi
- **SEBELUM** menulis kode apapun, buat implementation plan di `docs/DEVPLAN.md` atau artifact
- Plan harus mencakup: file yang akan diubah, perubahan spesifik, dan alasan
- Tunggu persetujuan user sebelum eksekusi (kecuali fix minor/typo)
### Rule 2: Selalu Tambahkan Komentar di Setiap Fungsi
- Setiap **function**, **method**, **computed**, dan **watcher** HARUS punya komentar JSDoc/TSDoc
- Format minimum: `/** Deskripsi singkat fungsi ini */`
- Untuk fungsi kompleks, tambahkan `@param`, `@returns`, `@example`
- Komentar inline untuk logika yang tidak obvious
```javascript
// ✅ BENAR
/**
* Mengambil data pasien dari API berdasarkan ID loket
* @param {string} loketId - ID loket yang akan di-fetch
* @returns {Promise<QueuePatient[]>} Daftar pasien dalam antrian
*/
const fetchPatientsForLoket = async (loketId) => { ... }
// ❌ SALAH — tanpa komentar
const fetchPatientsForLoket = async (loketId) => { ... }
```
### Rule 3: Selalu Update Dokumentasi Setelah Perubahan
- **DEVLOG.md**: Tambahkan entry untuk setiap perubahan signifikan
- **DEVPLAN.md**: Update status task yang sudah selesai
- **PRD.md**: Update jika ada perubahan requirements/API/arsitektur
- **AGENTS.md**: Update jika ada knowledge baru yang penting
### Rule 4: Selalu Baca AGENTS.md dan Skills Sebelum Eksekusi
- Baca file ini SEBELUM mulai task apapun
- Baca skill `web-antrean-coding-standards` untuk konvensi kode
- Jangan mengulang kesalahan yang sudah tercatat di bagian Gotchas
### Rule 5: Jangan Mengulang Kesalahan yang Sudah Diketahui
- Cek bagian **⚠️ Gotchas & Known Issues** sebelum debug
- Cek **DEVLOG.md** untuk masalah serupa yang pernah diselesaikan
- Jika menemukan masalah baru, catat di DEVLOG dan Gotchas
---
## 🏗️ Arsitektur & Tech Stack
| Layer | Teknologi |
|---|---|
| **Framework** | Nuxt 3 (`^3.17.7`) dengan SSR |
| **UI Library** | Vuetify 3 (`^3.9.3`) + Material Design Icons (`@mdi/font`) |
| **State Management** | Pinia (`^3.0.3`) + `pinia-plugin-persistedstate` |
| **Styling** | SCSS (`sass-embedded`), design tokens di `assets/scss/` |
| **Auth** | Keycloak OAuth2 (custom server-side implementation, bukan nuxt-auth) |
| **Database (Server)** | SQLite via `better-sqlite3` — untuk konfigurasi device & user sync |
| **Charting** | Chart.js + vue-chartjs + nuxt-charts |
| **QR Code** | `html5-qrcode` (scanner) + `qrcode.vue` / `nuxt-qrcode` (generator) |
| **WebSocket** | Custom composable (`useWebSocket.ts`) ke backend Go |
| **Deployment** | Docker + docker-compose |
| **Testing** | Vitest (unit) + Cypress (E2E) |
| **Font** | Google Fonts (Inter 400-700) |
---
## 📁 Struktur Direktori
```
web-antrean/
├── app.vue # Entry point — store schema migration + loading overlay
├── nuxt.config.ts # Konfigurasi Nuxt, Vuetify, runtime config, proxy
├── .env # Environment variables (API URLs, auth, proxy)
├── docker-compose.yml # Docker deployment config
├── .agents/ # Agent rules & skills
│ ├── AGENTS.md # FILE INI — rules & knowledge base
│ └── skills/ # Coding standards & workflow skills
├── docs/ # Dokumentasi project
│ ├── DEVLOG.md # Development log (kronologis)
│ ├── DEVPLAN.md # Development plan (task breakdown)
│ └── PRD.md # Product Requirements Document
├── assets/scss/ # Design system
│ ├── _colors.scss # Color tokens (CSS variables)
│ ├── _variables.scss # Spacing, breakpoints, etc.
│ ├── _typography.scss # Font styles
│ └── main.scss # Global imports
├── components/
│ ├── common/ # Shared: PageHeader, Avatar, AppSnackbar, SelectionDialog
│ ├── layout/ # SideBar, ProfileMenu, ProfilePopup
│ ├── features/ # Feature-specific (antrean, master, monitoring, queue)
│ ├── AdminKlinik/ # Komponen admin klinik eksekutif
│ ├── GrandPaviliun/ # Komponen Grand Paviliun (Gp*)
│ ├── HakAkses/ # Komponen manajemen hak akses
│ ├── MonitorPasien/ # Komponen monitoring pasien
│ ├── checkin/ # Komponen check-in
│ ├── verification/ # Komponen verifikasi akun
│ └── preview/ # Komponen preview
├── composables/
│ ├── useAuth.ts # Auth state & session management
│ ├── useWebSocket.ts # WebSocket client dengan auto-reconnect
│ ├── useQueueAPI.ts # API wrapper untuk antrian (verificationApiBaseUrl)
│ ├── useVisitAPI.ts # API wrapper untuk visit (externalApiBaseUrl)
│ ├── useQueueSync.ts # Sinkronisasi antrian antar client
│ ├── useCheckIn.ts # Logika check-in pasien
│ ├── useCheckInHistory.ts # Riwayat check-in
│ ├── useClinicAPI.ts # API wrapper klinik
│ ├── useGrandPaviliun.ts # Logika Grand Paviliun
│ ├── useHakAkses.ts # Manajemen hak akses
│ ├── usePermissions.ts # Permission check helper
│ ├── useQRGenerator.ts # QR code generation
│ ├── useQRScanner.ts # QR code scanning (html5-qrcode)
│ ├── useThermalPrint.ts # Thermal printer (tiket antrian)
│ ├── useSnackbar.ts # Toast notification
│ └── useInfiniteScroll.ts # Infinite scroll pagination
├── layouts/
│ ├── default.vue # Main layout — SideBar + middleware auth + checkPageAccess
│ └── empty.vue # Empty layout (untuk halaman tanpa sidebar)
├── middleware/
│ ├── auth.ts # Auth check → redirect ke /LoginPage jika belum login
│ ├── guest.ts # Untuk halaman publik
│ ├── checkPageAccess.ts # Cek akses halaman berdasarkan hak akses
│ └── permissions.ts # Auto-sync permissions dari backend (DISABLED)
├── pages/ # Semua halaman (lihat detail di PRD.md section 8.1)
│ ├── AdminKlinikRuang/ # [kodeKlinik].vue (~111KB)
│ ├── AdminLoket/ # [id].vue (~58KB)
│ ├── CheckInPasien/ # checkIn.vue (~230KB, LARGEST)
│ ├── Anjungan/ # Display anjungan (kiosk)
│ ├── Setting/ # Master data & konfigurasi
│ └── ...
├── server/
│ ├── api/auth/ # Auth endpoints (Keycloak OAuth flow)
│ ├── api/users/ # User management (SQLite-backed)
│ ├── api/config/ # Device configuration (SQLite-backed)
│ ├── api/hak-akses/ # Hak akses management
│ └── utils/ # configDb.ts, sessionStore.ts, userSync.ts
├── stores/ # Pinia stores (Single Source of Truth)
│ ├── clinicStore.js # Master data klinik (~1007 lines)
│ ├── queueStore.ts # Antrian pasien (~3302 lines, LARGEST)
│ ├── loketStore.js # Data loket (~549 lines)
│ ├── ruangStore.js # Data ruang klinik (~558 lines)
│ ├── masterStore.js # BACKWARD COMPAT LAYER (jangan tulis langsung!)
│ ├── penunjangStore.js # Data penunjang medis
│ ├── doctorStore.js # Data dokter (fetched from API)
│ └── ... # anjunganStore, screenStore, navItems1, dll
├── types/ # TypeScript interfaces & types
│ ├── auth.ts, queue.ts, checkin.ts, setting.ts
└── data/
└── users.db # SQLite database (user sync + device config)
```
---
## 🔌 API Endpoints & External Services
### Environment Variables (`.env`)
| Variable | Deskripsi | Value (Juli 2026) |
|---|---|---|
| `ANTRIAN_API_URL` | API antrian utama (Go backend) | `http://10.10.150.131:8089/api/v1` |
| `VERIFICATION_API_BASE_URL` | Alias untuk ANTRIAN_API_URL | (same) |
| `VISIT_API_URL` | API visit/kunjungan pasien | `http://10.10.123.135:8084/api/v1` |
| `WS_API_URL` | WebSocket URL | `ws://10.10.123.135:8084/api/v1/ws` |
| `EXTERNAL_API_BASE_URL` | External API (JWT validation) | `http://10.10.123.135:8084` |
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.rssa.top/realms/sandbox` |
| `AUTH_ORIGIN` | Aplikasi origin URL | `http://10.10.150.175:3000` |
| `HOST` | Dev server host | `http://10.10.150.175:3000` |
### Runtime Config (`nuxt.config.ts`)
```
runtimeConfig.public.verificationApiBaseUrl → ANTRIAN_API_URL (antrian API)
runtimeConfig.public.externalApiBaseUrl → VISIT_API_URL (visit API)
runtimeConfig.public.wsBaseUrl → WS_API_URL (WebSocket)
```
### External Backend APIs
1. **Antrian API** (`verificationApiBaseUrl`) — Go backend
- `GET /klinik/reguler` — Fetch daftar klinik reguler
- `GET /loket/ruang` — Fetch daftar ruangan per klinik
- `GET /loket/{id}` — Fetch pasien per loket
- `GET /dokter/{id}` — Fetch data dokter per klinik
- `POST /tiket/generate` — Generate tiket antrian
- `POST /tiket/checkin` — Check-in pasien
- `GET /permission` — Fetch permission/hak akses
2. **Visit API** (`externalApiBaseUrl`) — Visit/kunjungan
- `GET /visit?klinik_id={id}` — Fetch data pasien per klinik
- `POST /external/validate-token` — Validate JWT token
3. **WebSocket** (`wsBaseUrl`)
- Real-time queue updates, connected via `useWebSocket.ts`
> **PENTING**: Semua kode menggunakan `config.public.verificationApiBaseUrl` dengan fallback hardcoded. Saat ganti IP, ubah di `.env` lalu **restart dev server**. Nuxt hanya membaca `.env` saat startup!
---
## 🗄️ Store Architecture
### Hirarki Store
```
masterStore (BACKWARD COMPAT LAYER — JANGAN TULIS LANGSUNG)
├── clinicStore → Single source of truth untuk data KLINIK
├── loketStore → Data LOKET (counter antrian)
├── ruangStore → Data RUANG per klinik
└── penunjangStore → Data PENUNJANG medis
queueStore → Data ANTRIAN PASIEN (terbesar, 136KB)
doctorStore → Data DOKTER (fetched per klinik)
anjunganStore → Config ANJUNGAN (kiosk)
screenStore → Config SCREEN display
antreanMasukScreenStore → Config screen antrian masuk
navItems1 → Navigation items + hak akses filter
permissionStore → Permission data
verificationStore → Verifikasi akun
```
### Store Schema Migration
- `app.vue` manages `STORE_SCHEMA_VERSION` (currently `3`)
- Saat versi tidak cocok, semua localStorage Pinia di-clear
- **Naikkan versi** saat ada perubahan struktur state
---
## 🔐 Authentication Flow
1. **Login**: User klik login → `keycloak-login.ts` → redirect ke Keycloak
2. **Callback**: Keycloak redirect back → `keycloak-callback.get.ts` → exchange code for token → create session cookie
3. **Session**: `session.get.ts` validates cookie → returns user data
4. **Middleware**: `auth.ts` checks session on every protected route
5. **User Sync**: `userSync.ts` syncs Keycloak users to local SQLite DB
---
## ⚠️ Gotchas & Known Issues (HARUS DIBACA SEBELUM DEBUG)
1. **ENV reload**: Nuxt hanya membaca `.env` saat startup. Setelah ubah `.env`, HARUS restart dev server (`Ctrl+C``npm run dev`).
2. **Hardcoded fallback IPs**: Banyak file masih punya fallback IP lama. Saat ganti IP, ubah `.env` + restart server. JANGAN cuma ubah satu file.
3. **`totalQuota === 0` filter di ruangStore**: Klinik dengan `totalQuota: 0` diskip dari Admin Klinik Ruang. Ini menyembunyikan klinik yang valid tapi belum dikonfigurasi kuotanya. Trade-off yang disengaja.
4. **masterStore adalah proxy**: Jangan langsung tulis data ke masterStore. Selalu gunakan store yang sesuai (clinicStore, loketStore, ruangStore, penunjangStore).
5. **File besar** (hati-hati saat edit):
- `CheckInPasien/checkIn.vue` (~230KB)
- `queueStore.ts` (~136KB)
- `AdminKlinikRuang/[kodeKlinik].vue` (~111KB)
6. **SQLite DB path**: `data/users.db` — digunakan untuk konfigurasi device & user sync. Di Docker, di-mount sebagai volume.
7. **WebSocket auto-upgrade**: `useWebSocket.ts` otomatis upgrade `ws://` ke `wss://` jika halaman diakses via HTTPS.
8. **Pinia persist `paths` type mismatch**: Gunakan `// @ts-ignore` untuk properti `paths` karena kompatibel runtime tapi melanggar validasi tipe.
---
## 🔗 Key File Quick Reference
| Kebutuhan | File |
|---|---|
| Ganti API URL | `.env` → restart dev server |
| Auth flow | `server/api/auth/` |
| Konfigurasi Nuxt | `nuxt.config.ts` |
| Data klinik | `stores/clinicStore.js` |
| Data antrian | `stores/queueStore.ts` |
| Data ruang | `stores/ruangStore.js` |
| Data loket | `stores/loketStore.js` |
| Sidebar/Navigation | `stores/navItems1.ts` + `components/layout/SideBar.vue` |
| Design tokens | `assets/scss/_colors.scss`, `_variables.scss` |
| Database schema | `server/utils/configDb.ts` |
| User management | `server/api/users/` + `server/utils/userSync.ts` |
| WebSocket | `composables/useWebSocket.ts` |
| Thermal print | `composables/useThermalPrint.ts` |
---
## 🚀 Development Commands
```bash
npm run dev # Start dev server
npm run dev:https # Start with HTTPS + host 0.0.0.0
npm run build # Production build
npm run preview # Preview production build
npm run test # Run Vitest
docker compose up -d # Deploy production
```
@@ -0,0 +1,173 @@
---
name: web-antrean-coding-standards
description: Aturan dasar pengkodean dan konvensi untuk project web-antrean berbasis Nuxt 3, Vue 3, TypeScript, Pinia, dan Vuetify.
---
# 📜 Web Antrean Coding Standards
Skill ini berisi standar pengkodean wajib yang harus diikuti saat memodifikasi atau membuat kode baru di project `web-antrean`.
## 1. Komentar & Dokumentasi (WAJIB)
- **Tiap Fungsi/Method/Computed/Watcher HARUS dikomentari.**
- Gunakan JSDoc (`/** ... */`) untuk mendeskripsikan tujuan fungsi.
- Jika fungsi kompleks atau memiliki parameter spesifik, gunakan tag `@param`, `@returns`.
- Berikan komentar singkat (`//`) di dalam body fungsi jika ada logika yang tidak langsung jelas (non-obvious).
```typescript
// ✅ CONTOH BENAR
/**
* Menghitung total antrean aktif untuk loket tertentu.
* @param {string} loketId - ID unik dari loket.
* @returns {number} Jumlah antrean berstatus 'menunggu'.
*/
const countActiveQueue = (loketId: string): number => {
// Hanya hitung pasien yang belum dipanggil atau di-skip
return allPatients.value.filter(p => p.loketId === loketId && p.status === 'menunggu').length;
};
```
## 2. Struktur Komponen (Vue 3 / Nuxt 3)
- Gunakan `<script setup lang="ts">`. Sebisa mungkin gunakan TypeScript.
- **Urutan blok dalam Vue SFC (Single File Component):**
1. `<template>`
2. `<script setup lang="ts">`
3. `<style scoped>` (Lebih disukai `<style scoped lang="scss">`)
- **Organisasi dalam `<script setup>`:**
1. `imports` (Vue, composables, stores, types)
2. `defineProps`, `defineEmits`, `defineExpose`
3. State variables (`ref`, `reactive`)
4. Computed properties (`computed`)
5. Watchers (`watch`, `watchEffect`)
6. Functions / Methods
7. Lifecycle hooks (`onMounted`, `onUnmounted`)
## 3. Composables (`composables/`)
- Nama file dan nama fungsi harus diawali dengan `use` (contoh: `useQueueSync.ts`, `export const useQueueSync = () => {}`).
- Fokuskan composable pada satu fitur spesifik (contoh: `useThermalPrint` khusus print, `useWebSocket` khusus WS).
- Jika mengembalikan state reaktif, kembalikan objek berisi `ref` atau `computed`.
## 4. Pinia Stores (`stores/`)
- Gunakan format Setup Store (seperti composable) alih-alih Option Store.
- Berikan tipe eksplisit (TypeScript) untuk state array/object yang kompleks.
- Jangan melakukan mutasi data store lain secara langsung, gunakan actions.
- **PENTING:** Jika ada store baru, daftarkan di `PINIA_STORE_KEYS` di `app.vue` jika perlu di-persist dan bersihkan localStorage jika `STORE_SCHEMA_VERSION` naik.
```typescript
// ✅ CONTOH SETUP STORE
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { QueuePatient } from '~/types/queue';
export const useMyStore = defineStore('myStore', () => {
// State
const patients = ref<QueuePatient[]>([]);
// Computed
const activePatients = computed(() => patients.value.filter(p => p.status === 'menunggu'));
/**
* Action untuk menambah pasien
* @param {QueuePatient} newPatient
*/
const addPatient = (newPatient: QueuePatient) => {
patients.value.push(newPatient);
};
return { patients, activePatients, addPatient };
});
```
## 5. Integrasi API & Error Handling
- Gunakan `useRuntimeConfig().public` untuk base URL (contoh: `verificationApiBaseUrl`).
- **JANGAN** hardcode IP address (seperti `10.10.123.140`) di dalam kode komponen atau composable. Selalu ambil dari config.
- Gunakan composable wrapper atau `useFetch`/`$fetch` dari Nuxt.
- Harus ada blok `try...catch` untuk setiap pemanggilan API, dengan logging error yang jelas (minimal `console.error`).
## 6. Styling
- Gunakan class Vuetify sebanyak mungkin (`d-flex`, `mt-4`, `text-h5`, `text-primary`).
- Jika butuh custom style, gunakan `<style scoped lang="scss">` dan manfaatkan variabel dari `assets/scss/_colors.scss` atau `_variables.scss` jika memungkinkan.
## 7. Naming Conventions
- **Komponen:** PascalCase (contoh: `PatientCard.vue`, `AdminKlinik.vue`).
- **File/Folder (selain komponen):** camelCase atau kebab-case tergantung standar folder (Nuxt standard: pages biasanya kebab-case atau PascalCase jika spesifik view).
- **Fungsi/Variabel:** camelCase (contoh: `fetchPatients`, `isActive`).
- **Tipe Data (Types/Interfaces):** PascalCase (contoh: `QueuePatient`).
## 8. Aturan Refactoring (Refactoring Rules)
Saat melakukan refactoring (terutama mengkonversi `.js` lama ke `.ts` atau memecah file besar), ikuti aturan wajib berikut:
- **Incremental Refactoring (Bertahap):** Jangan merombak seluruh file raksasa (seperti `queueStore` atau `checkIn.vue`) sekaligus. Pisahkan sebagian logika (contoh: pisahkan API call ke `useQueueAPI`, WS ke `useQueueSync`) secara bertahap untuk meminimalisasi *blast radius* (kerusakan beruntun).
- **Isolasi Logika (Separation of Concerns):** Komponen `.vue` hanya boleh berisi logika UI (presentational). Logika bisnis berat harus dipindah ke `composables/` atau `stores/`.
- **Backward Compatibility (Kompabilitas Mundur):** Jika mengubah struktur *store* yang banyak dipakai (contoh: `masterStore`), jangan langsung menghapus properti lama. Buat *proxy/delegate* ke store baru sampai semua komponen selesai di-update.
- **No Implicit `any`:** Saat memigrasi `.js` ke `.ts`, WAJIB mendefinisikan tipe data/interface yang jelas di direktori `types/`. Sebisa mungkin hindari penggunaan tipe `any`.
- **Pertahankan Business Logic:** Refactoring HANYA mengubah struktur/kebersihan kode, **BUKAN** mengubah alur bisnis kecuali diminta secara spesifik. Pastikan fitur safety seperti *polling fallback 30s* atau *auto-reconnect WS* tidak terhapus tanpa sengaja.
- **Dokumentasikan Perubahan:** Jika refactor mengubah alur arsitektur secara signifikan, selalu catat di `docs/DEVLOG.md` dan perbarui `AGENTS.md`.
## 9. Reusability & Prinsip DRY (Don't Repeat Yourself)
- Jika suatu fungsi, blok UI, atau logika API dipakai di lebih dari dua tempat, **wajib** diekstrak menjadi `Composable` (untuk logika), `Component` (untuk UI), atau file utility di `utils/`.
- Jangan melakukan copy-paste kode panjang antar halaman.
## 10. Penggunaan State (Lokal vs Global)
- **Lokal (`ref` / `reactive`):** Gunakan untuk state yang hanya hidup di satu komponen (contoh: status loading tombol, form input sementara, toggle modal).
- **Global (Pinia):** Gunakan HANYA untuk state yang perlu dibagikan lintas halaman/komponen (contoh: data pasien antrean berjalan, data user login, master data klinik).
## 11. Props & Emits (Komunikasi Antar Komponen)
- Definisikan `props` secara eksplisit menggunakan `defineProps<{ ... }>()` dengan tipe data yang ketat.
- Gunakan `defineEmits<{ ... }>()` untuk event child-to-parent. Hindari memanipulasi prop secara langsung dari child komponen (mutate prop mutation).
## 12. Manajemen Siklus Hidup (Lifecycle & Cleanup)
- Jika menginisiasi *event listener* global (`window.addEventListener`), *timer* (`setInterval`), atau *WebSocket subscription* di `onMounted`, Anda **WAJIB** membersihkannya di `onUnmounted`.
- Kegagalan melakukan *cleanup* akan menyebabkan kebocoran memori (memory leak) terutama di aplikasi SPA (Single Page Application).
## 13. Navigasi & Routing (Nuxt 3)
- Gunakan `<NuxtLink>` untuk navigasi internal di template. JANGAN menggunakan tag `<a>` biasa karena akan memicu *full page reload*.
- Untuk navigasi terprogram (programmatic) di dalam script, gunakan `navigateTo()` atau `useRouter().push()`.
## 14. Keamanan Dasar (Security Practices)
- **JANGAN PERNAH** meletakkan token JWT, password, atau secret key secara hardcode di sisi klien.
- Baca variabel sensitif melalui `useRuntimeConfig()` yang disuplai dari `.env`.
- Selalu percayakan validasi otorisasi di sisi *server* (middleware/Nitro), jangan hanya menyembunyikan tombol di UI.
## 15. Penanganan Feedback UI (Error & Success Boundaries)
- Jangan biarkan error API tertelan diam-diam (`swallowed error`).
- Selalu tampilkan umpan balik ke pengguna jika terjadi kegagalan (misalnya menggunakan komponen `AppSnackbar` yang sudah ada).
- Berikan indikator visual (spinner/loading state) saat memanggil API.
## 16. Magic Numbers & Magic Strings
- Hindari hardcode angka atau string spesifik yang memiliki arti logika bisnis di tengah komponen (contoh: `if (status === 3)` atau `if (role === 'superadmin')`).
- Pindahkan ke file konstan di `constants/` atau jadikan `Enum` di direktori `types/`.
## 17. Penggunaan Proxy API (Bypass CORS)
- Untuk endpoint eksternal yang terhalang CORS (seperti API Visit atau API Antrian asli), panggil selalu via *Proxy Route* bawaan Nuxt Nitro (contoh: panggil `/visit-api/...` atau `/klinik-api/...`).
- Jangan mencoba menebak atau merubah setting header CORS di sisi *client* Vue.
## 18. Konsistensi Design System
- DILARANG melakukan *hardcode* warna HEX/RGB di tag `<style>` (contoh: `color: #ff0000;`).
- Selalu gunakan SCSS variables dari `assets/scss/_colors.scss` (contoh: `color: var(--color-primary);`) atau gunakan *utility classes* bawaan Vuetify.
## 19. Optimasi Performa Vue (v-memo, v-once, Lazy)
- Untuk *list rendering* yang sangat panjang (ratusan pasien), pertimbangkan penggunaan pagination, infinite scroll, atau virtual scrolling.
- Gunakan `LazyComponent` (prefix `Lazy` di Nuxt) untuk modal atau komponen berat yang tidak langsung muncul di layar.
## 20. Penanganan Koneksi Real-time (WebSocket)
- Pastikan ada mekanisme **Auto-Reconnect** dengan *backoff delay*.
- Selalu miliki **Polling Fallback**. Jika WS mati total, sistem harus fallback mengambil data via HTTP `setInterval` setiap 30 detik agar layar antrean tidak *stuck*.
- Cegah duplikasi data pesan (implementasi *deduplication logic* berdasarkan ID unik pesan).
## 21. Isolasi State per Entitas (Loket / Ruang)
- Jangan gunakan variabel state tunggal untuk sesuatu yang berjalan paralel.
- Contoh buruk: `currentPatient` (berisiko loket A tertukar pasien dengan loket B).
- Contoh benar: `currentPatientPerLoket: Record<string, Patient>` (diakses dengan ID loket).
## 22. Format Koding & Tanda Baca
- Ikuti standar *formatting* yang ada: indentasi 2 spasi, gunakan *single quotes* (`'`) untuk string JS/TS, dan selalu gunakan tanda titik koma (`;`) di akhir *statement* script.
## 23. Standar Pengujian (Testing Readiness)
- Setiap logika kalkulasi yang rumit (contoh: perhitungan estimasi waktu tunggu, filter spesialisasi) harus ditulis murni (pure function) agar mudah di-unit test nantinya (Vitest). Jangan mencampur logika ini dengan DOM manipulation.
## 24. Git Workflow & Commit Messages
- Tulis pesan *commit* yang jelas dan bermakna mengikuti pola *Conventional Commits*:
- `feat: ...` (fitur baru)
- `fix: ...` (perbaikan bug)
- `refactor: ...` (perubahan struktur tanpa ubah fitur)
- `docs: ...` (pembaruan dokumentasi)
- Satu *commit* sebaiknya fokus pada satu perubahan logika/fitur saja.
+36
View File
@@ -0,0 +1,36 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDEs and OS files
.DS_Store
.fleet
.idea
.vscode
Thumbs.db
# Local env files
.env
.env.*
!.env.example
!.env.docker.example
+87
View File
@@ -0,0 +1,87 @@
# # .env
# # A random string used to hash tokens, sign cookies, and generate cryptographic keys.
# # You can generate one here: https://generate-secret.vercel.app/32
# NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
# # The base URL of your application
# AUTH_ORIGIN="http://localhost:3000"
# # Keycloak Credentials
# # Get these from your Keycloak client configuration
# KEYCLOAK_CLIENT_ID="nuxt-app"
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
# .env
# A random string used to hash tokens, sign cookies, and generate cryptographic keys.
# You can generate one here: https://generate-secret.vercel.app/32
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
# The base URL of your application
# Keycloak Credentials
# Get these from your Keycloak client configuration
# KEYCLOAK_CLIENT_ID="nuxt-app"
# KEYCLOAK_CLIENT_SECRET="OaB90t1HEEFBn31PUX4qELWHwErnwFtg"
# KEYCLOAK_ISSUER="http://localhost:8080/realms/rssa-app"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
SESSION_DURATION_HOURS=1
OAUTH_STATE_DURATION_MINUTES=10
#AUTH ORIGIN UNTUK ACCESS APLIKASI login dari KEYCLOAK
# AUTH_ORIGIN="http://10.10.150.175:3000"
# AUTH_ORIGIN="0.0.0.0"
# AUTH_ORIGIN="http://localhost:3000"
AUTH_ORIGIN="https://antrean.dev.rssa.id"
# AUTH_ORIGIN="https://antrean.rssa.id"
#HOST UNTUK ACCESS APLIKASI
# HOST="http://localhost:3000"
# HOST="http://10.10.150.175:3000"
# HOST="https://antrean.rssa.id"
# HOST="https://antrean.dev.rssa.id"
HOST="0.0.0.0"
# HOST=
# Custom Keycloak logout endpoint UBAH SESUAI REALM DAN DOMAIN KEYCLOAK ANDA
KEYCLOAK_LOGOUT_URI="https://auth.rssa.top/realms/sandbox/protocol/openid-connect/logout"
# Pilih salah satu dari dua opsi di bawah ini sesuai kebutuhan Anda:
# digunakan untuk redirect setelah logout dari KEYCLOAK
# Untuk redirect tanpa query parameter:
# POST_LOGOUT_REDIRECT_URI="http://10.10.150.175:3000/LoginPage"
# POST_LOGOUT_REDIRECT_URI="http://localhost:3000/LoginPage"
POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage"
# POST_LOGOUT_REDIRECT_URI=
# ATAU untuk redirect dengan query parameter:
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
# ATAU untuk redirect dengan query parameter:
# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success"
# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success"
# External API Configuration
# API eksternal untuk validasi JWT token
EXTERNAL_API_BASE_URL="http://10.10.150.100:8084"
EXTERNAL_API_TIMEOUT=10000
# API untuk ekstrak expertise dan verifikasi akun
# Digunakan di pages/verifikasiAkun/VerifikasiAkun.vue
EKSTRAK_EXPERTISE_URL="http://10.10.123.218/ekstrakexpertise"
VERIFICATION_API_BASE_URL="http://10.10.123.140:8089/api/v1"
# Simplified Modular API URLs (New Config)
ANTRIAN_API_URL="http://10.10.123.140:8089/api/v1"
VISIT_API_URL="http://10.10.123.135:8084/api/v1"
WS_API_URL="ws://10.10.123.135:8084/api/v1/ws"
+9
View File
@@ -6,6 +6,12 @@
.cache .cache
dist dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies # Node dependencies
node_modules node_modules
@@ -22,3 +28,6 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
REFACTORING_STATUS.md
CHECKIN_DOCUMENTATION.md
USERLOGIN_DOCUMENTATION.md
+1
View File
@@ -0,0 +1 @@
setups.@nuxt/test-utils="3.23.0"
+260
View File
@@ -0,0 +1,260 @@
# Cara Mengakses Database better-sqlite3
## 📍 Lokasi Database
- **Path relatif**: `data/users.db`
- **Path lengkap**: `E:\PROJECT\ddddd\Web-Antrean - Copy (2)\data\users.db`
---
## 🔧 Metode 1: Menggunakan Node.js Script (Recommended)
Karena project ini sudah menggunakan `better-sqlite3`, cara termudah adalah membuat script Node.js:
### Contoh Script untuk Query Database
```javascript
// scripts/query-db.js
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '..', 'data', 'users.db');
const db = new Database(dbPath);
// Contoh: Get all users
const users = db.prepare('SELECT * FROM users').all();
console.log('All users:', users);
// Contoh: Get user by ID
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('user-id-here');
console.log('User:', user);
// Contoh: Count users
const count = db.prepare('SELECT COUNT(*) as total FROM users').get();
console.log('Total users:', count.total);
db.close();
```
**Jalankan dengan:**
```bash
node scripts/query-db.js
```
---
## 🔧 Metode 2: Menggunakan SQLite CLI
### Install SQLite CLI (jika belum ada)
**Windows:**
1. Download dari: https://www.sqlite.org/download.html
2. Atau install via Chocolatey: `choco install sqlite`
3. Atau install via Scoop: `scoop install sqlite`
**Atau gunakan npx (tidak perlu install):**
```bash
npx sqlite3 data/users.db
```
### Perintah SQLite CLI
```bash
# Buka database
sqlite3 data/users.db
# Atau dengan npx (jika SQLite tidak terinstall)
npx sqlite3 data/users.db
# Di dalam SQLite shell:
.tables # Lihat semua tabel
.schema users # Lihat schema tabel users
SELECT * FROM users; # Lihat semua data
SELECT * FROM users LIMIT 10; # Lihat 10 baris pertama
.mode column # Format output sebagai kolom
.headers on # Tampilkan header kolom
.quit # Keluar
```
---
## 🔧 Metode 3: Menggunakan GUI Tools
### 1. **DB Browser for SQLite** (Gratis, Recommended)
- Download: https://sqlitebrowser.org/
- Buka file: `data/users.db`
- Bisa edit, query, dan export data dengan mudah
### 2. **SQLiteStudio** (Gratis)
- Download: https://sqlitestudio.pl/
- Cross-platform, open source
### 3. **DBeaver** (Gratis)
- Download: https://dbeaver.io/
- Universal database tool, support banyak database termasuk SQLite
### 4. **VS Code Extension**
- Install extension: **SQLite Viewer** atau **SQLite**
- Buka file `data/users.db` langsung di VS Code
---
## 🔧 Metode 4: Menggunakan API Endpoints yang Sudah Ada
Project ini sudah punya API endpoints untuk mengakses data:
```bash
# Get all users
GET /api/users/list
# Get current user
GET /api/users/current
# Get user by ID
GET /api/users/[id]
# Create user
POST /api/users/create
# Update user
PATCH /api/users/[id]
# Delete user
DELETE /api/users/[id]
```
---
## 📊 Contoh Query Berguna
### Melihat semua users:
```sql
SELECT * FROM users;
```
### Melihat users dengan format tanggal yang readable:
```sql
SELECT
id,
namaLengkap,
namaUser,
email,
datetime(lastLogin, 'unixepoch') as lastLoginFormatted,
datetime(createdAt, 'unixepoch') as createdAtFormatted
FROM users;
```
### Mencari user berdasarkan nama:
```sql
SELECT * FROM users WHERE namaLengkap LIKE '%nama%';
```
### Mencari user berdasarkan username:
```sql
SELECT * FROM users WHERE namaUser = 'username';
```
### Melihat users yang belum login:
```sql
SELECT * FROM users WHERE lastLogin IS NULL;
```
### Melihat users yang baru dibuat:
```sql
SELECT * FROM users ORDER BY createdAt DESC LIMIT 10;
```
### Export data ke CSV:
```sql
.mode csv
.headers on
.output users_export.csv
SELECT * FROM users;
.output stdout
```
---
## ⚠️ Catatan Penting
1. **Backup sebelum edit manual**: Selalu backup database sebelum melakukan perubahan manual
2. **Tutup koneksi**: Pastikan aplikasi tidak sedang menggunakan database saat mengedit manual
3. **Format timestamps**: `lastLogin`, `createdAt`, `updatedAt` disimpan sebagai Unix timestamp (seconds)
4. **JSON fields**: `roles`, `realmRoles`, `accountRoles`, `resourceRoles`, `groups` disimpan sebagai JSON string
---
## 🛠️ Quick Access Script
Buat file `scripts/db-access.js` untuk akses cepat:
```javascript
const Database = require('better-sqlite3');
const path = require('path');
const readline = require('readline');
const dbPath = path.join(process.cwd(), 'data', 'users.db');
const db = new Database(dbPath);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log('📊 Database Access Tool');
console.log('Database path:', dbPath);
console.log('\nAvailable commands:');
console.log(' list - List all users');
console.log(' count - Count total users');
console.log(' schema - Show table schema');
console.log(' query <SQL> - Run custom SQL query');
console.log(' exit - Exit\n');
function prompt() {
rl.question('> ', (input) => {
const [cmd, ...args] = input.trim().split(' ');
try {
switch(cmd.toLowerCase()) {
case 'list':
const users = db.prepare('SELECT id, namaLengkap, namaUser, email FROM users').all();
console.table(users);
break;
case 'count':
const count = db.prepare('SELECT COUNT(*) as total FROM users').get();
console.log(`Total users: ${count.total}`);
break;
case 'schema':
const schema = db.prepare("PRAGMA table_info(users)").all();
console.table(schema);
break;
case 'query':
const sql = args.join(' ');
if (!sql) {
console.log('Error: Please provide SQL query');
break;
}
const result = db.prepare(sql).all();
console.table(result);
break;
case 'exit':
db.close();
rl.close();
return;
default:
console.log('Unknown command. Type "exit" to quit.');
}
} catch (error) {
console.error('Error:', error.message);
}
prompt();
});
}
prompt();
```
Jalankan dengan: `node scripts/db-access.js`
+371
View File
@@ -0,0 +1,371 @@
# Checklist Perubahan Domain
Ketika mengubah domain aplikasi, ikuti checklist berikut:
## 1. File Environment (.env)
**Ubah `AUTH_ORIGIN` sesuai domain baru:**
### Development:
```env
AUTH_ORIGIN="https://antrean.dev.rssa.id"
```
### Production:
```env
AUTH_ORIGIN="https://antrean.rssa.id"
```
**Lokasi:** `.env` (atau `.env.development` / `.env.production`)
---
## 2. Keycloak Configuration
**Di Keycloak Admin Console → Clients → [Your Client] → Settings:**
### Valid Redirect URIs:
Tambahkan:
- `https://antrean.dev.rssa.id/api/auth/keycloak-callback` (development)
- `https://antrean.rssa.id/api/auth/keycloak-callback` (production)
### Valid Post Logout Redirect URIs:
Tambahkan:
- `https://antrean.dev.rssa.id/LoginPage*` (development)
- `https://antrean.rssa.id/LoginPage*` (production)
### Web Origins:
Tambahkan:
- `https://antrean.dev.rssa.id` (development)
- `https://antrean.rssa.id` (production)
**Catatan:** Gunakan wildcard `*` untuk post logout redirect agar bisa handle query parameters.
---
## 3. nuxt.config.ts (Opsional)
**Jika menggunakan IP address untuk development:**
```typescript
devServer: {
port: 3000,
host: '0.0.0.0' // atau IP address jika perlu
}
```
**Untuk production dengan domain, biasanya tidak perlu diubah.**
---
## 4. Kubernetes Configuration
### 4.1. ConfigMap (untuk non-sensitive environment variables)
**Buat atau update ConfigMap:**
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: antrean-config
namespace: default # atau namespace Anda
data:
AUTH_ORIGIN: "https://antrean.rssa.id" # atau https://antrean.dev.rssa.id untuk dev
KEYCLOAK_ISSUER: "https://auth.rssa.top/realms/sandbox"
KEYCLOAK_CLIENT_ID: "akbar-test"
```
**Atau gunakan kubectl:**
```bash
kubectl create configmap antrean-config \
--from-literal=AUTH_ORIGIN=https://antrean.rssa.id \
--from-literal=KEYCLOAK_ISSUER=https://auth.rssa.top/realms/sandbox \
--from-literal=KEYCLOAK_CLIENT_ID=akbar-test \
-n <your-namespace>
```
### 4.2. Secret (untuk sensitive data)
**Buat Secret untuk credentials:**
```yaml
apiVersion: v1
kind: Secret
metadata:
name: antrean-secrets
namespace: default
type: Opaque
stringData:
KEYCLOAK_CLIENT_SECRET: "your-secret-here"
NUXT_AUTH_SECRET: "your-super-secret-string-of-at-least-32-characters"
```
**Atau gunakan kubectl:**
```bash
kubectl create secret generic antrean-secrets \
--from-literal=KEYCLOAK_CLIENT_SECRET=your-secret \
--from-literal=NUXT_AUTH_SECRET=your-auth-secret \
-n <your-namespace>
```
### 4.3. Ingress (untuk domain routing)
**Update Ingress dengan domain baru:**
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: antrean-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod" # atau issuer Anda
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx # atau ingress class Anda
tls:
- hosts:
- antrean.rssa.id
- antrean.dev.rssa.id
secretName: antrean-tls-secret
rules:
- host: antrean.rssa.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: antrean-service
port:
number: 3000
- host: antrean.dev.rssa.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: antrean-dev-service
port:
number: 3000
```
### 4.4. Deployment (update environment variables)
**Update Deployment untuk menggunakan ConfigMap dan Secret:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: antrean-app
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: antrean
template:
metadata:
labels:
app: antrean
spec:
containers:
- name: antrean
image: your-registry/antrean:latest
ports:
- containerPort: 3000
env:
# Dari ConfigMap
- name: AUTH_ORIGIN
valueFrom:
configMapKeyRef:
name: antrean-config
key: AUTH_ORIGIN
- name: KEYCLOAK_ISSUER
valueFrom:
configMapKeyRef:
name: antrean-config
key: KEYCLOAK_ISSUER
- name: KEYCLOAK_CLIENT_ID
valueFrom:
configMapKeyRef:
name: antrean-config
key: KEYCLOAK_CLIENT_ID
# Dari Secret
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: antrean-secrets
key: KEYCLOAK_CLIENT_SECRET
- name: NUXT_AUTH_SECRET
valueFrom:
secretKeyRef:
name: antrean-secrets
key: NUXT_AUTH_SECRET
envFrom:
# Atau load semua dari ConfigMap (opsional)
# - configMapRef:
# name: antrean-config
```
**Atau update dengan kubectl:**
```bash
kubectl set env deployment/antrean-app \
AUTH_ORIGIN=https://antrean.rssa.id \
--from=configmap/antrean-config \
-n <your-namespace>
```
### 4.5. Service (biasanya tidak perlu diubah)
Service biasanya tidak perlu diubah karena hanya routing internal.
### 4.6. Rollout/Restart Deployment
**Setelah update ConfigMap/Secret, restart pods:**
```bash
# Method 1: Rolling restart
kubectl rollout restart deployment/antrean-app -n <your-namespace>
# Method 2: Delete pods (akan auto-recreate)
kubectl delete pods -l app=antrean -n <your-namespace>
# Method 3: Scale down then up
kubectl scale deployment antrean-app --replicas=0 -n <your-namespace>
kubectl scale deployment antrean-app --replicas=2 -n <your-namespace>
```
### 4.7. Verifikasi di Kubernetes
```bash
# Cek ConfigMap
kubectl get configmap antrean-config -n <namespace> -o yaml
# Cek Secret (values akan di-encode base64)
kubectl get secret antrean-secrets -n <namespace> -o yaml
# Cek Ingress
kubectl get ingress antrean-ingress -n <namespace>
# Cek pods environment
kubectl exec -it <pod-name> -n <namespace> -- env | grep AUTH_ORIGIN
# Cek logs
kubectl logs -f deployment/antrean-app -n <namespace>
```
---
## 5. Server/Deployment Configuration (Non-Kubernetes)
### Nginx/Reverse Proxy (jika ada):
- Update `server_name` dengan domain baru
- Update SSL certificate untuk domain baru
- Pastikan proxy_pass mengarah ke aplikasi yang benar
### Docker (jika ada):
- Update environment variables di docker-compose.yml atau Dockerfile
- Update port mapping jika perlu
---
## 6. DNS Configuration
- Pastikan domain sudah pointing ke IP server yang benar
- Pastikan A record atau CNAME sudah dikonfigurasi
- Tunggu DNS propagation (bisa beberapa menit sampai 24 jam)
---
## 7. SSL Certificate
- Pastikan SSL certificate sudah diinstal untuk domain baru
- Pastikan certificate valid dan tidak expired
- Untuk production, gunakan Let's Encrypt atau certificate resmi
---
## 8. Restart Server/Deployment
**PENTING:** Setelah mengubah `.env`:
1. Stop server (Ctrl+C)
2. Start server lagi (`npm run dev` atau `npm run build && npm start`)
Environment variables hanya dimuat saat server start!
---
## 9. Verifikasi
Setelah semua perubahan, verifikasi:
1. **Cek log server saat login:**
```
🔧 AUTH_ORIGIN from config: https://antrean.dev.rssa.id
🔗 Redirect URI being sent to Keycloak: https://antrean.dev.rssa.id/api/auth/keycloak-callback
```
2. **Test login flow:**
- Login harus redirect ke Keycloak
- Setelah login, harus kembali ke aplikasi
- Tidak ada error "Invalid redirect URI"
3. **Test logout flow:**
- Logout harus redirect ke Keycloak
- Setelah logout, harus kembali ke login page
- Tidak ada error "Invalid redirect URI"
---
## File yang TIDAK Perlu Diubah
✅ **Kode aplikasi** - Sudah menggunakan `config.public.authUrl` dari environment variable
✅ **Server API handlers** - Sudah menggunakan `config.public.authUrl`
✅ **Components** - Tidak ada hardcoded domain
---
## Contoh Konfigurasi Lengkap
### Development (.env.development):
```env
AUTH_ORIGIN="https://antrean.dev.rssa.id"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="your-secret"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
```
### Production (.env.production):
```env
AUTH_ORIGIN="https://antrean.rssa.id"
KEYCLOAK_CLIENT_ID="akbar-test"
KEYCLOAK_CLIENT_SECRET="your-secret"
KEYCLOAK_ISSUER="https://auth.rssa.top/realms/sandbox"
NUXT_AUTH_SECRET="your-super-secret-string-of-at-least-32-characters"
```
---
## Troubleshooting
### Masih redirect ke domain lama?
- ✅ Pastikan server sudah restart
- ✅ Cek `.env` file sudah benar
- ✅ Clear browser cache
- ✅ Cek Keycloak configuration sudah benar
### Error "Invalid redirect URI"?
- ✅ Pastikan URI sudah ditambahkan di Keycloak
- ✅ Pastikan format URI sama persis (dengan/tanpa trailing slash)
- ✅ Pastikan menggunakan HTTPS jika domain menggunakan HTTPS
### Session tidak tersimpan?
- ✅ Pastikan cookie settings sesuai (secure: true untuk HTTPS)
- ✅ Cek browser console untuk cookie errors
- ✅ Pastikan domain di cookie sesuai dengan domain aplikasi
+332
View File
@@ -0,0 +1,332 @@
# Dashboard Dummy Data Documentation
Dokumentasi lengkap data dummy yang digunakan di `pages/Dashboard.vue`
## 📊 Statistics Cards (Header Mini Stats)
Data statistik mini yang ditampilkan di header:
```javascript
{
pasienHariIni: 324,
antreanAktif: 47
}
```
## 📈 Main Statistics Cards
Data untuk 4 kartu statistik utama:
### 1. Pasien Hari Ini
```javascript
{
label: 'Pasien Hari Ini',
value: '324',
icon: 'mdi-account-heart',
iconColor: 'white',
color: 'primary',
change: '+18.2%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
```
### 2. Antrean Aktif
```javascript
{
label: 'Antrean Aktif',
value: '47',
icon: 'mdi-clock-fast',
iconColor: 'white',
color: 'secondary',
change: '+5',
changeType: 'positive',
changeIcon: 'mdi-arrow-up'
}
```
### 3. Rata-rata Tunggu
```javascript
{
label: 'Rata-rata Tunggu',
value: '12 min',
icon: 'mdi-timer-outline',
iconColor: 'white',
color: 'success',
change: '-3 min',
changeType: 'positive',
changeIcon: 'mdi-trending-down'
}
```
### 4. Tingkat Kehadiran
```javascript
{
label: 'Tingkat Kehadiran',
value: '89.5%',
icon: 'mdi-check-circle',
iconColor: 'white',
color: 'primary',
change: '+2.3%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
```
---
## 📊 Chart 1: Tren Kunjungan Pasien (Line/Area Chart)
Chart yang menampilkan tren kunjungan pasien bulanan untuk tahun 2025.
### Labels (Bulan)
```javascript
[
"Jan",
"Feb",
"Mar",
"Apr",
"Mei",
"Jun",
"Jul",
"Ags",
"Sep",
"Okt",
"Nov",
"Des",
];
```
### Dataset 1: Pasien Umum
```javascript
{
label: 'Pasien Umum',
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
backgroundColor: 'rgba(86, 126, 231, 0.2)',
borderColor: '#567EE7' // primary-500
}
```
**Total Pasien Umum 2025**: 13,070 pasien
### Dataset 2: Pasien BPJS
```javascript
{
label: 'Pasien BPJS',
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
backgroundColor: 'rgba(255, 132, 65, 0.2)',
borderColor: '#FF8441' // secondary-500
}
```
**Total Pasien BPJS 2025**: 18,790 pasien
**Grand Total**: 31,860 pasien sepanjang tahun 2025
---
## 💰 Chart 2: Status Pembayaran (Doughnut Chart)
Distribusi metode pembayaran pasien.
### Data
```javascript
{
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
datasets: [{
data: [1850, 680, 320, 280]
}]
}
```
### Breakdown Persentase
| Metode Pembayaran | Jumlah | Persentase |
| ----------------- | --------- | ---------- |
| BPJS Kesehatan | 1,850 | 58.6% |
| Umum/Tunai | 680 | 21.5% |
| Asuransi Swasta | 320 | 10.1% |
| Corporate | 280 | 8.9% |
| **Total** | **3,130** | **100%** |
---
## ⏱️ Chart 3: Waktu Tunggu per Poli (Horizontal Bar Chart)
Rata-rata waktu tunggu untuk setiap poliklinik (dalam menit).
### Data
```javascript
{
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
datasets: [{
label: 'Waktu Tunggu (menit)',
data: [15, 12, 8, 18, 10, 22]
}]
}
```
### Ranking Waktu Tunggu
| Rank | Poliklinik | Waktu Tunggu |
| ---- | ------------ | ------------ |
| 1 | Poli Gigi | 8 menit ⚡ |
| 2 | Poli THT | 10 menit |
| 3 | Poli Anak | 12 menit |
| 4 | Poli Umum | 15 menit |
| 5 | Poli Mata | 18 menit |
| 6 | Poli Jantung | 22 menit 🐌 |
**Rata-rata Total**: 14.17 menit
---
## ✅ Chart 4: Tingkat Kehadiran (Polar Area Chart)
Status kehadiran pasien dalam seminggu.
### Data
```javascript
{
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
datasets: [{
data: [1850, 420, 280, 150, 230]
}]
}
```
### Breakdown Persentase
| Status | Jumlah | Persentase |
| -------------------- | --------- | ---------- |
| Hadir Tepat Waktu ✅ | 1,850 | 62.6% |
| Hadir Terlambat ⏰ | 420 | 14.2% |
| Tidak Hadir ❌ | 280 | 9.5% |
| Batal 🚫 | 150 | 5.1% |
| Reschedule 📅 | 230 | 7.8% |
| **Total** | **2,960** | **100%** |
**Tingkat Kehadiran Total** (Hadir Tepat Waktu + Hadir Terlambat): 76.8%
---
## 🎨 Color Palette
Palet warna yang digunakan dalam dashboard:
### Primary Colors
```javascript
{
700: '#3556AE',
600: '#3A61C9',
500: '#567EE7',
400: '#7898EC',
300: '#9AB2F1',
200: '#BBCBF5'
}
```
### Secondary Colors
```javascript
{
700: '#E65A0D',
600: '#F16F29',
500: '#FF8441',
400: '#FF9D67',
300: '#FFB58D'
}
```
### Success Colors
```javascript
{
700: '#008D65',
600: '#33A484',
500: '#59B59B',
400: '#80C6B2'
}
```
### Neutral Colors
```javascript
{
100: '#FFFFFF',
300: '#F5F7FA',
500: '#CDD4DC',
700: '#717171',
900: '#212121'
}
```
---
## 📤 Export Options
Menu export data yang tersedia:
```javascript
[
{ title: "JSON (.json)", type: "json", icon: "mdi-code-json" },
{ title: "CSV (.csv)", type: "csv", icon: "mdi-file-delimited" },
{
title: "Excel (.xlsx) - Server Only",
type: "excel",
icon: "mdi-file-excel",
},
{ title: "PDF (.pdf) - Placeholder", type: "pdf", icon: "mdi-file-pdf" },
];
```
---
## 📝 Summary Insights
### Total Visitors
- **Hari Ini**: 324 pasien (+18.2%)
- **Bulan Ini** (estimasi): ~2,617 pasien
- **Tahun 2025** (dari chart): 31,860 pasien
### Performance Metrics
- **Antrean Aktif**: 47 (+5)
- **Rata-rata Waktu Tunggu**: 12 menit (-3 min improvement)
- **Tingkat Kehadiran**: 89.5% (+2.3%)
- **Kehadiran Tepat Waktu**: 62.6%
### Top Performing Clinic
- **Tercepat**: Poli Gigi (8 menit)
- **Terlambat**: Poli Jantung (22 menit)
### Payment Distribution
- **BPJS dominan**: 58.6% dari total transaksi
- **Non-BPJS**: 41.4% (Umum + Asuransi + Corporate)
---
## 🔧 Usage Notes
File ini mendokumentasikan semua data dummy yang digunakan dalam komponen Dashboard.vue. Data ini dapat digunakan sebagai:
1. **Reference** untuk struktur data API
2. **Testing data** untuk pengembangan
3. **Template** untuk implementasi data real dari backend
4. **Documentation** untuk tim pengembang
Semua data bersifat **dummy/sample** dan harus diganti dengan data real dari API/backend saat production.
+51
View File
@@ -0,0 +1,51 @@
# Dockerfile for web-antrean Nuxt.js Application
# Multi-stage build for optimized image size and native modules support
# Stage 1: Build Stage
FROM node:20-alpine AS builder
# Install build dependencies for compiling native addons (like better-sqlite3)
RUN apk add --no-cache python3 make g++ gcc libc-dev
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including devDependencies for build)
RUN npm ci
# Copy application files
COPY . .
# Build the application for production
RUN npm run build
# Stage 2: Production Stage
FROM node:20-alpine AS runner
# Install runtime dependencies (like libc6-compat for compatibility with precompiled binaries)
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy built output and compiled node_modules from builder stage
# This avoids needing to install build tools (g++, make, python) in this slim production stage
COPY --from=builder /app/.output /app/.output
COPY --from=builder /app/node_modules /app/node_modules
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# Start the application
CMD ["node", ".output/server/index.mjs"]
+763
View File
@@ -0,0 +1,763 @@
# API Endpoint Documentation - Hak Akses
## Deskripsi
Dokumentasi ini menjelaskan semua API endpoint yang diperlukan untuk fitur **Hak Akses** di halaman `/pages/Setting/HakAkses.vue`. Endpoint-endpoint ini akan dihubungkan ke backend API yang disediakan oleh tim backend.
---
## Base URL
```
{API_BASE_URL}/api/v1
```
**Catatan:** Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya (contoh: `http://10.10.123.140:8089/`)
---
## 1. Get List Users
Mengambil daftar semua users beserta roles, groups, dan informasi lainnya yang diperlukan untuk form hak akses.
### Endpoint
```
GET /api/users/list
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:** Tidak ad
### Response
**Success (200 OK):**
```json
[
{
"id": "user-123",
"namaLengkap": "John Doe",
"namaUser": "johndoe",
"email": "[email protected]",
"tipeUser": "Super Admin",
"lastLogin": 1704067200,
"roles": ["superadmin", "admin"],
"realmRoles": ["default-roles-sandbox"],
"accountRoles": [],
"resourceRoles": [],
"groups": ["/Instalasi STIM/Devops/Superadmin", "/Instalasi STIM/Admin"],
"given_name": "John",
"family_name": "Doe",
"createdAt": 1704067200,
"updatedAt": 1704067200
}
]
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to fetch users"
}
```
### Field Description
- `id`: Unique identifier user
- `namaLengkap`: Nama lengkap user
- `namaUser`: Username untuk login
- `email`: Email user
- `tipeUser`: Tipe/jenis user (contoh: "Super Admin", "Admin Loket", dll)
- `lastLogin`: Timestamp terakhir login (Unix timestamp dalam detik)
- `roles`: Array of role names
- `realmRoles`: Array of realm roles dari Keycloak
- `accountRoles`: Array of account roles
- `resourceRoles`: Array of resource roles
- `groups`: Array of group paths (contoh: "/Instalasi STIM/Devops/Superadmin")
- `given_name`: Nama depan
- `family_name`: Nama belakang
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
---
## 2. Get Permissions by Role and Group
Mengambil daftar permissions dari backend berdasarkan role dan group yang dipilih.
### Endpoint
```
GET /api/v1/permission
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `roles` | string | Yes | Role name (contoh: "superadmin", "admin") |
| `groups` | string | Yes | Group name atau group path (contoh: "STIM", "/Instalasi STIM/Devops/Superadmin") |
**Example:**
```
GET /api/v1/permission?roles=superadmin&groups=STIM
```
### Response
**Success (200 OK):**
```json
{
"message": "Data permission berhasil diambil",
"data": [
{
"id": 1,
"create": false,
"read": true,
"update": false,
"disable": false,
"delete": false,
"active": true,
"pagename": "Halaman Utama",
"pagesID": 1,
"level": 1,
"sort": 1,
"parent": null
},
{
"id": 2,
"create": false,
"read": true,
"update": false,
"disable": false,
"delete": false,
"active": true,
"pagename": "Pengaturan",
"pagesID": 2,
"level": 1,
"sort": 2,
"parent": null
},
{
"id": 3,
"create": true,
"read": true,
"update": true,
"disable": true,
"delete": false,
"active": true,
"pagename": "Hak Akses",
"pagesID": 3,
"level": 2,
"sort": 3,
"parent": 2
}
],
"meta": {
"count": 3,
"total": 3
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "roles or groups parameter is required"
}
```
**Error (500 Internal Server Error):**
```json
{
"message": "Failed to fetch permissions",
"data": [],
"meta": {
"count": 0,
"total": 0
}
}
```
### Field Description
- `id`: Unique identifier permission
- `create`: Boolean, permission untuk create/tambah data
- `read`: Boolean, permission untuk read/lihat data
- `update`: Boolean, permission untuk update/edit data
- `disable`: Boolean, permission untuk disable/nonaktifkan
- `delete`: Boolean, permission untuk delete/hapus data
- `active`: Boolean, status aktif permission
- `pagename`: Nama halaman/menu (akan di-mapping ke menu sidebar)
- `pagesID`: ID halaman di sistem backend
- `level`: Level hierarki menu (1 = parent, 2 = child, dll)
- `sort`: Urutan tampil
- `parent`: ID parent menu (null jika parent menu)
---
## 3. Get List Hak Akses
Mengambil daftar semua hak akses yang sudah tersimpan.
### Endpoint
```
GET /api/v1/hak-akses
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Query Parameters:** Tidak ada
### Response
**Success (200 OK):**
```json
[
{
"id": 1,
"userId": "user-123",
"namaLengkap": "John Doe",
"namaUser": "johndoe",
"tipeUser": "Super Admin",
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
},
{
"name": "Master Data",
"canAccess": true,
"canView": true,
"canAdd": true,
"canEdit": true,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067200
}
]
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to fetch hak akses"
}
```
### Field Description
- `id`: Unique identifier hak akses
- `userId`: ID user (optional, untuk backward compatibility)
- `namaLengkap`: Nama lengkap user (optional)
- `namaUser`: Username (optional)
- `tipeUser`: Tipe user (optional)
- `role`: Role name (required)
- `group`: Group name (required)
- `groupPath`: Full group path (optional, untuk group-based access)
- `namaTipeUser`: Nama tipe user (optional)
- `hakAksesMenu`: Array of menu permissions
- `name`: Nama menu
- `canAccess`: Boolean, apakah bisa akses menu
- `canView`: Boolean, apakah bisa lihat data
- `canAdd`: Boolean, apakah bisa tambah data
- `canEdit`: Boolean, apakah bisa edit data
- `canDelete`: Boolean, apakah bisa hapus data
- `isGroupBased`: Boolean, apakah hak akses ini group-based (true) atau individual (false)
- `createdAt`: Timestamp pembuatan (Unix timestamp dalam detik)
- `updatedAt`: Timestamp update terakhir (Unix timestamp dalam detik)
---
## 4. Create Hak Akses
Membuat hak akses baru (group-based atau individual).
### Endpoint
```
POST /api/v1/hak-akses
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Body:**
```json
{
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
},
{
"name": "Master Data",
"canAccess": true,
"canView": true,
"canAdd": true,
"canEdit": true,
"canDelete": false
}
],
"isGroupBased": true
}
```
### Response
**Success (201 Created):**
```json
{
"success": true,
"message": "Hak akses berhasil dibuat",
"data": {
"id": 1,
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067200
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "role and group are required"
}
```
**Error (409 Conflict):**
```json
{
"statusCode": 409,
"statusMessage": "Hak akses untuk group dan role ini sudah ada"
}
```
### Field Description
**Required Fields:**
- `role`: Role name (string, required)
- `group`: Group name (string, required)
- `hakAksesMenu`: Array of menu permissions (array, required)
**Optional Fields:**
- `userId`: ID user (string, optional)
- `namaLengkap`: Nama lengkap (string, optional)
- `namaUser`: Username (string, optional)
- `tipeUser`: Tipe user (string, optional)
- `groupPath`: Full group path (string, optional)
- `namaTipeUser`: Nama tipe user (string, optional)
- `isGroupBased`: Boolean, default true (boolean, optional)
---
## 5. Update Hak Akses
Mengupdate hak akses yang sudah ada.
### Endpoint
```
PATCH /api/v1/hak-akses/{id}
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | ID hak akses yang akan diupdate |
**Body (semua field optional, hanya kirim field yang ingin diupdate):**
```json
{
"role": "admin",
"group": "LOKET",
"namaTipeUser": "Admin Loket",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
]
}
```
### Response
**Success (200 OK):**
```json
{
"success": true,
"message": "Hak akses berhasil diperbarui",
"data": {
"id": 1,
"role": "admin",
"group": "LOKET",
"groupPath": "/Instalasi STIM/Loket",
"namaTipeUser": "Admin Loket",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true,
"createdAt": 1704067200,
"updatedAt": 1704067300
}
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "ID is required"
}
```
**Error (404 Not Found):**
```json
{
"statusCode": 404,
"statusMessage": "Hak akses not found"
}
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to update hak akses"
}
```
---
## 6. Delete Hak Akses
Menghapus hak akses.
### Endpoint
```
DELETE /api/v1/hak-akses/{id}
```
### Request
**Headers:**
```
Content-Type: application/json
Accept: application/json
```
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | ID hak akses yang akan dihapus |
### Response
**Success (200 OK):**
```json
{
"success": true,
"message": "Hak akses berhasil dihapus"
}
```
**Error (400 Bad Request):**
```json
{
"statusCode": 400,
"statusMessage": "ID is required"
}
```
**Error (404 Not Found):**
```json
{
"statusCode": 404,
"statusMessage": "Hak akses not found"
}
```
**Error (500 Internal Server Error):**
```json
{
"statusCode": 500,
"statusMessage": "Failed to delete hak akses"
}
```
---
## Mapping Permissions ke Menu Sidebar
Sistem akan otomatis memetakan `pagename` dari API permission ke nama menu di sidebar. Mapping dilakukan dengan:
1. **Exact match**: `pagename.toLowerCase() === menu.name.toLowerCase()`
2. **Partial match**: `pagename.toLowerCase().includes(menu.name.toLowerCase())` atau sebaliknya
### Contoh Mapping:
- `pagename: "Halaman Utama"` → Menu: "Dashboard"
- `pagename: "Pengaturan"` → Menu: "Master Data"
- `pagename: "Hak Akses"` → Menu: "Hak Akses"
### Permission Mapping:
- `read: true``canView: true`
- `create: true``canAdd: true`
- `update: true``canEdit: true`
- `delete: true``canDelete: true`
- `active: true || read: true``canAccess: true`
---
## Group-Based Access
Sistem mendukung **Group-Based Access** dimana:
- Satu hak akses dapat diberikan ke semua user yang memiliki group dan role yang sama
- Setiap user dapat memiliki multiple hak akses dari berbagai group
- Hak akses disimpan dengan flag `isGroupBased: true` dan `groupPath` untuk identifikasi
### Contoh:
Jika hak akses dibuat dengan:
- `role: "superadmin"`
- `groupPath: "/Instalasi STIM/Devops/Superadmin"`
- `isGroupBased: true`
Maka semua user yang memiliki:
- Role: "superadmin" **DAN**
- Group: "/Instalasi STIM/Devops/Superadmin"
Akan otomatis mendapatkan hak akses tersebut.
---
## Error Handling
Semua endpoint harus mengembalikan error dengan format konsisten:
```json
{
"statusCode": 400|404|409|500,
"statusMessage": "Error message description"
}
```
**HTTP Status Codes:**
- `200 OK`: Request berhasil
- `201 Created`: Resource berhasil dibuat
- `400 Bad Request`: Request tidak valid (missing required fields, invalid format)
- `404 Not Found`: Resource tidak ditemukan
- `409 Conflict`: Resource sudah ada (untuk create duplicate)
- `500 Internal Server Error`: Server error
---
## Authentication
**Catatan:** Tim backend perlu menentukan apakah endpoint-endpoint ini memerlukan authentication token atau tidak. Jika diperlukan, tambahkan:
**Headers:**
```
Authorization: Bearer {access_token}
```
---
## Testing
### Contoh Request dengan cURL:
**1. Get Users List:**
```bash
curl -X GET "http://{API_BASE_URL}/api/users/list" \
-H "Content-Type: application/json"
```
**2. Get Permissions:**
```bash
curl -X GET "http://{API_BASE_URL}/api/v1/permission?roles=superadmin&groups=STIM" \
-H "Content-Type: application/json"
```
**3. Create Hak Akses:**
```bash
curl -X POST "http://{API_BASE_URL}/api/v1/hak-akses" \
-H "Content-Type: application/json" \
-d '{
"role": "superadmin",
"group": "STIM",
"groupPath": "/Instalasi STIM/Devops/Superadmin",
"namaTipeUser": "Super Admin",
"hakAksesMenu": [
{
"name": "Dashboard",
"canAccess": true,
"canView": true,
"canAdd": false,
"canEdit": false,
"canDelete": false
}
],
"isGroupBased": true
}'
```
**4. Update Hak Akses:**
```bash
curl -X PATCH "http://{API_BASE_URL}/api/v1/hak-akses/1" \
-H "Content-Type: application/json" \
-d '{
"namaTipeUser": "Updated Admin",
"hakAksesMenu": [...]
}'
```
**5. Delete Hak Akses:**
```bash
curl -X DELETE "http://{API_BASE_URL}/api/v1/hak-akses/1" \
-H "Content-Type: application/json"
```
---
## Catatan Penting
1. **Base URL**: Ganti `{API_BASE_URL}` dengan URL backend yang sebenarnya
2. **Timestamps**: Semua timestamp menggunakan format Unix timestamp (detik sejak epoch)
3. **Group Path**: Group path harus full path (contoh: "/Instalasi STIM/Devops/Superadmin") untuk group-based access
4. **Menu Permissions**: Array `hakAksesMenu` harus sesuai dengan struktur menu sidebar
5. **Validation**: Backend harus melakukan validasi untuk:
- Required fields (role, group, hakAksesMenu)
- Duplicate check untuk group+role combination (jika isGroupBased = true)
- Format data yang valid
---
## Kontak
Jika ada pertanyaan atau perubahan requirement, silakan hubungi tim frontend.
+61
View File
@@ -0,0 +1,61 @@
# Setup HTTPS untuk Development
Aplikasi ini sudah dikonfigurasi untuk menggunakan HTTPS di development mode agar fitur kamera bisa berfungsi.
## Cara Menggunakan
1. **Jalankan aplikasi dengan HTTPS:**
```bash
npm run dev:https
```
atau
```bash
npm run dev
```
2. **Akses aplikasi:**
- Dari komputer: `https://localhost:3001`
- Dari HP (dalam jaringan yang sama): `https://[IP-KOMPUTER]:3001`
- Contoh: `https://10.10.150.175:3001`
## Peringatan Keamanan Browser
Karena menggunakan self-signed certificate, browser akan menampilkan peringatan keamanan:
### Chrome/Edge:
1. Klik **"Advanced"** atau **"Lanjutkan ke localhost (tidak aman)"**
2. Klik **"Proceed to localhost (unsafe)"**
### Firefox:
1. Klik **"Advanced"**
2. Klik **"Accept the Risk and Continue"**
### Safari (Mac):
1. Klik **"Show Details"**
2. Klik **"visit this website"**
3. Klik **"Visit Website"** di dialog konfirmasi
### Mobile (HP):
- **Android Chrome**: Klik **"Advanced"** → **"Proceed to [IP] (unsafe)"**
- **iOS Safari**: Klik **"Advanced"** → **"Proceed to [IP]"**
## Catatan Penting
- ✅ Setelah menerima certificate, browser akan mengingatnya untuk kunjungan selanjutnya
- ✅ HTTPS diperlukan untuk akses kamera di browser modern
- ✅ Pastikan HP dan komputer dalam jaringan yang sama (WiFi yang sama)
- ✅ Gunakan IP komputer Anda, bukan `localhost` saat akses dari HP
## Troubleshooting
### Kamera masih tidak muncul?
1. Pastikan sudah menggunakan `https://` bukan `http://`
2. Cek izin kamera di browser settings
3. Pastikan tidak ada aplikasi lain yang menggunakan kamera
4. Restart browser setelah setup HTTPS pertama kali
### Tidak bisa akses dari HP?
1. Pastikan firewall tidak memblokir port 3001
2. Pastikan HP dan komputer dalam WiFi yang sama
3. Cek IP komputer dengan `ipconfig` (Windows) atau `ifconfig` (Mac/Linux)
+94
View File
@@ -0,0 +1,94 @@
# Pages List - Web Antrean Application
This document lists all pages in the application with their file paths and route paths.
## Root Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Home/Index | `pages/index.vue` | `/` |
| Login Page | `pages/LoginPage.vue` | `/login-page` |
| Dashboard | `pages/Dashboard.vue` | `/dashboard` |
| Admin Klinik | `pages/AdminKlinik.vue` | `/admin-klinik` |
| Admin Loket | `pages/AdminLoket.vue` | `/admin-loket` |
| Admin Penunjang | `pages/AdminPenunjang.vue` | `/admin-penunjang` |
| Buat Antrean | `pages/BuatAntrean.vue` | `/buat-antrean` |
| Klinik Ruang Admin | `pages/KlinikRuangAdmin.vue` | `/klinik-ruang-admin` |
| Ranap Admin | `pages/RanapAdmin.vue` | `/ranap-admin` |
## Anjungan Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Anjungan | `pages/Anjungan/Anjungan.vue` | `/anjungan` |
| Admin Anjungan | `pages/Anjungan/AdminAnjungan.vue` | `/anjungan/admin-anjungan` |
| Antrian Klinik | `pages/Anjungan/AntrianKlinik.vue` | `/anjungan/antrian-klinik` |
| Antrian Klinik Ruang | `pages/Anjungan/AntrianKlinikRuang.vue` | `/anjungan/antrian-klinik-ruang` |
| Antrian Penunjang | `pages/Anjungan/AntrianPenunjang.vue` | `/anjungan/antrian-penunjang` |
## Check In Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Check In | `pages/CheckInPasien/checkIn.vue` | `/check-in-pasien/check-in` |
## Data Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Data Pasien Index | `pages/data-pasien/index.vue` | `/data-pasien` |
| Edit Data Pasien | `pages/data-pasien/edit/[id].vue` | `/data-pasien/edit/:id` |
## Monitoring Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Monitoring Pasien | `pages/MonitoringPasien/monitoringPasien.vue` | `/monitoring-pasien/monitoring-pasien` |
| Detail Pasien | `pages/MonitoringPasien/pasien/[id].vue` | `/monitoring-pasien/pasien/:id` |
## Profile Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Profil | `pages/Profile/Profil.vue` | `/profile/profil` |
## Setting Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| User Login | `pages/Setting/UserLogin.vue` | `/setting/user-login` |
| Hak Akses | `pages/Setting/HakAkses.vue` | `/setting/hak-akses` |
| Master Klinik | `pages/Setting/MasterKlinik.vue` | `/setting/master-klinik` |
| Master Klinik Ruang | `pages/Setting/MasterKlinikRuang.vue` | `/setting/master-klinik-ruang` |
| Master Loket | `pages/Setting/MasterLoket.vue` | `/setting/master-loket` |
| Master Penunjang | `pages/Setting/MasterPenunjang.vue` | `/setting/master-penunjang` |
| Screen | `pages/Setting/Screen.vue` | `/setting/screen` |
| Tambah Loket | `pages/Setting/TambahLoket.vue` | `/setting/tambah-loket` |
| Edit Loket | `pages/Setting/Edit-Loket/[id].vue` | `/setting/edit-loket/:id` |
| Screen Index | `pages/Setting/Screen/index.vue` | `/setting/screen` |
| Edit Screen | `pages/Setting/Screen/edit/[id].vue` | `/setting/screen/edit/:id` |
## Verifikasi Akun Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Verifikasi Akun | `pages/verifikasiAkun/VerifikasiAkun.vue` | `/verifikasi-akun/verifikasi-akun` |
---
## Summary
- **Total Pages**: 30+ pages
- **Dynamic Routes**: 4 pages with `[id]` parameter
- **Note**: `Pengaturan.vue.txt` appears to be a text file, not an active Vue component
## Route Path Conversion Rules
In Nuxt.js, file paths are converted to routes as follows:
- File names are converted to kebab-case
- Folders create nested routes
- `index.vue` files create routes at the folder level
- `[id].vue` files create dynamic routes with `:id` parameter
- Special characters like hyphens are preserved
+83
View File
@@ -0,0 +1,83 @@
# Placeholder API Permission
## Deskripsi
Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia.
## Cara Menggunakan
### 1. Menggunakan Placeholder API secara Default
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.123.140:8089/api/v1/permission`) tidak dapat diakses.
### 2. Memaksa Menggunakan Placeholder API
Tambahkan query parameter `usePlaceholder=true` pada request:
```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true
```
### 3. Menonaktifkan Placeholder API
Tambahkan query parameter `usePlaceholder=false` pada request:
```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
```
## Data Placeholder yang Tersedia
### Role: superadmin, Group: STIM
Data placeholder mengembalikan 5 permission items:
- Halaman Utama (read: true, active: true)
- Pengaturan (read: true, active: true)
- Halaman (read: true, active: true, disable: true)
- Dashboard (read: true, active: true, disable: true)
## Mapping Pagename ke Menu Sidebar
Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
- "Halaman Utama" → "Dashboard"
- "Pengaturan" → "Master Data"
- "Halaman" → "Master Data"
- "Dashboard" → "Dashboard"
## Testing dengan User bayurssa
Untuk testing dengan user email "bayurssa":
1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak
2. Login dengan email "bayurssa" dan password "12345"
3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia
4. Sidebar akan terfilter berdasarkan permissions dari placeholder API
## Mapping Role dan Group
Sistem secara otomatis melakukan normalisasi untuk role dan group:
### Normalisasi Role
- `default-roles-sandbox``superadmin`
- Role lain akan digunakan apa adanya (lowercase)
### Normalisasi Group
- `Instalasi STIM``STIM`
- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM"
- Group lain akan digunakan apa adanya (uppercase)
### Contoh Mapping
- Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
- Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
## Catatan
- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan
- Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API
- Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API
- Jika backend API juga gagal, sistem akan mengembalikan data kosong
+66
View File
@@ -0,0 +1,66 @@
# Dokumentasi Implementasi Proxy API (Nuxt 3)
Dokumen ini menjelaskan cara menggunakan dan menambah proxy API di file `nuxt.config.ts` untuk menangani masalah CORS dan menyederhanakan pemanggilan endpoint backend.
## 1. Menambah Proxy Baru di `nuxt.config.ts`
Jika Anda memiliki API backend lain (misalnya departemen berbeda atau IP berbeda), Anda cukup menambah baris baru di dalam objek `routeRules`.
### Contoh Konfigurasi:
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Proxy untuk Statistik (yang sudah ada)
"/stats-api/**": {
proxy: "http://10.10.150.100:8084/api/v1/**",
},
// Proxy untuk API Pasien (Contoh Tambahan)
"/patient-api/**": {
proxy: "http://10.10.150.101:8000/api/v2/**",
},
// Proxy untuk API Antrean (IP sama, prefix path berbeda)
"/queue-api/**": {
proxy: "http://10.10.150.100:8084/api/v1/queue/**",
},
},
});
```
---
## 2. Cara Menggunakan di Composable
Setelah didaftarkan di `nuxt.config.ts`, Anda tidak perlu lagi menulis IP Address di dalam kode Vue/TypeScript Anda. Gunakan path prefix yang sudah didefinisikan.
### Contoh File: `composables/usePatientAPI.ts`
```typescript
export const usePatientAPI = () => {
const fetchPatient = async (id: string) => {
// Alamat asli: http://10.10.150.101:8000/api/v2/patient/123
// Cukup panggil seperti ini:
return await $fetch(`/patient-api/patient/${id}`);
};
return { fetchPatient };
};
```
---
## 3. Keuntungan Menggunakan Proxy
1. **Menghindari CORS**: Browser tidak akan memblokir request karena seolah-olah request dikirim ke server yang sama dengan frontend.
2. **Keamanan**: IP backend asli tidak terekspos secara langsung di kode frontend (Client-side).
3. **Kemudahan Maintenance**: Jika IP Server backend berubah, Anda hanya perlu mengubah satu baris di `nuxt.config.ts` tanpa harus mencari dan mengganti di seluruh file `.vue` atau `.ts`.
---
## 4. Tips Debugging Proxy
- Jika menggunakan **Vite Dev Server**, pastikan Anda menjalankan ulang `npm run dev` setelah mengubah `nuxt.config.ts`.
- Cek tab **Network** di Developer Tools (F12). Anda akan melihat request ke `localhost:3000/stats-api/...` dan server Nuxt akan meneruskannya ke IP backend secara otomatis.
+101
View File
@@ -0,0 +1,101 @@
# Panduan Deployment Docker - web-antrean
Panduan ini berisi langkah-langkah untuk menjalankan aplikasi `web-antrean` (Nuxt 3) menggunakan Docker & Docker Compose.
---
## 📦 Prasyarat (Prerequisites)
Sebelum memulai, pastikan Anda telah menginstal:
- **Docker Desktop** (atau Docker Engine di Linux)
- **Docker Compose** (biasanya otomatis terinstal dengan Docker Desktop)
---
## 🚀 Quick Start (Mulai Cepat)
### 1. Siapkan File `.env`
Pastikan file `.env` sudah ada di root folder `web-antrean` dengan konfigurasi yang benar. Anda bisa menyalin dari template yang disediakan:
```bash
cp .env.docker.example .env
```
> [!IMPORTANT]
> Pastikan variabel `HOST` diatur ke `0.0.0.0` agar aplikasi dapat menerima request dari luar container Docker.
> Sesuaikan `AUTH_ORIGIN` dan `POST_LOGOUT_REDIRECT_URI` dengan alamat/domain server Anda (misal: `http://10.10.150.175:3000`).
### 2. Bangun dan Jalankan Container
Jalankan perintah berikut di terminal untuk melakukan build image sekaligus menjalankan container di background:
```bash
docker-compose up -d --build
```
### 3. Akses Aplikasi
Setelah container berhasil berjalan, aplikasi dapat diakses di browser melalui:
- **http://localhost:3000** (atau IP server Anda, misal: **http://10.10.150.175:3000**)
---
## 🔧 Perintah Docker yang Sering Digunakan (Useful Commands)
### Menjalankan Container (Jalankan di background)
```bash
docker-compose up -d
```
### Mematikan Container
```bash
docker-compose down
```
### Membangun Ulang & Menjalankan Ulang (Jika ada perubahan kode)
```bash
docker-compose up -d --build
```
### Melihat Log Aplikasi secara Real-time
```bash
docker-compose logs -f web-antrean
```
### Masuk ke Terminal di Dalam Container
```bash
docker exec -it web-antrean sh
```
---
## 💾 Persistensi Database (SQLite)
Aplikasi `web-antrean` menggunakan `better-sqlite3` untuk menyimpan data sinkronisasi pengguna di `/app/data/users.db`.
Agar data ini tidak hilang saat container dihapus atau dibangun ulang, kami menggunakan **Volume Mounting** di `docker-compose.yml`:
```yaml
volumes:
- ./data:/app/data
```
Ini akan otomatis memetakan folder local `./data` di host server Anda ke folder `/app/data` di dalam container. File database Anda akan tersimpan dengan aman di folder local `./data`.
---
## 🔍 Troubleshooting (Penyelesaian Masalah)
### 1. Masalah dengan Native Module compilation (`better-sqlite3`)
Jika image gagal dibangun karena error kompilasi C/C++, Dockerfile kami telah dikonfigurasi menggunakan **multi-stage build** dengan base image `node:20-alpine` dan menginstal package compiler (`make`, `g++`, `gcc`, `python3`) di builder stage. Ini menjamin proses build aman dan hasil image final tetap berukuran kecil (~150MB - 200MB).
### 2. Cek Status Container dan Health Check
Container ini dilengkapi dengan health check otomatis. Anda bisa mengecek statusnya dengan:
```bash
docker ps
```
Jika statusnya `Up (healthy)`, berarti server Nuxt Anda telah siap dan dapat diakses.
### 3. Hapus Cache Docker jika Mengalami Error Aneh
```bash
docker-compose down --rmi all --volumes
```
---
## 🛡️ Catatan Keamanan
- Jangan pernah meng-commit file `.env` ke Git repository.
- Selalu gunakan `NUXT_AUTH_SECRET` yang aman dan unik untuk setiap environment (Development/Production).
+69 -4
View File
@@ -1,7 +1,72 @@
<script setup>
import { onMounted, ref } from 'vue';
const nuxtApp = useNuxtApp();
const loading = ref(false);
nuxtApp.hook('page:start', () => {
loading.value = true;
});
nuxtApp.hook('page:finish', () => {
loading.value = false;
});
// Naikkan versi ini setiap kali ada perubahan struktur state (schema migration).
// Saat versi tidak cocok, semua cache Pinia akan dibersihkan otomatis.
const STORE_SCHEMA_VERSION = 3;
const VERSION_KEY = 'app-store-version';
// Daftar semua localStorage key yang dikelola oleh Pinia stores
const PINIA_STORE_KEYS = [
'queue-store-state',
'clinic-store-state',
'doctor-store',
'loket-store-state',
'ruang-store-state',
'klinikruang-store-state',
'antrean-masuk-screen-store',
'anjungan-store',
'screen-store',
'penunjang-store',
];
onMounted(() => {
try {
if (typeof window === 'undefined' || !window.localStorage) return;
const savedVersion = parseInt(localStorage.getItem(VERSION_KEY) || '0', 10);
if (savedVersion < STORE_SCHEMA_VERSION) {
// Versi lama terdeteksi → hapus semua cache Pinia sekaligus
console.log(`[Migration] Store schema v${savedVersion} → v${STORE_SCHEMA_VERSION}. Clearing all caches...`);
PINIA_STORE_KEYS.forEach(key => localStorage.removeItem(key));
localStorage.setItem(VERSION_KEY, String(STORE_SCHEMA_VERSION));
console.log('[Migration] Done. App will fetch fresh data from API.');
}
} catch (e) {
// Ignore storage errors (e.g., private browsing mode)
}
});
</script>
<template> <template>
<NuxtLayout> <v-app>
<v-app> <v-overlay
:model-value="loading"
class="align-center justify-center"
persistent
z-index="9999"
>
<v-progress-circular
color="primary"
indeterminate
size="64"
width="6"
></v-progress-circular>
</v-overlay>
<NuxtLayout>
<NuxtPage /> <NuxtPage />
</v-app> </NuxtLayout>
</NuxtLayout> </v-app>
</template> </template>
+136
View File
@@ -0,0 +1,136 @@
:root {
/* Neutral Colors */
--neutral-900: #212121;
--neutral-800: #4D4D4D;
--neutral-700: #717171;
--neutral-600: #89939E;
--neutral-500: #CDD4DC;
--neutral-400: #E1E5EA;
--neutral-300: #F5F7FA;
--neutral-100: #FFFFFF;
/* Primary Colors (Blue) */
--primary-700: #3556AE;
--primary-600: #3A61C9;
--primary-500: #567EE7;
--primary-400: #7898EC;
--primary-300: #9AB2F1;
--primary-200: #BBCBF5;
--primary-100: #DDE5FA;
/* Secondary Colors (Orange) */
--secondary-700: #E65A0D;
--secondary-600: #F16F29;
--secondary-500: #FF8441;
--secondary-400: #FF9D67;
--secondary-300: #FFB58D;
--secondary-200: #FFCEB3;
--secondary-100: #FFE6D9;
/* Success Colors (Green) */
--success-700: #008D65;
--success-600: #33A484;
--success-500: #59B59B;
--success-400: #80C6B2;
--success-300: #A6D7C9;
--success-200: #CCE8E0;
--success-100: #E5F4F0;
/* Danger Colors (Red) */
--danger-700: #D82719;
--danger-600: #DE473B;
--danger-500: #E57269;
--danger-400: #EB938C;
--danger-300: #F1B3AE;
--danger-200: #F7D4D1;
--danger-100: #FBE9E8;
}
/* Utility Classes untuk Background */
.bg-neutral-900 { background-color: var(--neutral-900); }
.bg-neutral-800 { background-color: var(--neutral-800); }
.bg-neutral-700 { background-color: var(--neutral-700); }
.bg-neutral-600 { background-color: var(--neutral-600); }
.bg-neutral-500 { background-color: var(--neutral-500); }
.bg-neutral-400 { background-color: var(--neutral-400); }
.bg-neutral-300 { background-color: var(--neutral-300); }
.bg-neutral-100 { background-color: var(--neutral-100); }
.bg-primary-700 { background-color: var(--primary-700); }
.bg-primary-600 { background-color: var(--primary-600); }
.bg-primary-500 { background-color: var(--primary-500); }
.bg-primary-400 { background-color: var(--primary-400); }
.bg-primary-300 { background-color: var(--primary-300); }
.bg-primary-200 { background-color: var(--primary-200); }
.bg-primary-100 { background-color: var(--primary-100); }
.bg-secondary-700 { background-color: var(--secondary-700); }
.bg-secondary-600 { background-color: var(--secondary-600); }
.bg-secondary-500 { background-color: var(--secondary-500); }
.bg-secondary-400 { background-color: var(--secondary-400); }
.bg-secondary-300 { background-color: var(--secondary-300); }
.bg-secondary-200 { background-color: var(--secondary-200); }
.bg-secondary-100 { background-color: var(--secondary-100); }
.bg-success-700 { background-color: var(--success-700); }
.bg-success-600 { background-color: var(--success-600); }
.bg-success-500 { background-color: var(--success-500); }
.bg-success-400 { background-color: var(--success-400); }
.bg-success-300 { background-color: var(--success-300); }
.bg-success-200 { background-color: var(--success-200); }
.bg-success-100 { background-color: var(--success-100); }
.bg-danger-700 { background-color: var(--danger-700); }
.bg-danger-600 { background-color: var(--danger-600); }
.bg-danger-500 { background-color: var(--danger-500); }
.bg-danger-400 { background-color: var(--danger-400); }
.bg-danger-300 { background-color: var(--danger-300); }
.bg-danger-200 { background-color: var(--danger-200); }
.bg-danger-100 { background-color: var(--danger-100); }
/* Utility Classes untuk Text Color */
.text-neutral-900 { color: var(--neutral-900); }
.text-neutral-800 { color: var(--neutral-800); }
.text-neutral-700 { color: var(--neutral-700); }
.text-neutral-600 { color: var(--neutral-600); }
.text-neutral-500 { color: var(--neutral-500); }
.text-neutral-100 { color: var(--neutral-100); }
.text-primary-700 { color: var(--primary-700); }
.text-primary-600 { color: var(--primary-600); }
.text-primary-500 { color: var(--primary-500); }
.text-primary-400 { color: var(--primary-400); }
.text-primary-300 { color: var(--primary-300); }
.text-primary-200 { color: var(--primary-200); }
.text-primary-100 { color: var(--primary-100); }
.text-secondary-700 { color: var(--secondary-700); }
.text-secondary-600 { color: var(--secondary-600); }
.text-secondary-500 { color: var(--secondary-500); }
.text-secondary-400 { color: var(--secondary-400); }
.text-secondary-300 { color: var(--secondary-300); }
.text-secondary-200 { color: var(--secondary-200); }
.text-secondary-100 { color: var(--secondary-100); }
.text-success-700 { color: var(--success-700); }
.text-success-600 { color: var(--success-600); }
.text-success-500 { color: var(--success-500); }
.text-success-400 { color: var(--success-400); }
.text-success-300 { color: var(--success-300); }
.text-success-200 { color: var(--success-200); }
.text-success-100 { color: var(--success-100); }
.text-danger-700 { color: var(--danger-700); }
.text-danger-600 { color: var(--danger-600); }
.text-danger-500 { color: var(--danger-500); }
.text-danger-400 { color: var(--danger-400); }
.text-danger-300 { color: var(--danger-300); }
.text-danger-200 { color: var(--danger-200); }
.text-danger-100 { color: var(--danger-100); }
/* Utility Classes untuk Border */
.border-primary-500 { border-color: var(--primary-500); }
.border-secondary-500 { border-color: var(--secondary-500); }
.border-success-500 { border-color: var(--success-500); }
.border-danger-500 { border-color: var(--danger-500); }
.border-neutral-500 { border-color: var(--neutral-500); }
+109
View File
@@ -0,0 +1,109 @@
/* Base Font */
* {
font-family: 'Inter', sans-serif;
}
/* Headlines - Desktop */
.headline-1 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 64px;
line-height: 76px;
}
.headline-2 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 36px;
line-height: 44px;
}
.headline-3 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 28px;
line-height: 36px;
}
.headline-4 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 20px;
line-height: 28px;
}
/* Body - Desktop */
.body-1 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 18px;
line-height: 28px;
}
.body-2 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 16px;
line-height: 24px;
}
.body-3 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 14px;
line-height: 20px;
}
/* Caption - Desktop */
.text-1 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 16px;
line-height: 24px;
}
.text-2 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 12px;
line-height: 16px;
}
/* Mobile Typography */
@media (max-width: 768px) {
.headline-1 {
font-weight: 700; /* Bold */
font-size: 28px;
line-height: 36px;
}
.headline-2 {
font-weight: 600; /* Semi Bold */
font-size: 20px;
line-height: 28px;
}
.headline-3 {
font-weight: 600; /* Semi Bold */
font-size: 18px;
line-height: 24px;
}
.headline-4 {
font-weight: 600; /* Semi Bold */
font-size: 18px;
line-height: 24px;
}
.body {
font-weight: 500; /* Medium */
font-size: 16px;
line-height: 24px;
}
.caption {
font-weight: 400; /* Regular */
font-size: 14px;
line-height: 20px;
}
}
+11
View File
@@ -0,0 +1,11 @@
# Avatar Images
## Default Avatar
Simpan gambar avatar default dengan nama: `default-avatar.png`
Gambar avatar default akan digunakan ketika user tidak memiliki foto profile.
## Cara Menggunakan
1. Simpan gambar avatar yang ingin digunakan dengan nama `default-avatar.png`
2. Pastikan gambar dalam format PNG dengan transparansi (jika diperlukan)
3. Ukuran gambar disarankan minimal 300x300px untuk kualitas yang baik
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+190
View File
@@ -0,0 +1,190 @@
@use 'sass:map';
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #CDD4DC;
$neutral-400: #E1E5EA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$primary-700: #3556AE;
$primary-600: #3A61C9;
$primary-500: #567EE7;
$primary-400: #7898EC;
$primary-300: #9AB2F1;
$primary-200: #BBCBF5;
$primary-100: #DDE5FA;
$primary-50: #EFF3FD;
$secondary-700: #E65A0D;
$secondary-600: #F16F29;
$secondary-500: #FF8441;
$secondary-400: #FF9D67;
$secondary-300: #FFB58D;
$secondary-200: #FFCEB3;
$secondary-100: #FFE6D9;
$secondary-50: #FFF3EC;
$success-700: #008D65;
$success-600: #33A484;
$success-500: #59B59B;
$success-400: #80C6B2;
$success-300: #A6D7C9;
$success-200: #CCE8E0;
$success-100: #E5F4F0;
$danger-700: #D82719;
$danger-600: #DE473B;
$danger-500: #E57269;
$danger-400: #EB938C;
$danger-300: #F1B3AE;
$danger-200: #F7D4D1;
$danger-100: #FBE9E8;
// CSS Variables untuk dynamic theming
:root {
// Neutral
--color-neutral-900: #{$neutral-900};
--color-neutral-800: #{$neutral-800};
--color-neutral-700: #{$neutral-700};
--color-neutral-600: #{$neutral-600};
--color-neutral-500: #{$neutral-500};
--color-neutral-400: #{$neutral-400};
--color-neutral-300: #{$neutral-300};
--color-neutral-100: #{$neutral-100};
// Primary
--color-primary-700: #{$primary-700};
--color-primary-600: #{$primary-600};
--color-primary-500: #{$primary-500};
--color-primary-400: #{$primary-400};
--color-primary-300: #{$primary-300};
--color-primary-200: #{$primary-200};
--color-primary-100: #{$primary-100};
--color-primary-50: #{$primary-50};
// Secondary
--color-secondary-700: #{$secondary-700};
--color-secondary-600: #{$secondary-600};
--color-secondary-500: #{$secondary-500};
--color-secondary-400: #{$secondary-400};
--color-secondary-300: #{$secondary-300};
--color-secondary-200: #{$secondary-200};
--color-secondary-100: #{$secondary-100};
--color-secondary-50: #{$secondary-50};
// Success
--color-success-700: #{$success-700};
--color-success-600: #{$success-600};
--color-success-500: #{$success-500};
--color-success-400: #{$success-400};
--color-success-300: #{$success-300};
--color-success-200: #{$success-200};
--color-success-100: #{$success-100};
// Danger
--color-danger-700: #{$danger-700};
--color-danger-600: #{$danger-600};
--color-danger-500: #{$danger-500};
--color-danger-400: #{$danger-400};
--color-danger-300: #{$danger-300};
--color-danger-200: #{$danger-200};
--color-danger-100: #{$danger-100};
// Theme colors (akan di-override oleh theme system)
--color-primary: #{$primary-500};
--color-secondary: #{$secondary-500};
--color-success: #{$success-600};
--color-danger: #{$danger-600};
--color-background: #{$neutral-300};
--color-surface: #{$neutral-100};
--color-textPrimary: #{$neutral-900};
--color-textSecondary: #{$neutral-700};
--color-borderColor: #{$neutral-500};
}
// Dark mode base
.dark {
--color-background: #1a1a1a;
--color-surface: #{$neutral-900};
--color-textPrimary: #{$neutral-100};
--color-textSecondary: #{$neutral-500};
--color-borderColor: #{$neutral-800};
}
// Mixin untuk generate color utilities
@mixin color-utilities($prefix, $color) {
.text-#{$prefix} {
color: $color;
}
.bg-#{$prefix} {
background-color: $color;
}
.border-#{$prefix} {
border-color: $color;
}
}
// Generate color utilities
$colors: (
'neutral-900': $neutral-900,
'neutral-800': $neutral-800,
'neutral-700': $neutral-700,
'neutral-600': $neutral-600,
'neutral-500': $neutral-500,
'neutral-400': $neutral-400,
'neutral-300': $neutral-300,
'neutral-100': $neutral-100,
'primary-700': $primary-700,
'primary-600': $primary-600,
'primary-500': $primary-500,
'primary-400': $primary-400,
'primary-300': $primary-300,
'primary-200': $primary-200,
'primary-100': $primary-100,
'primary-50': $primary-50,
'secondary-700': $secondary-700,
'secondary-600': $secondary-600,
'secondary-500': $secondary-500,
'secondary-400': $secondary-400,
'secondary-300': $secondary-300,
'secondary-200': $secondary-200,
'secondary-100': $secondary-100,
'secondary-50': $secondary-50,
'success-700': $success-700,
'success-600': $success-600,
'success-500': $success-500,
'success-400': $success-400,
'success-300': $success-300,
'success-200': $success-200,
'success-100': $success-100,
'danger-700': $danger-700,
'danger-600': $danger-600,
'danger-500': $danger-500,
'danger-400': $danger-400,
'danger-300': $danger-300,
'danger-200': $danger-200,
'danger-100': $danger-100
);
@each $name, $color in $colors {
@include color-utilities($name, $color);
}
// Shorthand semantic colors
.text-primary { color: $primary-500; }
.text-secondary { color: $secondary-500; }
.text-success { color: $success-600; }
.text-danger { color: $danger-600; }
.text-muted { color: $neutral-600; }
.bg-primary { background-color: $primary-500; }
.bg-secondary { background-color: $secondary-500; }
.bg-success { background-color: $success-600; }
.bg-danger { background-color: $danger-600; }
.bg-light { background-color: $neutral-300; }
.bg-white { background-color: $neutral-100; }
+121
View File
@@ -0,0 +1,121 @@
@use 'sass:map';
@use './variables' as *;
// Mixin untuk generate typography classes
@mixin typography($name, $size, $line-height, $weight) {
.#{$name} {
font-family: $font-family-base;
font-size: $size;
line-height: $line-height;
font-weight: $weight;
margin: 0;
}
}
// Base typography
* {
font-family: $font-family-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: $font-family-base;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
margin: 0;
padding: 0;
}
// Generate desktop typography classes - Gunakan map.get
@each $name, $props in $font-sizes {
@include typography(
$name,
map.get($props, 'size'),
map.get($props, 'line-height'),
map.get($props, 'weight')
);
}
// Generate mobile typography classes - Gunakan map.get
@media (max-width: $breakpoint-mobile) {
@each $name, $props in $font-sizes-mobile {
@include typography(
$name,
map.get($props, 'size'),
map.get($props, 'line-height'),
map.get($props, 'weight')
);
}
}
// HTML semantic tags
h1 {
@extend .headline-1;
}
h2 {
@extend .headline-2;
}
h3 {
@extend .headline-3;
}
h4 {
@extend .headline-4;
}
p {
@extend .body-1;
}
small {
@extend .caption-2;
}
// Utility classes
.text-regular {
font-weight: $font-weight-regular !important;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
.text-bold {
font-weight: $font-weight-bold !important;
}
.text-uppercase {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
// Line clamp utilities
@for $i from 1 through 5 {
.line-clamp-#{$i} {
display: -webkit-box;
-webkit-line-clamp: $i;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
+117
View File
@@ -0,0 +1,117 @@
@use 'sass:map';
@use 'sass:math';
// Font Family
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
// Font Weights
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// Font Sizes & Line Heights - Desktop
$font-sizes: (
'headline-1': (
'size': 64px,
'line-height': 76px,
'weight': $font-weight-semibold
),
'headline-2': (
'size': 36px,
'line-height': 44px,
'weight': $font-weight-semibold
),
'headline-3': (
'size': 28px,
'line-height': 36px,
'weight': $font-weight-semibold
),
'headline-4': (
'size': 20px,
'line-height': 28px,
'weight': $font-weight-semibold
),
'body-1': (
'size': 18px,
'line-height': 28px,
'weight': $font-weight-regular
),
'body-2': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-regular
),
'body-3': (
'size': 14px,
'line-height': 20px,
'weight': $font-weight-regular
),
'caption-1': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-regular
),
'caption-2': (
'size': 12px,
'line-height': 16px,
'weight': $font-weight-regular
)
);
// Font Sizes - Mobile
$font-sizes-mobile: (
'headline-1': (
'size': 28px,
'line-height': 36px,
'weight': $font-weight-bold
),
'headline-2': (
'size': 20px,
'line-height': 28px,
'weight': $font-weight-semibold
),
'headline-3': (
'size': 18px,
'line-height': 24px,
'weight': $font-weight-semibold
),
'headline-4': (
'size': 18px,
'line-height': 24px,
'weight': $font-weight-semibold
),
'body': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-medium
),
'caption': (
'size': 14px,
'line-height': 20px,
'weight': $font-weight-regular
)
);
// Breakpoints
$breakpoint-mobile: 768px;
$breakpoint-tablet: 1024px;
$breakpoint-desktop: 1280px;
// Mixins
@mixin heading($level) {
@if map.has-key($font-sizes, $level) {
$props: map.get($font-sizes, $level);
font-size: map.get($props, 'size');
line-height: map.get($props, 'line-height');
font-weight: map.get($props, 'weight');
font-family: $font-family-base;
}
}
@mixin responsive-text {
@media (max-width: $breakpoint-mobile) {
font-size: 14px;
line-height: 20px;
}
}
+882
View File
@@ -0,0 +1,882 @@
// Check-in Page Component Styles
@import 'variables';
/* Modern Minimalist Background */
.bg-modern {
background: $bg-white;
min-height: 100vh;
max-height: 100vh;
position: relative;
overflow: hidden;
}
.no-scroll-container {
height: 100vh;
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
}
.no-scroll-container::-webkit-scrollbar {
width: $spacing-xs;
}
.no-scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.no-scroll-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.no-overflow {
overflow: hidden !important;
height: 100vh;
max-height: 100vh;
}
.bg-modern::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 50%, rgba(21, 101, 192, 0.05) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(21, 101, 192, 0.05) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* Main Card dengan Glassmorphism */
.main-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: $radius-xl !important;
box-shadow: $shadow-xl !important;
overflow: hidden;
position: relative;
z-index: $z-base;
}
/* Header Modern Minimalis */
.header-modern {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
padding: $spacing-xl $spacing-2xl $spacing-lg;
position: relative;
overflow: hidden;
}
.header-modern::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
pointer-events: none;
}
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-md;
margin-bottom: $spacing-xs;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: $radius-full;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.header-text {
text-align: center;
}
.title-modern {
font-size: $font-3xl;
font-weight: 600;
color: white;
margin: 0;
line-height: 1.2;
}
.subtitle-modern {
font-size: $font-base;
color: rgba(255, 255, 255, 0.9);
margin: 2px 0 0;
font-weight: 400;
}
.tabs-modern {
position: relative;
z-index: $z-base;
}
.tabs-modern :deep(.v-tab) {
color: rgba(255, 255, 255, 0.8) !important;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
min-width: 120px;
transition: all $transition-base;
}
.tabs-modern :deep(.v-tab:hover) {
color: rgba(255, 255, 255, 1) !important;
background: rgba(255, 255, 255, 0.1);
border-radius: $radius-sm;
}
.tabs-modern :deep(.v-tab--selected) {
color: white !important;
font-weight: 600;
}
.tabs-modern :deep(.v-slider) {
background-color: $secondary-color !important;
height: 3px;
border-radius: 2px;
}
.tab-modern {
display: flex;
align-items: center;
gap: 6px;
}
.tab-modern .v-icon {
font-size: $font-xl !important;
}
/* Content Area */
.content-modern {
background: white;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.content-modern::-webkit-scrollbar {
width: $spacing-xs;
}
.content-modern::-webkit-scrollbar-track {
background: transparent;
}
.content-modern::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.tab-content {
animation: fadeIn $transition-slow ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Status Header */
.status-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-md;
padding: $spacing-lg;
background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%);
border-radius: $radius-md;
border: 1px solid rgba(21, 101, 192, 0.1);
text-align: center;
}
.status-icon-wrapper {
width: 40px;
height: 40px;
border-radius: 10px;
background: white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-sm;
flex-shrink: 0;
margin: 0 auto;
}
.status-text {
width: 100%;
text-align: center;
}
.status-title {
font-size: $font-lg;
font-weight: 600;
color: $text-primary;
margin: 0 0 $spacing-xs;
letter-spacing: -0.3px;
text-align: center;
}
.status-subtitle {
font-size: $font-base;
color: $text-secondary;
margin: 0;
line-height: 1.4;
text-align: center;
}
/* QR Scanner Modern */
.qr-scanner-container {
display: flex;
justify-content: center;
align-items: center;
margin: $spacing-2xl 0;
}
.qr-placeholder {
width: 100%;
max-width: 300px;
height: 300px;
background: linear-gradient(135deg, $bg-grey 0%, $bg-blue-light 100%);
border-radius: $radius-lg;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px dashed $primary-color;
box-shadow: 0 4px 20px rgba(21, 101, 192, 0.1);
margin: 0 auto;
}
.qr-reader-container {
width: 100%;
max-width: 500px;
margin: 0 auto;
position: relative;
}
.qr-reader-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-xl;
background: #000;
position: relative;
max-width: 500px;
max-height: 500px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(21, 101, 192, 0.2);
margin: 0 auto;
}
.qr-reader-wrapper :deep(video) {
width: 100% !important;
height: 100% !important;
border-radius: $radius-lg;
display: block !important;
object-fit: cover;
background: #000;
aspect-ratio: 1 / 1;
}
.qr-reader-wrapper :deep(canvas) {
display: none !important;
}
.qr-reader-wrapper :deep(#qr-reader__dashboard) {
display: none !important;
}
.qr-reader-wrapper :deep(#qr-reader__scan_region) {
border-radius: $radius-lg;
border: 3px solid rgba(21, 101, 192, 0.8) !important;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4) !important;
position: relative;
}
.qr-reader-wrapper :deep(#qr-reader__scan_region::before) {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border: 3px solid rgba(21, 101, 192, 0.8);
border-radius: $radius-lg;
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.02);
}
}
.qr-reader-wrapper :deep(#qr-reader__scan_region video) {
border-radius: $radius-lg;
width: 100% !important;
height: 100% !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.scanner-status {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
font-size: $font-sm;
}
.scanner-instruction {
display: flex;
align-items: center;
justify-content: center;
margin-top: $spacing-md;
padding: 10px $spacing-md;
background: linear-gradient(135deg, rgba(21, 101, 192, 0.1) 0%, rgba(13, 71, 161, 0.1) 100%);
border-radius: 10px;
color: $primary-color;
font-weight: 500;
font-size: $font-sm;
text-align: center;
border: 1px solid rgba(21, 101, 192, 0.2);
}
.scanner-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
border-radius: $radius-lg;
z-index: $z-overlay;
}
.scanner-overlay {
position: absolute;
width: 80%;
height: 80%;
}
.corner {
position: absolute;
width: 40px;
height: 40px;
border: 3px solid $primary-color;
}
.corner-tl {
top: 0;
left: 0;
border-right: none;
border-bottom: none;
border-radius: $radius-sm 0 0 0;
}
.corner-tr {
top: 0;
right: 0;
border-left: none;
border-bottom: none;
border-radius: 0 $radius-sm 0 0;
}
.corner-bl {
bottom: 0;
left: 0;
border-right: none;
border-top: none;
border-radius: 0 0 0 $radius-sm;
}
.corner-br {
bottom: 0;
right: 0;
border-left: none;
border-top: none;
border-radius: 0 0 $radius-sm 0;
}
.scan-line {
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, $primary-color, transparent);
top: 0;
animation: scan 2s linear infinite;
box-shadow: 0 0 10px $primary-color;
}
@keyframes scan {
0% { top: 0; }
100% { top: 100%; }
}
.qr-icon {
position: relative;
z-index: $z-base;
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Modern Buttons */
.btn-primary-modern {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%) !important;
color: white !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.3px;
border-radius: $radius-md !important;
padding: 14px $spacing-2xl !important;
transition: all $transition-bezier;
box-shadow: $shadow-primary !important;
}
.btn-primary-modern:hover {
transform: translateY(-2px);
box-shadow: $shadow-primary-hover !important;
}
.btn-primary-modern:active {
transform: translateY(0);
}
.btn-primary-modern:disabled {
opacity: 0.5;
transform: none !important;
}
.btn-stop-modern {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
font-weight: 600;
text-transform: none;
letter-spacing: 0.3px;
border-radius: $radius-md !important;
padding: 14px $spacing-2xl !important;
transition: all $transition-bezier;
box-shadow: $shadow-error !important;
}
.btn-stop-modern:hover {
transform: translateY(-2px);
box-shadow: $shadow-error-hover !important;
}
.action-buttons {
margin-top: $spacing-xl;
display: flex;
justify-content: center;
}
.btn-centered {
width: auto !important;
min-width: 240px !important;
max-width: 320px !important;
margin: 0 auto !important;
display: block !important;
}
.btn-centered-small {
width: auto !important;
min-width: 180px !important;
margin: 0 auto;
display: block;
}
.btn-test-camera {
border-color: $primary-color !important;
color: $primary-color !important;
}
.btn-test-camera :deep(.v-btn__content) {
color: $primary-color !important;
}
.btn-test-camera :deep(.v-icon) {
color: $primary-color !important;
opacity: 1 !important;
}
/* Modern Inputs */
.input-modern :deep(.v-field) {
border-radius: $radius-md;
font-size: $font-md;
background: $bg-light;
border: 1.5px solid $border-light;
transition: all $transition-base;
}
.input-modern :deep(.v-field--focused) {
background: white;
border-color: $primary-color;
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.1);
}
.input-modern :deep(.v-field__input) {
padding-top: $spacing-md;
padding-bottom: $spacing-md;
}
.input-modern :deep(.v-label) {
font-weight: 500;
color: $text-secondary;
}
.quick-actions {
margin-top: $spacing-lg;
padding-top: $spacing-lg;
border-top: 1px solid $border-light;
}
.info-card {
display: flex;
justify-content: center;
}
.info-alert-centered {
width: 100%;
}
.info-alert-centered :deep(.v-alert__content) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
}
.quick-actions .v-btn {
transition: all $transition-base;
border-radius: $radius-md !important;
border: 1.5px solid $border-light !important;
}
.quick-actions .v-btn:hover {
transform: translateY(-2px);
border-color: $primary-color !important;
box-shadow: 0 4px 12px rgba(21, 101, 192, 0.15);
}
.quick-actions .v-btn :deep(.v-icon) {
opacity: 1 !important;
color: inherit !important;
}
.quick-actions .v-btn[color="primary"] {
color: $primary-color !important;
border-color: $primary-color !important;
}
.quick-actions .v-btn[color="primary"] :deep(.v-icon) {
color: $primary-color !important;
}
.qr-code-container {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-lg;
background: white;
border-radius: $radius-md;
box-shadow: $shadow-md;
}
.qr-code-container :deep(canvas) {
border-radius: $radius-sm;
}
.qr-display {
animation: slideUp 0.5s ease-out;
}
/* Stats Footer Modern */
.stats-footer-modern {
animation: slideUp 0.5s ease-out 0.3s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card-modern {
background: white;
border-radius: $radius-md;
padding: $spacing-lg $spacing-md;
text-align: center;
border: 1px solid $border-light;
transition: all $transition-bezier;
position: relative;
overflow: hidden;
}
.stat-card-modern::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, $primary-color 0%, $primary-dark 100%);
transform: scaleX(0);
transition: transform $transition-base;
}
.stat-card-modern:hover {
transform: translateY(-4px);
box-shadow: $shadow-lg;
border-color: $primary-color;
}
.stat-card-modern:hover::before {
transform: scaleX(1);
}
.stat-icon-modern {
margin-bottom: $spacing-md;
display: inline-flex;
padding: $spacing-sm;
background: $bg-blue-light;
border-radius: 10px;
}
.stat-value {
font-size: $font-2xl;
font-weight: 700;
color: $text-primary;
margin-bottom: $spacing-xs;
letter-spacing: -0.5px;
}
.stat-label {
font-size: $font-sm;
color: $text-secondary;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.custom-snackbar {
margin-bottom: $spacing-lg;
margin-right: $spacing-lg;
}
.custom-snackbar :deep(.v-snackbar__wrapper) {
min-width: 300px;
}
/* History Dialog Styles */
.history-list {
max-height: 500px;
overflow-y: auto;
padding-right: $spacing-sm;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
.history-item {
transition: all $transition-base;
border-left: 4px solid transparent;
}
.history-item:hover {
transform: translateX(4px);
box-shadow: $shadow-md;
}
.history-success {
border-left-color: #4caf50;
background: rgba(76, 175, 80, 0.05);
}
.history-failed {
border-left-color: #f44336;
background: rgba(244, 67, 54, 0.05);
}
.history-pending {
border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.05);
}
/* Mobile Optimization */
@media (max-width: 600px) {
.v-container.no-scroll-container {
padding: $spacing-sm !important;
}
.main-card {
border-radius: $radius-lg !important;
}
.header-modern {
padding: $spacing-lg $spacing-lg $spacing-md !important;
}
.content-modern {
padding: $spacing-lg !important;
max-height: calc(100vh - 180px);
}
.header-content {
flex-direction: column;
text-align: center;
gap: $spacing-md;
}
.header-text {
text-align: center;
}
.icon-circle {
width: 56px;
height: 56px;
}
.title-modern {
font-size: $font-2xl;
}
.subtitle-modern {
font-size: $font-base;
}
.content-modern {
padding: $spacing-xl $spacing-lg !important;
}
.status-header {
padding: $spacing-lg;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: $spacing-md;
}
.status-icon-wrapper {
margin: 0 auto;
}
.status-text {
text-align: center;
width: 100%;
}
.status-title {
font-size: $font-lg;
}
.status-subtitle {
font-size: $font-base;
}
.qr-placeholder {
max-width: 100%;
height: 280px;
}
.qr-reader-container {
max-width: 100%;
}
.qr-reader-wrapper {
min-height: 300px;
max-height: 70vh;
border-radius: $radius-lg;
}
.qr-reader-wrapper :deep(video) {
max-height: 70vh;
object-fit: contain;
}
.scanner-instruction {
font-size: $font-sm;
padding: $spacing-md;
}
.stats-footer-modern {
margin-top: $spacing-sm;
}
.stat-card-modern {
padding: $spacing-md $spacing-sm;
}
.stat-value {
font-size: $font-xl;
}
.stat-label {
font-size: $font-xs;
}
.stat-icon-modern {
margin-bottom: $spacing-sm;
padding: 6px;
}
}
+230
View File
@@ -0,0 +1,230 @@
// Check-in Page Dialog Styles
@import 'variables';
.blur-dialog :deep(.v-overlay__scrim) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.dialog-card {
overflow: hidden;
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.8) translateY(20px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dialog-header {
position: relative;
overflow: hidden;
}
.dialog-header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%);
animation: rotate 10s linear infinite;
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.icon-wrapper {
display: inline-block;
animation: bounceIn 0.6s ease-out 0.2s both;
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.dialog-icon {
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.3));
}
.success-header {
background: linear-gradient(135deg, $success-color 0%, $success-dark 100%);
}
.warning-header {
background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%);
}
.error-header {
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
}
.icon-bg-error {
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
}
.dialog-button {
text-transform: none;
letter-spacing: 0.5px;
font-weight: 600;
transition: all $transition-base;
}
.dialog-button:hover {
transform: translateY(-2px);
}
/* Patient Info Card Styling */
.patient-info-card {
background: rgba(250, 250, 250, 0.8) !important;
border-color: $border-grey !important;
}
.patient-info-label {
color: $text-grey !important;
font-weight: 500;
opacity: 0.9;
}
.patient-info-value {
color: $text-grey-dark !important;
font-weight: 500 !important;
opacity: 0.85;
}
.patient-info-icon {
opacity: 0.7;
}
/* Decorative Circles */
.decorative-circles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
.circle-1 {
width: 100px;
height: 100px;
top: -50px;
left: -50px;
animation-delay: 0s;
}
.circle-2 {
width: 150px;
height: 150px;
top: 50%;
right: -75px;
animation-delay: 2s;
}
.circle-3 {
width: 80px;
height: 80px;
bottom: -40px;
left: 20%;
animation-delay: 4s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.3;
}
50% {
transform: translate(20px, -20px) scale(1.1);
opacity: 0.5;
}
}
/* Message Container */
.message-container {
text-align: center;
}
.message-icon {
display: flex;
justify-content: center;
align-items: center;
}
/* Status Badge */
.status-badge {
display: inline-flex;
}
/* Instruction Box */
.instruction-box {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.instruction-success {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
}
.instruction-warning {
background: rgba(255, 152, 0, 0.1);
border-color: rgba(255, 152, 0, 0.3);
}
/* Icon Background */
.icon-bg {
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1;
}
.icon-bg-success {
background: linear-gradient(135deg, $success-color 0%, $success-dark 100%);
}
.icon-bg-warning {
background: linear-gradient(135deg, $warning-color 0%, $warning-dark 100%);
}
.icon-bg-error {
background: linear-gradient(135deg, $error-color 0%, $error-dark 100%);
}
+74
View File
@@ -0,0 +1,74 @@
// Check-in Page Variables
// Colors
$primary-color: #1565C0;
$primary-dark: #0D47A1;
$secondary-color: #FB8C00;
$success-color: #66BB6A;
$success-dark: #43A047;
$warning-color: #FFA726;
$warning-dark: #FB8C00;
$error-color: #EF5350;
$error-dark: #E53935;
// Background Colors
$bg-white: #ffffff;
$bg-light: #fafafa;
$bg-grey: #f5f7fa;
$bg-blue-light: #e3f2fd;
// Text Colors
$text-primary: #1a1a1a;
$text-secondary: #6b7280;
$text-grey: #9e9e9e;
$text-grey-dark: #757575;
// Border Colors
$border-light: #e5e7eb;
$border-grey: rgba(0, 0, 0, 0.08);
// Shadow
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
$shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.1);
$shadow-primary: 0 4px 16px rgba(21, 101, 192, 0.3);
$shadow-primary-hover: 0 8px 24px rgba(21, 101, 192, 0.4);
$shadow-error: 0 4px 16px rgba(239, 68, 68, 0.3);
$shadow-error-hover: 0 8px 24px rgba(239, 68, 68, 0.4);
// Border Radius
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
$radius-full: 50%;
// Spacing
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 16px;
$spacing-xl: 20px;
$spacing-2xl: 24px;
$spacing-3xl: 28px;
// Font Sizes
$font-xs: 10px;
$font-sm: 11px;
$font-base: 13px;
$font-md: 15px;
$font-lg: 16px;
$font-xl: 18px;
$font-2xl: 20px;
$font-3xl: 24px;
// Transitions
$transition-fast: 0.2s ease;
$transition-base: 0.3s ease;
$transition-slow: 0.4s ease;
$transition-bezier: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Z-index
$z-base: 1;
$z-overlay: 10;
$z-dialog: 100;
+28
View File
@@ -0,0 +1,28 @@
// variables for Grand Paviliun (Eksekutif)
.theme-grand-paviliun {
--gp-primary: #3F51B5; // Indigo header
--gp-primary-dark: #303F9F;
--gp-bg: #F5F7FA; // Light grayish blue background
--gp-surface: #FFFFFF;
--gp-text-primary: #1F2937;
--gp-text-secondary: #6B7280;
--gp-border: #E5E7EB;
--gp-success: #10B981; // Selesai
--gp-danger: #EF4444; // Pending
--gp-warning: #F59E0B;
--gp-info: #3B82F6;
// Custom colors for specific tags
--gp-room-number: #F97316; // Orange text for RUANG 02
background-color: var(--gp-bg);
min-height: 100vh;
font-family: 'Inter', 'Roboto', sans-serif;
}
// Utility classes
.gp-text-primary { color: var(--gp-text-primary); }
.gp-text-secondary { color: var(--gp-text-secondary); }
.gp-font-bold { font-weight: 700; }
.gp-font-semibold { font-weight: 600; }
+74
View File
@@ -0,0 +1,74 @@
// Gunakan @use daripada @import
@use "./variables" as *;
@use "./colors" as *;
@use "./typography" as *;
@use "./pages/dashboard" as *;
// Global styles
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
background-color: var(--color-background);
color: var(--color-textPrimary);
transition:
background-color 0.3s ease,
color 0.3s ease;
}
// Selection color
::selection {
background-color: var(--color-primary-300);
color: var(--color-primary-700);
}
// Focus styles
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
// Responsive images
img {
max-width: 100%;
height: auto;
display: block;
}
// Links
a {
color: var(--color-primary-500);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--color-primary-600);
}
&:active {
color: var(--color-primary-700);
}
}
// Buttons base
button {
font-family: $font-family-base;
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
+179
View File
@@ -0,0 +1,179 @@
@use "../variables" as *;
@use "../colors" as *;
.dashboard-container {
min-height: 100vh;
background-color: #f5f5f5;
padding: 0;
}
.dashboard-content {
width: 100%;
margin: 0;
background: white;
min-height: 100vh;
}
.dashboard-header {
background: white;
border-bottom: 1px solid #e0e0e0;
padding: 1.5rem 2rem;
h1 {
font-size: 1.25rem;
font-weight: $font-weight-medium;
color: var(--color-neutral-900);
margin: 0;
}
}
.dashboard-body {
padding: 2rem;
}
.section {
margin-bottom: 2.5rem;
&-title {
font-size: 0.875rem;
font-weight: $font-weight-semibold;
color: var(--color-neutral-900);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
}
}
.info-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 0.75rem 1.5rem;
font-size: 0.875rem;
.label {
color: var(--color-neutral-700);
font-weight: $font-weight-regular;
}
.value {
color: var(--color-neutral-900);
font-weight: $font-weight-regular;
}
}
.token-section {
margin-bottom: 1.5rem;
.token-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
h4 {
font-size: 0.875rem;
font-weight: $font-weight-medium;
color: var(--color-neutral-900);
margin: 0;
}
}
.token-content {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-neutral-800);
word-break: break-all;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
&.collapsed {
max-height: 60px;
overflow: hidden;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #f8f9fa);
}
}
}
.toggle-btn {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-primary-600);
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: underline;
&:hover {
color: var(--color-primary-700);
}
}
}
.copy-btn {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
background: white;
border: 1px solid #d0d0d0;
border-radius: 4px;
color: var(--color-neutral-700);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #b0b0b0;
}
.v-icon {
font-size: 14px;
margin-right: 4px;
}
}
.payload-section {
.payload-content {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-neutral-800);
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
@media (max-width: $breakpoint-mobile) {
.dashboard-body {
padding: 1rem;
}
.info-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
.label {
font-weight: $font-weight-semibold;
}
}
}
+256
View File
@@ -0,0 +1,256 @@
/* assets/styles/loginpage/login.scss */
@use "~/assets/scss/_colors.scss" as *;
/* Main Background - Using Primary Color */
.login-background {
background: $primary-500; /* Primary-500: #567EE7 */
height: 100dvh;
position: relative;
overflow: hidden;
}
.login-background::before {
/* No radial gradients */
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: none;
}
/* Floating Medical Icons - Subtle on Primary Background */
.floating-medical-icon {
position: absolute;
opacity: 0.8;
transition: opacity 0.3s ease;
z-index: 1;
width: fit-content;
height: fit-content;
top: auto;
left: auto;
bottom: auto;
right: auto;
}
.floating-medical-icon:hover {
opacity: 1;
}
.floating-medical-icon .v-icon {
color: rgba(
255,
255,
255,
0.15
) !important; /* Light white for icons on primary background */
}
/* Specific icon positioning - now static */
.icon-1 {
top: 15%;
left: 5%;
}
.icon-2 {
top: 20%;
right: 10%;
}
.icon-3 {
bottom: 10%;
left: 15%;
}
.icon-4 {
top: 60%;
left: 10%;
}
.icon-5 {
bottom: 20%;
right: 5%;
}
.icon-6 {
top: 35%;
left: 20%;
}
/* Main Card - White */
.main-card {
z-index: 3;
/* max-width is now set in template to 450px */
margin: 2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); /* Adding a subtle shadow back */
}
.white-card {
background: $neutral-300 !important; /* Neutral-300: #F5F7FA */
border: 1px solid rgba(0, 0, 0, 0.1);
/* box-shadow is now managed by .main-card */
}
/* Right Login Section (Neutral-300) */
.login-section-box {
background: $neutral-300; /* Neutral-300: #F5F7FA */
min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */
}
.login-content {
max-width: 400px;
margin: 0 auto;
}
/* White Dialog Styles */
.white-dialog {
background: $neutral-100 !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Using Neutral Color with Larger Font */
.welcome-title-dark {
color: $neutral-900; /* Neutral-900: #212121 */
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: $neutral-700; /* Neutral-700: #717171 */
font-size: 1.1rem;
letter-spacing: 0.3px;
}
/* App Title - Using Primary Color */
.app-title-dark {
color: $primary-500; /* Primary-500: #567EE7 */
text-shadow: none;
}
.hospital-logo {
height: 180px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: $neutral-800; /* Dark grey for SSO text */
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons (Primary theme) */
.login-btn {
background: $primary-500 !important; /* Primary-500: #567EE7 */
color: $neutral-100 !important; /* White text on primary button */
border: none;
box-shadow:
0 8px 25px rgba(86, 126, 231, 0.3),
/* Primary shadow */ 0 4px 12px rgba(86, 126, 231, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
height: 56px;
}
.login-btn:hover {
transform: translateY(-2px);
background: $primary-600 !important; /* Primary-600: #3A61C9 on hover */
box-shadow:
0 12px 30px rgba(86, 126, 231, 0.4),
0 6px 15px rgba(86, 126, 231, 0.3);
}
.register-btn-dark {
color: $primary-500 !important; /* Primary-500: #567EE7 */
border: 2px solid $primary-500 !important; /* Primary border */
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
height: 48px;
}
.register-btn-dark:hover {
background: rgba(
86,
126,
231,
0.05
) !important; /* Light primary hover background */
border-color: $primary-600 !important; /* Primary-600 border on hover */
transform: translateY(-1px);
}
/* Custom Divider - Dark for white background */
.custom-divider-dark {
border-color: $neutral-400 !important; /* Light grey divider */
opacity: 1 !important;
}
/* Help Text and Links - Dark for white background */
.help-text-dark {
color: $neutral-800; /* Dark grey help text */
font-size: 0.9rem;
}
.contact-link-dark {
color: $primary-500 !important; /* Primary-500: #567EE7 */
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: $neutral-700; /* Grey link */
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: $primary-500; /* Primary-500 on hover */
}
/* Transparent Background */
.transparent {
background: transparent !important;
}
/* Dialog Accents */
.v-card-title .v-icon,
.v-list-item .v-icon {
color: $primary-500 !important; /* Primary accents */
}
.v-btn[color="#FF9B1B"] {
background-color: $primary-500 !important;
color: $neutral-100 !important;
}
.v-alert[color="orange"] {
border-left: 8px solid $primary-500 !important;
color: $neutral-900 !important;
}
/* Responsive Design Adjustments for single column */
@media (max-width: 960px) {
.main-card {
margin: 1rem;
max-width: 450px !important; /* Enforce max-width on smaller screens */
}
.login-section-box {
min-height: auto;
padding: 2rem !important;
}
}
@media (max-width: 600px) {
.main-card {
margin: 0.5rem;
}
.login-section-box {
padding: 1.5rem !important;
}
}
+520
View File
@@ -0,0 +1,520 @@
<template>
<div>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="28" color="white">mdi-shield-lock-outline</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Manajemen Hak Akses (Keycloak Integration)</h2>
<p class="page-subtitle">Konfigurasi Akses Halaman berdasarkan Role & Group Real-time</p>
</div>
</div>
</div>
</div>
<div class="hak-akses-page pa-4">
<!-- Tabs Selection -->
<v-tabs v-model="activeTab" color="primary" class="mb-4 bg-white rounded-lg elevation-1" align-tabs="start">
<v-tab value="roles" class="text-capitalize">
<v-icon start>mdi-account-star-outline</v-icon>
Realm Roles
</v-tab>
<v-tab value="groups" class="text-capitalize">
<v-icon start>mdi-account-group-outline</v-icon>
Groups
</v-tab>
</v-tabs>
<!-- Action Bar -->
<div class="action-bar mb-4 d-flex justify-space-between align-center px-4 py-3 bg-white rounded-xl border elevation-1">
<div class="d-flex align-center gap-2">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:label="`Cari ${activeTab === 'roles' ? 'Role' : 'Group'}...`"
variant="outlined"
density="compact"
hide-details
rounded="lg"
class="max-width-400"
></v-text-field>
<v-chip color="primary" variant="tonal" class="font-weight-bold">
{{ displayItems.length }} {{ activeTab === 'roles' ? 'Roles' : 'Groups' }}
</v-chip>
</div>
<v-btn
color="primary"
@click="loadData"
prepend-icon="mdi-sync"
variant="flat"
rounded="lg"
:loading="loading"
>
Sync Keycloak
</v-btn>
</div>
<!-- Main Content Card -->
<v-card class="rounded-xl border-0 shadow-soft overflow-hidden">
<v-data-table
:headers="headers"
:items="displayItems"
:loading="loading"
class="custom-table"
hover
>
<template v-slot:[`item.no`]="{ index }">
<span class="text-grey-darken-1 font-weight-medium">{{ index + 1 }}</span>
</template>
<template v-slot:[`item.name`]="{ item }">
<div class="d-flex align-center">
<v-icon
:icon="activeTab === 'roles' ? 'mdi-account-star' : 'mdi-folder-account'"
size="small"
color="primary"
class="mr-2"
></v-icon>
<span class="font-weight-bold text-primary-darken-1">{{ item.name }}</span>
</div>
</template>
<template v-slot:[`item.pagesCount`]="{ item }">
<v-tooltip bottom>
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
:color="item.validPagesCount > 0 ? 'success' : 'grey'"
size="small"
variant="flat"
class="font-weight-bold"
>
{{ item.validPagesCount }} Halaman
</v-chip>
</template>
<span>Akses ke {{ item.validPagesCount }} Halaman Terdaftar</span>
</v-tooltip>
</template>
<template v-slot:[`item.memberCount`]="{ item }">
<v-btn
variant="text"
color="primary"
size="small"
class="text-capitalize font-weight-bold"
prepend-icon="mdi-account-multiple-outline"
@click="viewMembers(item)"
>
Deteksi Member
</v-btn>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
color="warning"
size="small"
variant="flat"
prepend-icon="mdi-shield-edit-outline"
class="text-capitalize rounded-lg mr-2"
@click="editPermissions(item)"
>
Atur Akses
</v-btn>
</template>
</v-data-table>
</v-card>
</div>
<!-- Edit Permissions Dialog -->
<v-dialog v-model="showEditDialog" max-width="900px" persistent scrollable>
<v-card class="rounded-xl overflow-hidden">
<v-card-title class="pa-6 border-b d-flex align-center bg-primary text-white">
<v-icon icon="mdi-shield-edit" class="mr-3"></v-icon>
<div>
<div class="text-h6">Konfigurasi Akses: {{ editedEntity?.name }}</div>
<div class="text-caption text-white text-opacity-75">Tentukan halaman yang dapat diakses oleh {{ activeTab }} ini</div>
</div>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" color="white" @click="showEditDialog = false"></v-btn>
</v-card-title>
<v-card-text class="pa-0">
<div class="pa-6 bg-grey-lighten-4">
<v-alert
type="info"
variant="tonal"
density="compact"
class="mb-4 rounded-lg"
icon="mdi-information-outline"
>
Jika user memiliki beberapa Role/Group, mereka akan mendapatkan akses gabungan dari semuanya.
</v-alert>
</div>
<div class="pa-6 pt-0">
<EditHakAkses
v-if="editedEntity"
:pages="editedEntity.pages"
@update:pages="editedEntity.pages = $event"
/>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 bg-white">
<v-spacer></v-spacer>
<v-btn variant="outlined" rounded="lg" @click="showEditDialog = false" class="px-6 border-primary-lighten-4 text-primary">Batal</v-btn>
<v-btn color="primary" rounded="lg" @click="savePermissions" class="px-6 ml-2" :loading="loading">
Simpan Perubahan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Member List Dialog -->
<v-dialog v-model="showMembersDialog" max-width="600px" scrollable>
<v-card class="rounded-xl">
<v-card-title class="pa-6 border-b d-flex align-center bg-grey-lighten-4">
<v-icon icon="mdi-account-group" color="primary" class="mr-3"></v-icon>
<div>
<div class="text-h6">Member: {{ editedEntity?.name }}</div>
<div class="text-caption text-grey-darken-1">Daftar pengguna yang memiliki {{ activeTab }} ini</div>
</div>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" @click="showMembersDialog = false"></v-btn>
</v-card-title>
<v-card-text class="pa-0">
<v-list v-if="!memberLoading && currentMembers.length > 0" lines="two" class="pa-0">
<v-list-item
v-for="(user, i) in currentMembers"
:key="user.id"
:border="i !== currentMembers.length - 1"
class="px-6 py-3"
>
<template v-slot:prepend>
<v-avatar color="primary-lighten-5" size="44">
<v-icon icon="mdi-account" color="primary"></v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold text-primary">{{ user.name }}</v-list-item-title>
<v-list-item-subtitle class="mt-1">
<v-chip size="x-small" variant="outlined" class="mr-2">{{ user.username }}</v-chip>
<span class="text-caption text-grey">{{ user.email }}</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else-if="!memberLoading" class="pa-12 text-center">
<v-avatar color="grey-lighten-4" size="80" class="mb-4">
<v-icon icon="mdi-account-off-outline" color="grey" size="40"></v-icon>
</v-avatar>
<div class="text-grey font-weight-medium">Tidak ada pengguna yang terdeteksi</div>
</div>
<div v-else class="pa-6">
<v-skeleton-loader type="list-item-avatar-two-line@5"></v-skeleton-loader>
</div>
</v-card-text>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout" location="top">
{{ snackbar.message }}
<template v-slot:actions>
<v-btn icon="mdi-close" variant="text" @click="snackbar.show = false"></v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '~/stores/navItems1';
import type { HakAkses } from '~/types/setting';
definePageMeta({
middleware: ['auth', 'check-page-access']
})
const navItemsStore = useNavItemsStore();
// State
const activeTab = ref('roles');
const hakAksesList = ref<HakAkses[]>([]);
const keycloakEntities = ref<{ roles: any[], groups: any[] }>({ roles: [], groups: [] });
const loading = ref(false);
const showEditDialog = ref(false);
const showMembersDialog = ref(false);
const editedEntity = ref<any>(null);
const currentMembers = ref<any[]>([]);
const memberLoading = ref(false);
const search = ref('');
const snackbar = ref({
show: false,
message: '',
color: 'success',
timeout: 3000
});
const showSnackbar = (message: string, color: string = 'success') => {
snackbar.value.message = message;
snackbar.value.color = color;
snackbar.value.show = true;
};
// Headers
const headers = [
{ title: 'No', key: 'no', align: 'center' as const, width: '80px' },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Akses', key: 'pagesCount', align: 'center' as const, width: '150px' },
{ title: 'Member', key: 'memberCount', align: 'center' as const, width: '150px' },
{ title: 'Aksi', key: 'actions', align: 'center' as const, sortable: false, width: '250px' }
];
// Load everything
const loadData = async () => {
loading.value = true;
try {
// 1. Load our local mappings
const hakAksesRes = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
if (hakAksesRes && hakAksesRes.success) {
hakAksesList.value = hakAksesRes.data || [];
}
// 2. Load Keycloak entities
const entitiesRes = await $fetch<{ success: boolean, data: any }>('/api/hak-akses/entities');
if (entitiesRes && entitiesRes.success) {
keycloakEntities.value = entitiesRes.data;
}
} catch (error) {
console.error('Error loading data:', error);
showSnackbar('Gagal memuat data dari Keycloak', 'error');
} finally {
loading.value = false;
}
};
// Computed list based on active tab
const displayItems = computed(() => {
const list = activeTab.value === 'roles' ? keycloakEntities.value.roles : keycloakEntities.value.groups;
// Get all valid paths from current navigation store
const allNavPaths = new Set<string>();
const extractPaths = (items: any[]) => {
items.forEach((item: any) => {
if (item.path) allNavPaths.add(item.path);
if (item.children) extractPaths(item.children);
});
};
extractPaths(navItemsStore.getNavItems);
return list.map((entity: any) => {
// Find existing permission mapping
const mapping = hakAksesList.value.find(h => h.namaHakAkses === entity.name);
if (!mapping) return { ...entity, pages: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null };
// Count only valid pages that exist in navigation
const validPages = (mapping.pages || []).filter(p => {
const path = typeof p === 'string' ? p : (p as any)?.path;
return path && allNavPaths.has(path);
});
return {
...entity,
pages: mapping.pages, // Keep original for editing
validPagesCount: validPages.length,
status: mapping.status,
id_mapping: mapping.id
};
}).filter((item: any) => {
if (!search.value) return true;
return item.name.toLowerCase().includes(search.value.toLowerCase());
});
});
const editPermissions = (item: any) => {
editedEntity.value = JSON.parse(JSON.stringify(item));
showEditDialog.value = true;
};
const viewMembers = async (item: any) => {
editedEntity.value = item;
currentMembers.value = [];
showMembersDialog.value = true;
memberLoading.value = true;
try {
const res = await $fetch<{ success: boolean, data: any[] }>(`/api/hak-akses/members`, {
params: {
type: item.type,
name: item.name,
id: item.id
}
});
if (res && res.success) {
currentMembers.value = res.data;
}
} catch (error) {
console.error('Error loading members:', error);
showSnackbar('Gagal memuat member', 'error');
} finally {
memberLoading.value = false;
}
};
const savePermissions = async () => {
if (!editedEntity.value) return;
loading.value = true;
try {
const payload = {
id: editedEntity.value.id_mapping, // If null, backend create new
namaHakAkses: editedEntity.value.name,
status: 'aktif',
pages: editedEntity.value.pages
};
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
method: 'POST',
body: payload
});
if (response.success) {
showSnackbar('Hak akses berhasil diperbarui', 'success');
await loadData();
await navItemsStore.refreshNavItems();
showEditDialog.value = false;
}
} catch (error) {
console.error('Error saving:', error);
showSnackbar('Gagal menyimpan perubahan', 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
<style scoped lang="scss">
// Colors from Design System
$primary-700: #3556AE;
$primary-600: #3A61C9;
$success-700: #1B6E53;
$success-600: #009262;
$neutral-100: #FFFFFF;
$neutral-800: #4D4D4D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 0 !important;
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
height: 80px;
color: $neutral-100;
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 12px;
margin-right: 16px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 32px;
line-height: 40px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 2px 0 0 0;
opacity: 0.9;
font-size: 15px;
line-height: 22px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
// ============================================
// ACTION BAR (matching UserLogin)
// ============================================
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: $neutral-100;
border-radius: 12px;
border: 1px solid #E0E0E0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.action-bar-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.stat-chip {
color: $primary-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.5px;
}
.max-width-400 {
max-width: 400px;
}
.gap-2 {
gap: 8px;
}
.border-b {
border-bottom: 1px solid rgba(0,0,0,0.12);
}
</style>
+93
View File
@@ -0,0 +1,93 @@
// server/api/hak-akses/entities.get.ts
export default defineEventHandler(async (event) => {
console.log("📥 Fetching Keycloak entities (roles & groups)");
// Get session from session store
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
const session = await getSessionFromCookie(event);
if (!session) {
throw createError({
statusCode: 401,
statusMessage: "Session expired or not found",
});
}
const config = useRuntimeConfig();
const accessToken = session.accessToken;
if (!accessToken) {
throw createError({
statusCode: 401,
statusMessage: "No access token found in session",
});
}
try {
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
// 1. Fetch Realm Roles
const rolesResponse = await fetch(`${adminBaseUrl}/roles`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!rolesResponse.ok) {
throw new Error(`Failed to fetch roles: ${rolesResponse.status}`);
}
const roles = await rolesResponse.json();
// Filter out potential client-specific roles if needed, keeping realm roles
const realmRoles = roles.filter((r: any) => !r.clientRole);
// 2. Fetch Groups
const groupsResponse = await fetch(`${adminBaseUrl}/groups`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!groupsResponse.ok) {
throw new Error(`Failed to fetch groups: ${groupsResponse.status}`);
}
const groups = await groupsResponse.json();
// 3. For each role and group, we'll need to fetch members to show the count
// NOTE: This might be expensive if there are many roles/groups.
// For now, let's just return the list and we'll fetch members on demand in the UI or in a separate task.
// To be efficient, we'll only return basic info here.
return {
success: true,
data: {
roles: realmRoles.map((r: any) => ({
id: r.id,
name: r.name,
description: r.description || '',
type: 'role'
})),
groups: groups.map((g: any) => ({
id: g.id,
name: g.name,
path: g.path,
type: 'group'
}))
}
};
} catch (error: any) {
console.error("❌ Error fetching Keycloak entities:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to fetch Keycloak entities",
});
}
});
+74
View File
@@ -0,0 +1,74 @@
// server/api/hak-akses/members.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { type, name, id } = query;
if (!type || (!name && !id)) {
throw createError({
statusCode: 400,
statusMessage: "Type and (Name or ID) are required",
});
}
// Get session
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
const session = await getSessionFromCookie(event);
if (!session) {
throw createError({
statusCode: 401,
statusMessage: "Session expired",
});
}
const config = useRuntimeConfig();
const accessToken = session.accessToken;
try {
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
let url = '';
if (type === 'role') {
url = `${adminBaseUrl}/roles/${name}/users`;
} else if (type === 'group') {
url = `${adminBaseUrl}/groups/${id}/members`;
} else {
throw new Error("Invalid type");
}
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch members: ${response.status}`);
}
const members = await response.json();
return {
success: true,
data: members.map((m: any) => ({
id: m.id,
username: m.username,
email: m.email,
firstName: m.firstName,
lastName: m.lastName,
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.username
}))
};
} catch (error: any) {
console.error("❌ Error fetching members:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to fetch members",
});
}
});
+113
View File
@@ -0,0 +1,113 @@
// composables/useHakAkses.ts
// Composable for handling user permissions/access based on hakAkses
import { useAuth } from "~/composables/useAuth";
import type { HakAkses } from "~/types/setting";
export const useHakAkses = () => {
const { user, checkAuth } = useAuth();
/**
* Get all pages that user has access to based on their roles
*/
const getAllowedPages = async (): Promise<string[]> => {
// Ensure user is loaded
if (!user.value) {
await checkAuth();
}
const currentUser = user.value;
if (!currentUser) {
return [];
}
// Get roles and groups from multiple possible sources in User object
const roles = [
...(currentUser.roles || []),
...((currentUser as any).realm_access?.roles || []),
...((currentUser as any).resource_access?.['web-antrean']?.roles || [])
];
const groups = (currentUser as any).groups || [];
// Combine everything the user belongs to
const entities = [...new Set([...roles, ...groups])];
if (entities.length === 0) {
return [];
}
try {
// Fetch all hak akses data
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
if (response && response.success && Array.isArray(response.data)) {
const hakAksesList = response.data;
// Filter hak akses that match user's entities and are active
const userHakAkses = hakAksesList.filter((hakAkses) =>
entities.includes(hakAkses.namaHakAkses) &&
hakAkses.status === 'aktif'
);
// Combine all pages from all matching hak akses
const allPages = userHakAkses.reduce((pages: string[], hakAkses) => {
if (hakAkses.pages && Array.isArray(hakAkses.pages)) {
return [...pages, ...hakAkses.pages];
}
return pages;
}, []);
// Remove duplicates
return [...new Set(allPages)];
}
return [];
} catch (error) {
console.error('Error fetching allowed pages:', error);
return [];
}
};
/**
* Check if user has access to a specific page
*/
const hasPageAccess = async (pagePath: string): Promise<boolean> => {
const allowedPages = await getAllowedPages();
return allowedPages.includes(pagePath);
};
/**
* Check if user has access to any page in a list
*/
const hasAnyPageAccess = async (pagePaths: string[]): Promise<boolean> => {
const allowedPages = await getAllowedPages();
return pagePaths.some(path => allowedPages.includes(path));
};
const allHakAksesData = ref<HakAkses[]>([]);
const isLoading = ref(false);
const fetchHakAkses = async () => {
isLoading.value = true;
try {
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
if (response && response.success) {
allHakAksesData.value = response.data;
}
} catch (error) {
console.error('Error fetching hak akses:', error);
} finally {
isLoading.value = false;
}
};
return {
allHakAksesData,
isLoading,
fetchHakAkses,
getAllowedPages,
hasPageAccess,
hasAnyPageAccess
};
};
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const targetBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1'
// Extract path suffix after /klinik-api
const suffix = event.path.replace(/^\/klinik-api/, '')
const targetUrl = `${targetBase}${suffix}`
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
const headers = { ...event.node.req.headers } as Record<string, string>
// Extract backend target host
try {
const urlObj = new URL(targetBase)
headers.host = urlObj.host
} catch (e) {
headers.host = config.proxyTargetHostKlinikFallback || '10.10.123.140:8089'
}
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
const proxyOrigin = config.proxyClientOrigin as string
headers.origin = proxyOrigin
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
console.log(`[Proxy Klinik-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
console.log(`[Proxy Klinik-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
try {
return await proxyRequest(event, targetUrl, {
headers
})
} catch (error: any) {
console.error(`[Proxy Klinik-API] Error forwarding to ${targetUrl}:`, error)
throw error
}
})
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
// Extract path suffix after /stats-api
const suffix = event.path.replace(/^\/stats-api/, '')
const targetUrl = `${targetBase}${suffix}`
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
const headers = { ...event.node.req.headers } as Record<string, string>
// Extract backend target host
try {
const urlObj = new URL(targetBase)
headers.host = urlObj.host
} catch (e) {
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
}
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
const proxyOrigin = config.proxyClientOrigin as string
headers.origin = proxyOrigin
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
console.log(`[Proxy Stats-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
console.log(`[Proxy Stats-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
try {
return await proxyRequest(event, targetUrl, {
headers
})
} catch (error: any) {
console.error(`[Proxy Stats-API] Error forwarding to ${targetUrl}:`, error)
throw error
}
})
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const targetBase = config.public.externalApiBaseUrl || 'http://10.10.123.135:8084/api/v1'
// Extract path suffix after /visit-api
const suffix = event.path.replace(/^\/visit-api/, '')
const targetUrl = `${targetBase}${suffix}`
// Clone request headers and overwrite Host/Origin/Referer with backend-whitelisted values
const headers = { ...event.node.req.headers } as Record<string, string>
// Extract backend target host
try {
const urlObj = new URL(targetBase)
headers.host = urlObj.host
} catch (e) {
headers.host = config.proxyTargetHostFallback || '10.10.123.135:8084'
}
// Spoof the origin and referer to match the server's trusted/whitelisted client origin
const proxyOrigin = config.proxyClientOrigin as string
headers.origin = proxyOrigin
headers.referer = proxyOrigin.endsWith('/') ? proxyOrigin : `${proxyOrigin}/`
console.log(`[Proxy Visit-API] Forwarding ${event.node.req.method} to: ${targetUrl}`)
console.log(`[Proxy Visit-API] Headers - Host: ${headers.host}, Origin: ${headers.origin}`)
try {
return await proxyRequest(event, targetUrl, {
headers
})
} catch (error: any) {
console.error(`[Proxy Visit-API] Error forwarding to ${targetUrl}:`, error)
throw error
}
})
@@ -0,0 +1,174 @@
<template>
<div class="theme-grand-paviliun gp-container">
<!-- Header -->
<div class="gp-header">
<div class="header-left">
<v-icon size="24" color="white" class="mr-3">mdi-domain</v-icon>
<div>
<h1 class="header-title">Klinik Admin Grand Paviliun</h1>
<p class="header-date">{{ currentDate }}</p>
</div>
</div>
<div class="header-right">
<v-btn color="#F97316" variant="flat" class="text-white font-weight-bold">
<v-icon start size="18">mdi-account-cog</v-icon>
KELOLA PASIEN
</v-btn>
</div>
</div>
<!-- Search Bar -->
<div class="gp-search-bar">
<v-text-field
v-model="searchQuery"
placeholder="Cari pasien di semua ruang (barcode, nomor antrian, nama...)"
density="compact"
hide-details
prepend-inner-icon="mdi-magnify"
variant="outlined"
bg-color="white"
class="search-input"
></v-text-field>
</div>
<!-- Main Content -->
<div class="gp-main-content">
<div class="kanban-board">
<GpRoomColumn
v-for="clinic in filteredClinics"
:key="clinic.kodeKlinik"
:clinic-name="clinic.namaKlinik"
:current-patient="clinic.currentPatient"
:queue-patients="clinic.queuePatients"
:room-options="clinic.roomOptions"
@pemeriksaan-awal="(p, r) => handlePemeriksaanAwal(p, clinic.kodeKlinik, r)"
@panggil-pemeriksaan="(p, r) => handlePanggilPemeriksaan(p, clinic.kodeKlinik, r)"
@action-pending="p => handleAction(p, 'pending', clinic.kodeKlinik)"
@action-selesai="p => handleAction(p, 'selesai', clinic.kodeKlinik)"
@process-patient="p => handleProcess(p, clinic.kodeKlinik, clinic.roomOptions?.[0]?.nomorRuang || '1')"
/>
</div>
<!-- Right Sidebar -->
<GpMonitorSidebar :rooms="monitorRooms" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import GpRoomColumn from '../GrandPaviliun/GpRoomColumn.vue';
import GpMonitorSidebar from '../GrandPaviliun/GpMonitorSidebar.vue';
import { useGrandPaviliun } from '@/composables/useGrandPaviliun';
import '@/assets/scss/eksekutif.scss';
const {
clinics,
monitorRooms,
fetchEksekutifData,
handlePemeriksaanAwal,
handlePanggilPemeriksaan,
processPatientAction
} = useGrandPaviliun();
const searchQuery = ref('');
const filteredClinics = computed(() => {
if (!searchQuery.value) return clinics.value;
const lowerSearch = searchQuery.value.toLowerCase();
// Filter by patient barcode, queue number, or name in the clinic columns
return clinics.value.map(clinic => {
const isCurrentMatch = clinic.currentPatient && (
(clinic.currentPatient.noAntrian || '').toLowerCase().includes(lowerSearch) ||
(clinic.currentPatient.barcode || '').toLowerCase().includes(lowerSearch) ||
(clinic.currentPatient.name || '').toLowerCase().includes(lowerSearch)
);
const matchedQueue = clinic.queuePatients.filter(p =>
(p.noAntrian || '').toLowerCase().includes(lowerSearch) ||
(p.barcode || '').toLowerCase().includes(lowerSearch) ||
(p.name || '').toLowerCase().includes(lowerSearch)
);
return {
...clinic,
currentPatient: isCurrentMatch ? clinic.currentPatient : null,
queuePatients: matchedQueue
};
}).filter(c => c.currentPatient || c.queuePatients.length > 0);
});
// Action Handlers
const handleAction = async (patient, action, kodeKlinik) => {
// Use the current room of the patient or fallback to '1'
const nomorRuang = patient.nomorRuang || '1';
await processPatientAction(patient, action, kodeKlinik, nomorRuang);
};
const handleProcess = async (patient, kodeKlinik, fallbackRuang) => {
const action = patient.status === 'pending' ? 'waiting' : 'proses';
await processPatientAction(patient, action, kodeKlinik, fallbackRuang);
};
// Date Formatting
const currentDate = computed(() => {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
return new Date().toLocaleDateString('id-ID', options);
});
// Load data when mounted
fetchEksekutifData();
</script>
<style scoped>
.gp-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background-color: var(--gp-bg, #F5F7FA);
}
.gp-header {
background-color: var(--gp-primary, #3F51B5);
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
.header-title {
font-size: 18px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.header-date {
font-size: 12px;
opacity: 0.8;
margin: 0;
}
.gp-search-bar {
padding: 12px 24px;
background: white;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.search-input {
max-width: 100%;
}
.gp-main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.kanban-board {
flex: 1;
display: flex;
overflow-x: auto;
padding: 16px 24px;
align-items: stretch;
}
</style>
@@ -0,0 +1,135 @@
<template>
<div class="gp-current-patient">
<div class="header-row">
<span class="label">SEDANG DIPROSES</span>
<div class="badges">
<button class="action-badge" @click="$emit('action-pending')"><GpStatusBadge status="PENDING" /></button>
<button class="action-badge ml-1" @click="$emit('action-selesai')"><GpStatusBadge status="SELESAI" /></button>
</div>
</div>
<div class="patient-big-info">
<div class="queue-number">{{ queueNumber || '-' }}</div>
<div class="room-indicator" v-if="roomName">{{ roomName }}</div>
</div>
<div class="action-section">
<label class="input-label">Tujukan ke ruang*</label>
<v-select
v-model="selectedRoom"
:items="roomOptions"
item-title="namaRuang"
item-value="nomorRuang"
placeholder="Pilih Ruang Pemeriksaan"
density="compact"
variant="outlined"
hide-details
class="room-select mb-3"
></v-select>
<div class="action-buttons">
<v-btn
color="#3F51B5"
variant="flat"
class="flex-1 text-white text-none font-weight-bold"
size="small"
@click="$emit('pemeriksaan-awal')"
>
<v-icon start size="16">mdi-stethoscope</v-icon>
Pemeriksaan Awal
</v-btn>
<v-btn
color="#3F51B5"
variant="flat"
class="flex-1 text-white text-none font-weight-bold ml-2"
size="small"
@click="$emit('panggil-pemeriksaan')"
>
<v-icon start size="16">mdi-bullhorn</v-icon>
Panggil Pemeriksaan
</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import GpStatusBadge from './GpStatusBadge.vue';
const props = defineProps({
queueNumber: { type: String, default: '' },
roomName: { type: String, default: '' },
roomOptions: { type: Array, default: () => [] }
});
const emit = defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'action-pending', 'action-selesai', 'update:selectedRoom']);
const selectedRoom = ref(props.roomOptions?.[0]?.nomorRuang || null);
watch(selectedRoom, (newVal) => {
emit('update:selectedRoom', newVal);
});
</script>
<style scoped>
.gp-current-patient {
background: white;
padding: 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.label {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.patient-big-info {
text-align: center;
margin-bottom: 24px;
}
.queue-number {
font-size: 36px;
font-weight: 800;
color: var(--gp-text-primary, #1f2937);
line-height: 1.2;
}
.room-indicator {
font-size: 14px;
font-weight: 700;
color: var(--gp-room-number, #F97316);
margin-top: 4px;
text-transform: uppercase;
}
.input-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--gp-text-secondary, #6b7280);
margin-bottom: 4px;
}
.room-select {
/* vuetify default is fine */
}
.action-buttons {
display: flex;
width: 100%;
}
.flex-1 {
flex: 1;
}
.action-badge {
background: none;
border: none;
padding: 0;
cursor: pointer;
outline: none;
}
.action-badge:hover {
opacity: 0.8;
}
</style>
@@ -0,0 +1,98 @@
<template>
<div class="gp-monitor-sidebar">
<div class="sidebar-header">
<v-icon size="18" class="mr-2" color="#3F51B5">mdi-monitor-dashboard</v-icon>
<span class="title">MONITOR RUANG</span>
</div>
<div class="stats-row">
<div class="stat-box box-success">
<div class="stat-label text-success">TERSEDIA</div>
<div class="stat-value text-success">{{ availableCount }}</div>
</div>
<div class="stat-box box-danger ml-2">
<div class="stat-label text-danger">SIBUK</div>
<div class="stat-value text-danger">{{ busyCount }}</div>
</div>
</div>
<div class="rooms-list">
<GpRoomMonitorCard
v-for="room in rooms"
:key="room.id"
:room-name="room.name"
:active-patient="room.activePatient"
:clinic-name="room.clinicName"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import GpRoomMonitorCard from './GpRoomMonitorCard.vue';
const props = defineProps({
rooms: { type: Array, default: () => [] }
});
const availableCount = computed(() => props.rooms.filter(r => !r.activePatient).length);
const busyCount = computed(() => props.rooms.filter(r => r.activePatient).length);
</script>
<style scoped>
.gp-monitor-sidebar {
width: 300px;
background: white;
height: 100%;
display: flex;
flex-direction: column;
border-left: 1px solid var(--gp-border, #e5e7eb);
}
.sidebar-header {
padding: 16px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.title {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.stats-row {
display: flex;
padding: 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
}
.stat-box {
flex: 1;
padding: 12px;
border-radius: 2px;
}
.box-success {
background: #ECFDF5;
border: 1px solid #D1FAE5;
}
.box-danger {
background: #FFF7ED;
border: 1px solid #FFEDD5;
}
.stat-label {
font-size: 10px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Orange */
.rooms-list {
padding: 16px;
overflow-y: auto;
flex: 1;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div class="gp-queue-list-container">
<div class="list-header">
<span class="title">DAFTAR ANTRIAN</span>
<span class="count-badge">{{ patients.length }}</span>
</div>
<div class="list-body">
<GpQueueListItem
v-for="patient in patients"
:key="patient.id || patient.noAntrian"
:queue-number="patient.noAntrian"
:status="patient.status"
:is-active="patient.isActive"
@view-detail="$emit('view-detail', patient)"
@process-patient="$emit('process-patient', patient)"
/>
</div>
</div>
</template>
<script setup>
import GpQueueListItem from './GpQueueListItem.vue';
defineProps({
patients: { type: Array, default: () => [] }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-container {
display: flex;
flex-direction: column;
flex: 1;
background: white;
min-height: 200px;
}
.list-header {
padding: 12px 16px;
background: #EEF2F6; /* Light blue-gray from design */
border-bottom: 1px solid var(--gp-border, #e5e7eb);
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 10px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
.count-badge {
background: #D1D5DB; /* Gray */
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: 700;
}
.list-body {
overflow-y: auto;
}
</style>
@@ -0,0 +1,47 @@
<template>
<div :class="['gp-queue-list-item', { active: isActive }]">
<div class="item-left">
<span class="queue-number">{{ queueNumber }}</span>
</div>
<div class="item-right">
<GpStatusBadge v-if="status && !isActive" :status="status" />
<v-btn v-if="!isActive" icon="mdi-play" size="x-small" variant="text" color="success" class="ml-1" @click="$emit('process-patient')" />
<v-btn v-if="isActive" icon="mdi-eye" size="x-small" variant="text" color="#3F51B5" @click="$emit('view-detail')" />
</div>
</div>
</template>
<script setup>
import GpStatusBadge from './GpStatusBadge.vue';
defineProps({
queueNumber: { type: String, required: true },
status: { type: String, default: '' },
isActive: { type: Boolean, default: false }
});
defineEmits(['view-detail', 'process-patient']);
</script>
<style scoped>
.gp-queue-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--gp-border, #e5e7eb);
background-color: var(--gp-surface, #fff);
transition: background-color 0.2s ease;
}
.gp-queue-list-item:last-child {
border-bottom: none;
}
.gp-queue-list-item.active {
background-color: #E3EBFF; /* Light blue highlighting active patient */
}
.queue-number {
font-size: 14px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="gp-room-column">
<div class="column-header">
<v-icon size="20" class="mr-2" color="white">mdi-domain</v-icon>
<span class="column-title">{{ clinicName.toUpperCase() }}</span>
</div>
<div class="column-content">
<template v-if="currentPatient || queuePatients.length > 0">
<GpCurrentPatient
v-if="currentPatient"
:queue-number="currentPatient.noAntrian"
:room-name="currentPatient.roomIndicator"
:room-options="roomOptions"
@pemeriksaan-awal="$emit('pemeriksaan-awal', currentPatient, $event)"
@panggil-pemeriksaan="$emit('panggil-pemeriksaan', currentPatient, $event)"
@action-pending="$emit('action-pending', currentPatient)"
@action-selesai="$emit('action-selesai', currentPatient)"
/>
<GpQueueList
:patients="queuePatients"
@view-detail="p => $emit('view-detail', p)"
@process-patient="p => $emit('process-patient', p)"
/>
</template>
<div v-else class="empty-column-state">
<v-icon size="48" color="#D1D5DB" class="mb-4">mdi-account-off</v-icon>
<div class="empty-text">TIDAK ADA PASIEN YANG DIPROSES</div>
</div>
</div>
</div>
</template>
<script setup>
import GpCurrentPatient from './GpCurrentPatient.vue';
import GpQueueList from './GpQueueList.vue';
defineProps({
clinicName: { type: String, required: true },
currentPatient: { type: Object, default: null },
queuePatients: { type: Array, default: () => [] },
roomOptions: { type: Array, default: () => [] }
});
defineEmits(['pemeriksaan-awal', 'panggil-pemeriksaan', 'view-detail', 'action-pending', 'action-selesai', 'process-patient']);
</script>
<style scoped>
.gp-room-column {
width: 380px;
min-width: 380px;
background: var(--gp-bg, #F5F7FA);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-right: 16px;
}
.column-header {
background: var(--gp-primary, #3F51B5);
color: white;
padding: 12px 16px;
display: flex;
align-items: center;
}
.column-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.column-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: white; /* Make the whole column body white */
}
.empty-column-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
background: white;
}
.empty-text {
font-size: 11px;
font-weight: 700;
color: var(--gp-text-secondary, #6b7280);
}
</style>
@@ -0,0 +1,71 @@
<template>
<div class="gp-room-monitor-card" :class="{ 'is-busy': isBusy }">
<div class="card-header">
<span class="room-name">{{ roomName }}</span>
<span class="room-status" :class="isBusy ? 'text-danger' : 'text-success'">
{{ isBusy ? 'SIBUK' : 'TERSEDIA' }}
</span>
</div>
<div class="card-body">
<span v-if="isBusy" class="active-patient">{{ activePatient }} - {{ clinicName }}</span>
<span v-else class="waiting-text">Menunggu pasien...</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
roomName: { type: String, required: true },
activePatient: { type: String, default: null }, // e.g. "AI002"
clinicName: { type: String, default: '' },
});
const isBusy = computed(() => !!props.activePatient);
</script>
<style scoped>
.gp-room-monitor-card {
background: var(--gp-surface, #fff);
border: 1px solid var(--gp-border, #e5e7eb);
border-left: 4px solid var(--gp-success, #10b981);
border-radius: 2px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.gp-room-monitor-card.is-busy {
border-left-color: var(--gp-room-number, #F97316); /* Orange */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.room-name {
font-size: 13px;
font-weight: 700;
color: var(--gp-text-primary, #1f2937);
}
.room-status {
font-size: 10px;
font-weight: 700;
}
.text-success { color: var(--gp-success, #10b981); }
.text-danger { color: var(--gp-room-number, #F97316); } /* Matching image orange */
.card-body {
font-size: 13px;
}
.active-patient {
color: var(--gp-primary, #3F51B5);
font-weight: 700;
}
.waiting-text {
color: var(--gp-text-secondary, #6b7280);
font-style: italic;
font-size: 12px;
}
</style>
@@ -0,0 +1,40 @@
<template>
<span :class="['gp-badge', badgeClass]">
{{ label }}
</span>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: { type: String, required: true },
text: { type: String, default: '' }
});
const label = computed(() => props.text || props.status.toUpperCase());
const badgeClass = computed(() => {
const s = props.status.toLowerCase();
if (s === 'pending' || s === 'sibuk') return 'gp-badge-danger';
if (s === 'selesai' || s === 'tersedia' || s === 'proses') return 'gp-badge-success';
return 'gp-badge-default';
});
</script>
<style scoped>
.gp-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: white;
}
.gp-badge-danger { background-color: var(--gp-danger, #ef4444); }
.gp-badge-success { background-color: var(--gp-success, #10b981); }
.gp-badge-default { background-color: var(--gp-text-secondary, #6b7280); }
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<v-card variant="outlined" class="rounded-lg overflow-hidden">
<v-table density="compact">
<thead>
<tr>
<th class="text-left font-weight-bold">Halaman</th>
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
</tr>
</thead>
<tbody>
<template v-for="item in flatNavItems" :key="item.path || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.path }">
<td :style="{ paddingLeft: item.level * 24 + 'px' }">
<div class="d-flex align-center">
<v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon>
<span :class="{ 'font-weight-bold': !item.path }">{{ item.name }}</span>
</div>
</td>
<td class="text-center">
<v-checkbox
v-if="item.path"
:model-value="isAllowed(item.path)"
@update:model-value="toggleAccess(item, !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
<v-icon v-else icon="mdi-folder-open" size="small" color="grey-lighten-1"></v-icon>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-card>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1';
const props = defineProps({
pages: {
type: Array as () => any[], // Be flexible with legacy data
default: () => []
}
});
const emit = defineEmits(['update:pages']);
const navItemsStore = useNavItemsStore();
interface FlatNavItem {
name: string;
path: string;
icon: string;
level: number;
}
const flatNavItems = computed(() => {
const result: FlatNavItem[] = [];
const walk = (items: any[], level = 0) => {
items.forEach(item => {
result.push({
name: item.name,
path: item.path,
icon: item.icon,
level
});
if (item.children && item.children.length > 0) {
walk(item.children, level + 1);
}
});
};
walk(navItemsStore.getNavItems);
return result;
});
const isAllowed = (path: string) => {
if (!props.pages || !Array.isArray(props.pages)) return false;
return props.pages.some(p => {
if (typeof p === 'string') return p === path;
if (p && typeof p === 'object' && p.path) return p.path === path;
return false;
});
};
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
let newPages = [...props.pages];
if (allowed) {
if (!newPages.includes(item.path)) {
newPages.push(item.path);
}
} else {
newPages = newPages.filter(p => p !== item.path);
}
emit('update:pages', newPages);
};
</script>
<style scoped>
.bg-grey-lighten-4 {
background-color: #f5f5f5;
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<template>
<v-card
class="rounded-lg elevation-2 d-flex flex-column ticket-card"
color="white"
height="100%"
>
<!-- Header dengan background biru -->
<v-card-title class="ticket-header">
<div class="ticket-header-content">
<v-icon color="white" class="mr-2">mdi-hospital-box-outline</v-icon>
<span class="ticket-header-title">{{ cleanTitle }}</span>
<v-chip
v-if="extractedQueueNumber"
size="small"
class="ml-2 ticket-header-chip"
color="primary-700"
>
Antrean: {{ extractedQueueNumber }}
</v-chip>
</div>
</v-card-title>
<!-- Content -->
<v-card-text class="ticket-content pa-4">
<div class="timeline-scroll-container flex-grow-1">
<v-timeline density="compact" align="start" line-inset="12" line-color="#567EE7">
<v-timeline-item
v-for="(step, index) in steps"
:key="index"
:dot-color="getStepColor(index)"
:icon="getStepIcon(index)"
size="small"
icon-color="white"
>
<div class="d-flex flex-column align-start">
<span
class="font-weight-bold"
:class="getStepTextClass(index)"
>
{{ step.label }}
</span>
<span class="text-caption" :class="getStepDateClass(index)">
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
</span>
</div>
</v-timeline-item>
</v-timeline>
</div>
<v-divider class="mt-4 mb-4"></v-divider>
<v-btn
size="large"
variant="tonal"
color="#567EE7"
class="mt-auto font-weight-bold reprint-button text-secondary-500"
prepend-icon="mdi-printer"
block
>
Cetak Ulang Tiket
</v-btn>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
title: String,
steps: Array,
color: String,
currentStepLabel: String,
queueNumber: String, // Nomor antrean untuk ditampilkan di chip
});
const currentStepIndex = computed(() => {
return props.steps.findIndex(step => step.label === props.currentStepLabel);
});
// Extract nomor antrean dari title jika queueNumber tidak disediakan
const extractedQueueNumber = computed(() => {
if (props.queueNumber) {
return props.queueNumber;
}
// Extract dari title format: "JIWA (Antrean: 1)" atau "RADIOLOGI (Antrean: 5)"
const match = props.title.match(/\(Antrean:\s*(\d+)\)/);
return match ? match[1] : null;
});
// Clean title tanpa nomor antrean untuk ditampilkan di header
const cleanTitle = computed(() => {
// Hapus bagian "(Antrean: X)" dari title
return props.title.replace(/\s*\(Antrean:\s*\d+\)/g, '').trim();
});
const getStepIcon = (index) => {
if (index < currentStepIndex.value) {
return 'mdi-check';
} else if (index === currentStepIndex.value) {
return 'mdi-progress-check';
} else {
return 'mdi-circle-outline';
}
};
const getStepColor = (index) => {
// Warna berbeda berdasarkan status step (dari _colors.scss)
if (index < currentStepIndex.value) {
return '#33A484'; // success-600 - hijau untuk completed
} else if (index === currentStepIndex.value) {
return '#FF8441'; // secondary-500 - orange untuk current
}
return '#CDD4DC'; // neutral-500 - abu-abu untuk pending
};
const getStepTextClass = (index) => {
// Warna text label sesuai dengan warna dot
if (index < currentStepIndex.value) {
return 'text-success-600'; // hijau untuk completed
} else if (index === currentStepIndex.value) {
return 'text-secondary-500'; // orange untuk current
}
return 'text-neutral-600'; // abu-abu untuk pending
};
const getStepDateClass = (index) => {
// Warna text date/time lebih terang
if (index < currentStepIndex.value) {
return 'text-success-400'; // hijau terang untuk completed
} else if (index === currentStepIndex.value) {
return 'text-secondary-400'; // orange terang untuk current
}
return 'text-neutral-500'; // abu-abu untuk pending
};
</script>
<style scoped lang="scss">
@import '@/assets/scss/_colors.scss';
/* =============================================== */
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
/* =============================================== */
.timeline-scroll-container {
/* Flex grow untuk mengisi ruang yang tersedia */
flex: 1 1 auto;
min-height: 200px;
max-height: 400px;
/* Membuat konten scrollable di sumbu Y jika melebihi height */
overflow-y: auto;
/* Memberi jarak internal di bagian scrollable */
padding-right: 8px;
}
/* PENTING: Menghilangkan padding bawah dari V-Timeline bawaan */
.v-timeline {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
/* Kustomisasi scrollbar untuk tampilan yang lebih bersih (Opsional) */
.timeline-scroll-container::-webkit-scrollbar {
width: 6px;
}
.timeline-scroll-container::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
/* =============================================== */
/* V-TIMELINE KUSTOMISASI */
/* =============================================== */
.v-timeline-item :deep(.v-timeline-item__body) {
padding-inline-start: 16px !important;
}
.v-timeline-item :deep(.v-timeline-item__opposite) {
display: none;
}
/* =============================================== */
/* TICKET CARD - FIXED HEIGHT */
/* =============================================== */
.ticket-card {
min-height: 550px !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden;
}
/* =============================================== */
/* TICKET HEADER */
/* =============================================== */
.ticket-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 16px 20px;
box-shadow: 0 2px 8px rgba(58, 97, 201, 0.2);
border-radius: 8px 8px 0 0;
}
.ticket-header-content {
display: flex;
align-items: center;
font-weight: 700;
font-size: 16px;
gap: 8px;
width: 100%;
}
.ticket-header-title {
flex: 1;
color: $neutral-100;
font-weight: 700;
}
.ticket-header-chip {
color: $neutral-100 !important;
font-weight: 600;
font-size: 12px;
}
/* =============================================== */
/* TICKET CONTENT */
/* =============================================== */
.ticket-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px !important;
}
/* =============================================== */
/* REPRINT BUTTON - FIXED SIZE */
/* =============================================== */
.reprint-button {
min-height: 48px !important;
height: 48px !important;
max-height: 48px !important;
font-size: 14px !important;
letter-spacing: 0.5px;
flex-shrink: 0 !important;
margin-top: auto !important;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
// Basic test to verify Vitest is working
describe('Vitest Setup', () => {
it('works correctly', () => {
expect(1 + 1).toBe(2)
})
})
// Basic test for Vue component mounting (optional, depends on having a simple component)
/*
import MyComponent from './MyComponent.vue'
describe('Component Test', () => {
it('mounts properly', () => {
const wrapper = mount(MyComponent)
expect(wrapper.exists()).toBe(true)
})
})
*/
+181
View File
@@ -0,0 +1,181 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="500"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-8" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'success-header' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'warning-header' : 'error-header'">
<div class="decorative-circles">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<div class="icon-wrapper mb-4">
<div class="icon-bg" :class="lastCheckInResult?.success && infoAction === 'checkin' ? 'icon-bg-success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'icon-bg-warning' : 'icon-bg-error'">
<v-icon size="64" color="white" class="dialog-icon">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-alert-circle' : 'mdi-close-circle' }}
</v-icon>
</div>
</div>
<h2 class="text-h4 font-weight-bold text-white mb-2">
{{ lastCheckInResult?.success && infoAction === 'checkin'
? 'Check-in Berhasil!'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'Belum Diizinkan'
: 'Check-in Gagal' }}
</h2>
<div class="status-badge mt-3">
<v-chip
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'white' : 'white'"
:text-color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
</v-icon>
{{ lastCheckInResult?.success && infoAction === 'checkin'
? 'Berhasil'
: lastCheckInResult?.status === 'NOT_ALLOWED'
? 'Menunggu'
: 'Gagal' }}
</v-chip>
</div>
</div>
<v-card-text class="pa-8">
<div class="message-container">
<div class="message-icon mb-4">
<v-icon :color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'orange' : 'error'" size="32">
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check-circle' : lastCheckInResult?.status === 'NOT_ALLOWED' ? 'mdi-clock-alert' : 'mdi-close-circle' }}
</v-icon>
</div>
<div class="text-h6 font-weight-bold mb-3 text-center" style="white-space: pre-line;">{{ infoMessage }}</div>
<v-divider class="my-4"></v-divider>
<div v-if="lastCheckInResult" class="instruction-box pa-4 rounded-lg" :class="lastCheckInResult.success && infoAction === 'checkin' ? 'instruction-success' : 'instruction-warning'">
<div class="d-flex align-start">
<v-icon :color="lastCheckInResult.success && infoAction === 'checkin' ? 'success' : 'orange'" class="mr-3 mt-1">
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'mdi-check-circle' : 'mdi-timer-sand' }}
</v-icon>
<div>
<p class="text-body-1 font-weight-medium mb-1">
{{ lastCheckInResult.success && infoAction === 'checkin' ? 'Status Check-in:' : 'Status:' }}
</p>
<p class="text-body-2 text-grey-darken-1">
{{ lastCheckInResult.success && infoAction === 'checkin'
? 'Check-in telah berhasil dilakukan. Pasien dapat melanjutkan ke tahap selanjutnya.'
: lastCheckInResult.status === 'NOT_ALLOWED'
? 'Mohon menunggu hingga antrean Anda dipanggil oleh petugas'
: 'Proses check-in gagal. Silakan coba lagi atau hubungi petugas.' }}
</p>
</div>
</div>
</div>
<!-- Patient Info Card -->
<v-card variant="outlined" class="mt-4 patient-info-card" color="grey-lighten-4">
<v-card-text class="py-3">
<div class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon color="primary" class="mr-2 patient-info-icon">mdi-account-circle</v-icon>
<div>
<p class="text-caption patient-info-label mb-0">ID Pasien</p>
<p class="text-body-1 patient-info-value mb-0">{{ scannedData?.split('|')[0] || 'N/A' }}</p>
</div>
</div>
<div class="text-right">
<p class="text-caption patient-info-label mb-0">Waktu Scan</p>
<p class="text-body-2 patient-info-value mb-0">{{ getCurrentTime() }}</p>
</div>
</div>
</v-card-text>
</v-card>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-row dense>
<v-col v-if="infoAction === 'kembali'" cols="12">
<v-btn
color="grey-darken-1"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleClose"
elevation="0"
prepend-icon="mdi-arrow-left"
>
Kembali
</v-btn>
</v-col>
<template v-else>
<v-col cols="12">
<v-btn
:color="lastCheckInResult?.success && infoAction === 'checkin' ? 'success' : 'primary'"
class="text-white font-weight-bold text-none dialog-button"
size="x-large"
block
variant="flat"
@click="handleClose"
elevation="4"
:prepend-icon="lastCheckInResult?.success && infoAction === 'checkin' ? 'mdi-check' : 'mdi-close'"
>
{{ lastCheckInResult?.success && infoAction === 'checkin' ? 'Tutup' : 'Tutup' }}
</v-btn>
</v-col>
</template>
</v-row>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { CheckInResult, InfoAction } from '~/types/checkin'
interface Props {
modelValue: boolean
lastCheckInResult: CheckInResult | null
infoMessage: string
infoAction: InfoAction
scannedData: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'close': []
}>()
const handleClose = () => {
emit('close')
emit('update:modelValue', false)
}
const getCurrentTime = () => {
// Format jam dengan titik dua (HH:MM:SS)
const timeString = new Date().toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
return timeString.replace(/\./g, ':')
}
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/dialogs';
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="header-modern">
<div class="header-content">
<div class="icon-circle">
<v-icon size="36" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="title-modern">Check-in Pasien</h1>
<p class="subtitle-modern">Sistem Antrean Rumah Sakit</p>
</div>
</div>
<!-- Tabs Minimalis -->
<v-tabs
:model-value="modelValue"
@update:model-value="updateTab"
align-tabs="center"
class="tabs-modern mt-3"
bg-color="transparent"
slider-color="#FB8C00"
height="40"
>
<v-tab value="scan" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode-scan</v-icon>
<span>Scan QR</span>
</v-tab>
<v-tab value="manual" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-keyboard</v-icon>
<span>Manual</span>
</v-tab>
<v-tab value="generate" class="tab-modern">
<v-icon size="20" class="mr-2">mdi-qrcode</v-icon>
<span>Generate QR</span>
</v-tab>
</v-tabs>
</div>
</template>
<script setup lang="ts">
import type { TabValue } from '~/types/checkin'
interface Props {
modelValue: TabValue
}
interface Emits {
(e: 'update:modelValue', value: TabValue): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const updateTab = (value: TabValue) => {
emit('update:modelValue', value)
}
</script>
<style scoped>
/* Header Modern Minimalis */
.header-modern {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);
padding: 20px 24px 16px;
position: relative;
overflow: hidden;
}
.header-modern::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
pointer-events: none;
}
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 4px;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.header-text {
text-align: center;
}
.title-modern {
font-size: 24px;
font-weight: 600;
color: white;
margin: 0;
line-height: 1.2;
}
.subtitle-modern {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
margin: 2px 0 0;
font-weight: 400;
}
.tabs-modern {
position: relative;
z-index: 1;
}
.tabs-modern :deep(.v-tab) {
color: rgba(255, 255, 255, 0.8) !important;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
min-width: 120px;
transition: all 0.3s ease;
}
.tabs-modern :deep(.v-tab:hover) {
color: rgba(255, 255, 255, 1) !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.tabs-modern :deep(.v-tab--selected) {
color: white !important;
font-weight: 600;
}
.tabs-modern :deep(.v-slider) {
background-color: #FB8C00 !important;
height: 3px;
border-radius: 2px;
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script setup lang="ts">
import QRCode from 'qrcode';
import { onMounted } from 'vue';
const props = defineProps<{
patientId: string;
status: string;
generatedQRData: string | null;
statusOptions: Array<{ title: string; value: string }>;
primaryColor: string;
generateRandomPatientId: () => string;
}>();
const emit = defineEmits<{
'update:patientId': [value: string];
'update:status': [value: string];
'generate': [];
'quick-generate': [patientId: string, status: string];
'download': [];
'copy': [];
'share': [];
'generate-random-id': [];
}>();
const localPatientId = computed({
get: () => props.patientId,
set: (value) => emit('update:patientId', value),
});
const localStatus = computed({
get: () => props.status,
set: (value) => emit('update:status', value),
});
const handleGenerate = () => {
emit('generate');
};
const handleQuickGenerate = (status: string) => {
const randomId = props.generateRandomPatientId();
emit('quick-generate', randomId, status);
};
const handleDownload = () => {
emit('download');
};
const handleCopy = () => {
emit('copy');
};
const handleShare = () => {
emit('share');
};
const handleGenerateRandomId = () => {
emit('generate-random-id');
};
const qrContainerRef = ref<HTMLDivElement | null>(null);
// Function to render QR code
const renderQRCode = async (data: string) => {
if (!data) {
return;
}
// Wait for DOM to be ready
await nextTick();
// Wait a bit more to ensure ref is mounted (especially when switching tabs)
await new Promise(resolve => setTimeout(resolve, 150));
if (!qrContainerRef.value) {
console.warn('QR container ref not available yet, retrying...');
// Retry after a short delay
setTimeout(() => {
if (qrContainerRef.value && data) {
renderQRCode(data);
}
}, 300);
return;
}
try {
// Clear previous content
qrContainerRef.value.innerHTML = '';
// Generate QR code
const qrDataUrl = await QRCode.toDataURL(data, {
errorCorrectionLevel: 'M',
type: 'image/png',
quality: 0.92,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF',
},
width: 300,
});
// Create and append image
const img = document.createElement('img');
img.src = qrDataUrl;
img.alt = 'QR Code';
img.style.width = '100%';
img.style.maxWidth = '300px';
img.style.height = 'auto';
img.style.display = 'block';
img.style.margin = '0 auto';
qrContainerRef.value.appendChild(img);
} catch (error) {
console.error('Error generating QR code:', error);
}
};
// Watch for generatedQRData changes to render QR code
watch(() => props.generatedQRData, async (newData) => {
if (newData) {
// Use setTimeout to ensure DOM is ready, especially when switching tabs
setTimeout(() => {
renderQRCode(newData);
}, 200);
} else if (qrContainerRef.value) {
// Clear container if data is cleared
qrContainerRef.value.innerHTML = '';
}
}, { immediate: true });
// Also watch for when component is mounted and ref is ready
onMounted(() => {
if (props.generatedQRData) {
// Delay to ensure ref is mounted
setTimeout(() => {
renderQRCode(props.generatedQRData!);
}, 300);
}
});
</script>
<template>
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="24">mdi-qrcode</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Generate QR Code untuk Testing</h3>
<p class="status-subtitle">Buat QR code yang bisa Anda scan di tab "Scan QR"</p>
</div>
</div>
<!-- Quick Preset Buttons -->
<div class="mb-3">
<p class="text-caption text-grey text-center mb-2">Quick Test QR Codes:</p>
<v-row dense>
<v-col cols="6">
<v-btn
variant="outlined"
color="success"
size="small"
@click="handleQuickGenerate('ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-check-circle</v-icon>
Test ALLOWED
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
variant="outlined"
color="warning"
size="small"
@click="handleQuickGenerate('NOT_ALLOWED')"
class="text-none"
block
>
<v-icon start size="16">mdi-clock-alert</v-icon>
Test NOT_ALLOWED
</v-btn>
</v-col>
</v-row>
</div>
<!-- Form Generate -->
<v-form @submit.prevent="handleGenerate">
<v-text-field
v-model="localPatientId"
label="ID Pasien"
placeholder="Contoh: P-123456"
prepend-inner-icon="mdi-identifier"
variant="outlined"
:color="primaryColor"
class="input-modern mb-3"
density="comfortable"
clearable
hide-details="auto"
>
<template #append-inner>
<v-btn
icon
size="small"
variant="text"
@click="handleGenerateRandomId"
title="Generate Random ID"
>
<v-icon size="20">mdi-refresh</v-icon>
</v-btn>
</template>
</v-text-field>
<v-select
v-model="localStatus"
label="Status Check-in"
:items="statusOptions"
prepend-inner-icon="mdi-shield-check"
variant="outlined"
:color="primaryColor"
class="input-modern mb-4"
density="comfortable"
hide-details="auto"
></v-select>
<div class="d-flex justify-center">
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
:disabled="!localPatientId"
>
<v-icon start size="20">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
</div>
</v-form>
<!-- QR Code Display -->
<div v-if="props.generatedQRData" class="qr-display mt-6">
<v-card variant="outlined" class="pa-4">
<div class="text-center">
<p class="text-subtitle-2 text-grey mb-3">QR Code Anda:</p>
<div ref="qrContainerRef" class="qr-code-container mb-4"></div>
<v-chip :color="localStatus === 'ALLOWED' ? 'success' : 'warning'" class="mb-3">
<v-icon start>{{ localStatus === 'ALLOWED' ? 'mdi-check' : 'mdi-clock-alert' }}</v-icon>
{{ localStatus === 'ALLOWED' ? 'Diizinkan Check-in' : 'Belum Diizinkan' }}
</v-chip>
<p class="text-body-2 text-grey mb-4">
Data: {{ props.generatedQRData }}
</p>
<!-- Action Buttons -->
<v-row dense>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="handleDownload"
class="text-none"
>
<v-icon start size="18">mdi-download</v-icon>
Download
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="handleCopy"
class="text-none"
>
<v-icon start size="18">mdi-content-copy</v-icon>
Copy
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
@click="handleShare"
class="text-none"
>
<v-icon start size="18">mdi-share-variant</v-icon>
Share
</v-btn>
</v-col>
</v-row>
</div>
</v-card>
<!-- Instructions -->
<v-alert
type="success"
variant="tonal"
class="mt-4 text-body-2"
>
<template #prepend>
<v-icon>mdi-information</v-icon>
</template>
<strong>Cara menggunakan untuk Testing:</strong>
<ol class="ml-4 mt-2">
<li>Gunakan tombol <strong>"Test ALLOWED"</strong> atau <strong>"Test NOT_ALLOWED"</strong> untuk generate QR cepat, atau isi form manual</li>
<li>Klik <strong>"Download"</strong> untuk menyimpan QR code ke komputer</li>
<li>Buka file QR code yang didownload (bisa di HP atau layar lain)</li>
<li>Pindah ke tab <strong>"Scan QR"</strong> dan scan QR code tersebut</li>
<li>Atau gunakan <strong>"Copy"</strong> untuk menyalin QR ke clipboard dan paste di aplikasi lain</li>
</ol>
</v-alert>
</div>
</div>
</template>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="800"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-history</v-icon>
Riwayat Check-in
</h2>
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="4">
<v-text-field
:model-value="search"
@update:model-value="$emit('update:search', $event)"
label="Cari ID Pasien atau Nomor Antrean"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-text-field
:model-value="dateFilter"
@update:model-value="$emit('update:dateFilter', $event || '')"
type="date"
label="Filter Tanggal"
variant="outlined"
density="comfortable"
clearable
hide-details
class="date-picker-field"
></v-text-field>
</v-col>
<v-col cols="12" md="2">
<v-select
:model-value="statusFilter"
@update:model-value="$emit('update:statusFilter', $event)"
label="Filter Status"
:items="statusOptions"
variant="outlined"
density="comfortable"
hide-details
clearable
></v-select>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="$emit('clear')"
:disabled="history.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- History List -->
<div v-if="filteredHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="mr-2"
>
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
</v-chip>
<v-chip
color="grey-lighten-1"
size="x-small"
variant="text"
>
{{ item.method }}
</v-chip>
</div>
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
ID Pasien: {{ item.patientId }}
</p>
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
Nomor Antrean: {{ item.queueNumber }}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-clock-outline</v-icon>
{{ formatDateTime(item.checkInTime) }}
</v-chip>
<v-chip
v-if="item.checkInDate"
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ formatDate(item.checkInDate) }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="error"
@click="$emit('delete-item', index)"
>
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-bold text-none"
size="large"
variant="flat"
@click="$emit('update:modelValue', false)"
prepend-icon="mdi-close"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { CheckInHistoryItem, HistoryStatus } from '~/types/checkin'
interface Props {
modelValue: boolean
search: string
dateFilter: string
statusFilter: HistoryStatus | ''
filteredHistory: CheckInHistoryItem[]
history: CheckInHistoryItem[] | ReadonlyArray<CheckInHistoryItem>
statusOptions: Array<{ title: string; value: string }>
primaryColor: string
getStatusColor: (status: string) => string
getStatusIcon: (status: string) => string
getStatusText: (status: string) => string
getStatusClass: (status: string) => string
formatDateTime: (dateString: string) => string
formatDate: (dateString: string) => string
}
defineProps<Props>()
defineEmits<{
'update:modelValue': [value: boolean]
'update:search': [value: string]
'update:dateFilter': [value: string]
'update:statusFilter': [value: HistoryStatus | '']
'delete-item': [index: number]
'clear': []
}>()
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
primaryColor: string;
secondaryColor: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
'submit': [];
'open-history': [];
}>();
const manualForm = ref<{ validate: () => boolean; resetValidation: () => void; reset: () => void } | null>(null);
const localValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const handleSubmit = () => {
emit('submit');
};
const handleOpenHistory = () => {
emit('open-history');
};
// Expose form ref for parent component
defineExpose({
form: manualForm,
});
</script>
<template>
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="secondaryColor" size="24">mdi-keyboard</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">Input Manual</h3>
<p class="status-subtitle">Masukkan nomor antrean atau ID pasien</p>
</div>
</div>
<v-form @submit.prevent="handleSubmit" ref="manualForm">
<v-text-field
v-model="localValue"
label="Nomor Antrean / ID Pasien"
placeholder="Contoh: P12345"
prepend-inner-icon="mdi-account-card-details"
variant="outlined"
:color="primaryColor"
class="input-modern mb-4"
required
:rules="[v => !!v || 'Field ini wajib diisi']"
density="comfortable"
hide-details="auto"
>
<template v-slot:append-inner>
<v-icon :color="localValue ? 'success' : 'grey'" size="20">
{{ localValue ? 'mdi-check-circle' : 'mdi-circle-outline' }}
</v-icon>
</template>
</v-text-field>
<v-btn
class="btn-primary-modern btn-centered"
size="large"
type="submit"
elevation="0"
>
<v-icon start size="20">mdi-login</v-icon>
Check-in Sekarang
</v-btn>
</v-form>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-4">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="handleOpenHistory"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat
</v-btn>
</v-col>
</v-row>
</div>
</div>
</template>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="800"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #FB8C00 0%, #F57C00 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-qrcode-scan</v-icon>
Riwayat QR Scan
</h2>
<p class="text-body-2 text-white opacity-90">Daftar QR code yang telah di-scan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="5">
<v-text-field
:model-value="search"
@update:model-value="$emit('update:search', $event)"
label="Cari Data QR Code"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
:model-value="dateFilter"
@update:model-value="$emit('update:dateFilter', $event || '')"
type="date"
label="Filter Tanggal"
variant="outlined"
density="comfortable"
clearable
hide-details
class="date-picker-field"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="$emit('clear')"
:disabled="history.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- QR History List -->
<div v-if="filteredHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-qrcode</v-icon>
Data QR: {{ item.data }}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-clock-outline</v-icon>
{{ item.time }}
</v-chip>
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ item.date }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="primary"
@click="$emit('use-data', item.data)"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
color="error"
@click="$emit('delete-item', index)"
>
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-qrcode-scan</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat QR scan</p>
<p class="text-body-2 text-grey">Riwayat QR scan akan muncul di sini setelah Anda melakukan scan</p>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-bold text-none"
size="large"
variant="flat"
@click="$emit('update:modelValue', false)"
prepend-icon="mdi-close"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { ScannedQRHistoryItem } from '~/types/checkin'
interface Props {
modelValue: boolean
search: string
dateFilter: string
filteredHistory: ScannedQRHistoryItem[]
history: ScannedQRHistoryItem[] | ReadonlyArray<ScannedQRHistoryItem>
primaryColor: string
}
defineProps<Props>()
defineEmits<{
'update:modelValue': [value: boolean]
'update:search': [value: string]
'update:dateFilter': [value: string]
'use-data': [data: string]
'delete-item': [index: number]
'clear': []
}>()
</script>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
@import '~/assets/scss/checkin/dialogs';
</style>
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
const props = defineProps<{
isScanning: boolean;
hasCamera: boolean;
cameraChecking: boolean;
cameraReady: boolean;
primaryColor: string;
}>();
const emit = defineEmits<{
'start-scanning': [];
'stop-scanning': [];
'test-camera': [];
'open-history': [];
'open-qr-history': [];
}>();
const handleStartScanning = () => {
emit('start-scanning');
};
const handleStopScanning = () => {
emit('stop-scanning');
};
const handleTestCamera = () => {
emit('test-camera');
};
const handleOpenHistory = () => {
emit('open-history');
};
const handleOpenQRHistory = () => {
emit('open-qr-history');
};
</script>
<template>
<div class="tab-content">
<!-- Status Header -->
<div class="status-header mb-3">
<div class="status-icon-wrapper">
<v-icon :color="primaryColor" size="24">mdi-qrcode-scan</v-icon>
</div>
<div class="status-text">
<h3 class="status-title">
{{ isScanning ? 'Arahkan Kamera ke QR Code' : 'Siap untuk Scan QR Code' }}
</h3>
<p class="status-subtitle">
{{ isScanning ? 'Pastikan QR code terlihat jelas dan tidak terpotong' : 'Klik tombol di bawah untuk memulai scan' }}
</p>
</div>
</div>
<!-- Camera Status Check -->
<div v-if="cameraChecking" class="text-center mb-4">
<v-progress-circular
indeterminate
:color="primaryColor"
size="24"
class="mr-2"
></v-progress-circular>
<span class="text-body-2 text-grey">Memeriksa ketersediaan kamera...</span>
</div>
<!-- Camera Not Available Warning -->
<v-alert
v-else-if="!hasCamera && !isScanning"
type="warning"
variant="tonal"
class="mb-4"
>
<template #prepend>
<v-icon>mdi-camera-off</v-icon>
</template>
<div>
<p class="font-weight-bold mb-1">Kamera tidak terdeteksi</p>
<p class="text-body-2 mb-0">
Perangkat Anda tidak memiliki kamera atau kamera tidak dapat diakses.
Silakan gunakan tab <strong>Manual</strong> untuk input data secara manual.
</p>
</div>
</v-alert>
<!-- QR Scanner Area dengan webcam -->
<div class="qr-scanner-container mb-4">
<div v-if="!isScanning" class="qr-placeholder">
<div class="scanner-overlay">
<div class="corner corner-tl"></div>
<div class="corner corner-tr"></div>
<div class="corner corner-bl"></div>
<div class="corner corner-br"></div>
<div class="scan-line"></div>
</div>
<v-icon size="64" :color="primaryColor" class="qr-icon">mdi-qrcode-scan</v-icon>
</div>
<div v-else class="qr-reader-container">
<div class="scanner-status mb-2">
<v-chip color="success" size="small" class="mr-2">
<v-icon start size="16">mdi-camera</v-icon>
Kamera Aktif
</v-chip>
<span class="text-caption text-grey">Preview kamera sedang berjalan</span>
</div>
<div id="qr-reader" class="qr-reader-wrapper">
<div v-if="!cameraReady" class="scanner-loading-overlay">
<v-progress-circular
indeterminate
color="white"
size="48"
></v-progress-circular>
<p class="text-white mt-4">Memuat kamera...</p>
</div>
</div>
<div class="scanner-instruction">
<v-icon :color="primaryColor" size="20" class="mr-2">mdi-information</v-icon>
<span class="text-body-2">Arahkan kamera ke QR code. Pastikan QR code berada dalam kotak pemindaian.</span>
</div>
</div>
</div>
<!-- Action Button Minimalis -->
<div class="action-buttons">
<v-btn
v-if="!isScanning"
class="btn-primary-modern btn-centered"
size="large"
elevation="0"
@click="handleStartScanning"
:disabled="!hasCamera && !cameraChecking"
>
<v-icon start size="20">mdi-camera</v-icon>
{{ hasCamera ? 'Mulai Scan QR' : 'Kamera Tidak Tersedia' }}
</v-btn>
<v-btn
v-else
class="btn-stop-modern btn-centered"
size="large"
elevation="0"
@click="handleStopScanning"
>
<v-icon start size="20">mdi-camera-off</v-icon>
Stop Scan
</v-btn>
</div>
<!-- Info tambahan -->
<div class="info-card mt-3">
<v-alert
type="info"
variant="tonal"
:color="primaryColor"
class="text-body-2 info-alert-centered"
density="compact"
>
<div class="d-flex align-center justify-center">
<v-icon size="16" class="mr-2">mdi-lightbulb-outline</v-icon>
<span style="font-size: 12px;">Tips: Pastikan pencahayaan cukup untuk hasil scan optimal</span>
</div>
</v-alert>
</div>
<!-- Test Camera Button (Debug) -->
<div class="test-camera-section mt-2 mb-2">
<v-btn
variant="outlined"
color="primary"
size="small"
class="text-none btn-centered-small btn-test-camera"
@click="handleTestCamera"
>
<v-icon start size="18" color="#1565C0">mdi-camera</v-icon>
Test Kamera
</v-btn>
<p class="text-caption text-grey text-center mt-1" style="font-size: 11px;">
Klik untuk menguji apakah browser dapat mengakses kamera
</p>
</div>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-4">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="handleOpenHistory"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat Check-in
</v-btn>
</v-col>
<v-col cols="12">
<v-btn
variant="outlined"
color="primary"
block
size="small"
class="text-none"
@click="handleOpenQRHistory"
>
<v-icon start size="18" color="#1565C0">mdi-qrcode-scan</v-icon>
Riwayat QR Scan
</v-btn>
</v-col>
</v-row>
</div>
</div>
</template>
<style scoped lang="scss">
@import '~/assets/scss/checkin/components';
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="stats-footer-modern mt-3">
<v-row dense>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="primaryColor" size="20">mdi-clock-outline</v-icon>
</div>
<div class="stat-value">{{ todayCount }}</div>
<div class="stat-label">Hari Ini</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon color="success" size="20">mdi-check-circle</v-icon>
</div>
<div class="stat-value">{{ completedCount }}</div>
<div class="stat-label">Selesai</div>
</div>
</v-col>
<v-col cols="4">
<div class="stat-card-modern">
<div class="stat-icon-modern">
<v-icon :color="secondaryColor" size="20">mdi-account-group</v-icon>
</div>
<div class="stat-value">{{ pendingCount }}</div>
<div class="stat-label">Menunggu</div>
</div>
</v-col>
</v-row>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CheckInHistoryItem } from '~/types/checkin'
import { PRIMARY_COLOR, SECONDARY_COLOR } from '~/constants/checkin'
interface Props {
history: CheckInHistoryItem[]
primaryColor?: string
secondaryColor?: string
}
const props = withDefaults(defineProps<Props>(), {
primaryColor: PRIMARY_COLOR,
secondaryColor: SECONDARY_COLOR,
})
// Computed untuk menghitung stats
const todayCount = computed(() => {
const today = new Date().toDateString()
return props.history.filter(item => {
const itemDate = new Date(item.checkInDate).toDateString()
return itemDate === today
}).length
})
const completedCount = computed(() => {
return props.history.filter(item =>
item.status === 'success' || item.status === 'ALLOWED'
).length
})
const pendingCount = computed(() => {
return props.history.filter(item =>
item.status === 'pending' || item.status === 'NOT_ALLOWED'
).length
})
</script>
<style scoped>
/* Stats Footer Modern */
.stats-footer-modern {
animation: slideUp 0.5s ease-out 0.3s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card-modern {
background: white;
border-radius: 12px;
padding: 16px 12px;
text-align: center;
border: 1px solid #e5e7eb;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.stat-card-modern::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #1565C0 0%, #0D47A1 100%);
transform: scaleX(0);
transition: transform 0.3s ease;
}
.stat-card-modern:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #1565C0;
}
.stat-card-modern:hover::before {
transform: scaleX(1);
}
.stat-icon-modern {
margin-bottom: 12px;
display: inline-flex;
padding: 8px;
background: #e3f2fd;
border-radius: 10px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 4px;
letter-spacing: -0.5px;
}
.stat-label {
font-size: 11px;
color: #6b7280;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<template>
<table class="ticket-table" cellpadding="0" cellspacing="0">
<tr>
<td class="ticket-content">
<div class="header">
<div class="hospital-name">RSUD DR. SAIFUL ANWAR</div>
<div class="hospital-address">Jl. Jaksa Agung Suprapto No.2, Malang</div>
<div class="ticket-title">Tiket Antrean</div>
</div>
<div class="no-antrian">{{ noAntrianDisplay }}</div>
<div class="klinik-name">{{ ruangInfo || klinik }}</div>
<div class="qr-code-section">
<div class="qr-code">
<img :src="qrCodeImage" alt="QR Code" />
</div>
<div class="barcode-number">{{ barcode }}</div>
</div>
<div class="info-section">
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Tanggal</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ tanggal }}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Waktu</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ waktu }}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Shift</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ shift }}</span>
</div>
<div class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Pembayaran</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ pembayaran }}</span>
</div>
<div v-if="namaDokter" class="info-row">
<div class="info-label-wrapper">
<span class="info-label-text">Dokter</span><span class="info-colon">:</span>
</div>
<span class="info-value">{{ namaDokter }}</span>
</div>
</div>
<div class="footer">
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
</div>
</td>
</tr>
</table>
</template>
<script setup lang="ts">
import type { ThermalPrintData } from '@/composables/useThermalPrint';
interface Props {
data: ThermalPrintData;
qrCodeImage: string;
noAntrianDisplay: string;
ruangInfo: string;
}
const props = defineProps<Props>();
const {
barcode,
klinik,
shift,
pembayaran,
tanggal,
waktu,
namaDokter
} = props.data;
</script>
<style scoped>
.ticket-table {
width: 100%;
border-collapse: collapse;
border: 2px dashed #000;
margin: 0;
background: white;
}
.ticket-table td {
padding: 0;
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
text-align: center;
}
.header {
margin-bottom: 2mm;
border-bottom: 2px solid #000;
padding-bottom: 1mm;
margin-top: 0;
}
.hospital-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 0.5mm;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1.0;
}
.hospital-address {
font-size: 10px;
font-weight: bold;
margin-bottom: 0.5mm;
line-height: 1.0;
}
.ticket-title {
font-size: 12px;
font-weight: bold;
margin-top: 1mm;
text-transform: uppercase;
line-height: 1.0;
}
.qr-code-section {
margin-top: 0;
margin-bottom: 0.5mm;
text-align: center;
}
.qr-code {
width: 45mm;
height: 45mm;
margin: 0 auto;
border: none;
padding: 0.5mm;
background: white;
}
.qr-code img {
width: 100%;
height: 100%;
display: block;
}
.barcode-number {
font-size: 10px;
font-weight: bold;
margin-top: -0.5mm;
word-break: break-all;
line-height: 1.0;
}
.info-section {
margin-top: 2mm;
text-align: left;
border-top: 1px dashed #000;
padding-top: 1mm;
padding-left: 3mm;
padding-right: 3mm;
}
.info-row {
margin-bottom: 0.8mm;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0;
line-height: 1.0;
}
.info-label-text {
font-weight: bold;
font-size: 10px;
text-align: left;
text-transform: uppercase;
word-break: keep-all;
line-height: 1.0;
}
.info-colon {
font-weight: bold;
font-size: 10px;
margin-left: auto;
padding-right: 2mm;
line-height: 1.0;
}
.info-label-wrapper {
display: flex;
width: 22mm;
flex-shrink: 0;
align-items: flex-start;
gap: 1mm;
}
.info-value {
font-size: 10px;
font-weight: bold;
text-align: right;
word-break: break-word;
line-height: 1.0;
}
.no-antrian {
font-size: 28px;
font-weight: bold;
margin: 0.3mm 0;
letter-spacing: 2px;
text-transform: uppercase;
line-height: 1.0;
}
.klinik-name {
font-size: 12px;
font-weight: bold;
margin-top: 0.3mm;
margin-bottom: 2mm;
padding-bottom: 0;
text-transform: uppercase;
overflow: visible;
line-height: 1.0;
}
.footer {
margin-top: 2mm;
padding-top: 1mm;
padding-bottom: 0;
margin-bottom: 6mm;
border-top: 2px solid #000;
font-size: 10px;
font-weight: bold;
text-align: center;
line-height: 1.0;
}
.footer-text {
margin-bottom: 0.5mm;
font-weight: bold;
line-height: 1.0;
}
.footer-text:last-child {
margin-bottom: 0;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<v-snackbar v-model="showModel" :color="color" :timeout="timeout">
<span class="body-3">{{ message }}</span>
<template #actions>
<v-btn variant="text" size="small" @click="showModel = false">
{{ closeText }}
</v-btn>
</template>
</v-snackbar>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
message: {
type: String,
default: ''
},
color: {
type: String,
default: 'success'
},
timeout: {
type: Number,
default: 3000
},
closeText: {
type: String,
default: 'Tutup'
}
});
const emit = defineEmits(['update:modelValue']);
const showModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
</script>
<style scoped lang="scss">
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
.body-3 {
font-size: 14px;
line-height: 20px;
font-family: $font-family-base;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<template>
<v-avatar :size="size" :class="avatarClass">
<v-img
v-if="src"
:src="src"
:alt="alt"
:lazy-src="defaultAvatar"
></v-img>
<v-img
v-else
:src="defaultAvatar"
:alt="alt"
></v-img>
</v-avatar>
</template>
<script setup>
import { DEFAULT_AVATAR } from '@/constants/avatar'
const props = defineProps({
src: {
type: String,
default: null
},
alt: {
type: String,
default: 'User Avatar'
},
size: {
type: [Number, String],
default: 44
},
avatarClass: {
type: String,
default: ''
}
})
const defaultAvatar = DEFAULT_AVATAR
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="field-group">
<div class="group-label">
<v-icon v-if="icon" size="18" class="icon-label">{{ icon }}</v-icon>
<span>{{ title }}</span>
</div>
<slot></slot>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
icon: {
type: String,
default: ''
}
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.field-group {
background: $neutral-100;
padding: 20px;
border-radius: 12px;
margin-bottom: 0;
border: 1px solid $neutral-400;
}
.group-label {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $primary-600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.icon-label {
color: $primary-600 !important;
}
</style>
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="page-header" :class="`page-header-${theme}`">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<NuxtImg
v-if="logo"
:src="logo"
:alt="title"
width="48"
height="48"
class="header-logo"
loading="eager"
/>
<v-icon v-else size="28" color="white">{{ icon }}</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">{{ title }}</h2>
<p class="page-subtitle">{{ subtitle }}</p>
</div>
</div>
<slot name="actions">
<v-btn
v-if="showAddButton"
color="white"
elevation="0"
class="add-btn"
:class="`add-btn-${theme}`"
@click="$emit('add-click')"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
{{ addButtonText }}
</v-btn>
</slot>
</div>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
default: 'mdi-view-dashboard'
},
logo: {
type: String,
default: ''
},
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
showAddButton: {
type: Boolean,
default: true
},
addButtonText: {
type: String,
default: 'Tambah'
},
theme: {
type: String,
default: 'primary', // 'primary', 'secondary', 'success', 'accent', or 'warning'
validator: (value) => ['primary', 'secondary', 'success', 'accent', 'warning'].includes(value)
}
});
defineEmits(['add-click']);
</script>
<style scoped lang="scss">
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.page-header {
border-radius: 0;
}
.page-header-primary {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
}
.page-header-secondary {
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
box-shadow: 0 4px 16px rgba(241, 111, 41, 0.2);
}
.page-header-success {
background: linear-gradient(135deg, var(--color-success-600) 0%, var(--color-success-700) 100%);
box-shadow: 0 4px 16px rgba(51, 164, 132, 0.2);
}
.page-header-accent {
// Accent tidak ada di design system, menggunakan secondary sebagai fallback
background: linear-gradient(135deg, var(--color-secondary-500) 0%, var(--color-secondary-600) 100%);
box-shadow: 0 4px 16px rgba(255, 132, 65, 0.2);
}
.page-header-warning {
// Warning tidak ada di design system, menggunakan secondary (orange) sebagai fallback
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
box-shadow: 0 4px 16px rgba(241, 111, 41, 0.2);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
height: 80px;
color: var(--color-neutral-100);
}
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 12px;
margin-right: 16px;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.header-logo {
width: 52px;
height: 52px;
object-fit: contain;
border-radius: 50%;
max-width: 100%;
max-height: 100%;
}
.page-title {
font-size: 32px;
line-height: 40px;
font-weight: $font-weight-semibold;
margin: 0;
color: var(--color-neutral-100);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 2px 0 0 0;
opacity: 0.9;
font-size: 15px;
line-height: 22px;
font-weight: $font-weight-regular;
color: var(--color-neutral-100);
}
.add-btn {
font-weight: $font-weight-semibold;
text-transform: none;
letter-spacing: 0.5px;
font-size: 16px;
line-height: 24px;
}
.add-btn-primary {
color: var(--color-primary-600) !important;
}
.add-btn-secondary {
color: var(--color-secondary-600) !important;
}
.add-btn-success {
color: var(--color-success-600) !important;
}
.add-btn-accent {
// Accent tidak ada di design system, menggunakan secondary sebagai fallback
color: var(--color-secondary-500) !important;
}
.add-btn-warning {
// Warning tidak ada di design system, menggunakan secondary (orange) sebagai fallback
color: var(--color-secondary-600) !important;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px" persistent>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ title }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="dialogModel = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider />
<v-card-text class="dialog-content">
<v-text-field
v-model="searchModel"
:placeholder="searchPlaceholder"
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
/>
<v-row dense>
<v-col
v-for="item in filteredItems"
:key="item.id"
cols="6"
sm="4"
>
<v-card
class="selection-card"
@click="handleSelect(item)"
elevation="0"
>
<v-card-text class="text-center pa-3">
<div class="selection-name">{{ item.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
title: {
type: String,
default: 'Pilih Item'
},
items: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
},
searchPlaceholder: {
type: String,
default: 'Cari...'
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredItems = computed(() => {
if (!searchModel.value) return props.items;
const search = searchModel.value.toLowerCase();
return props.items.filter(item =>
item.name.toLowerCase().includes(search)
);
});
const handleSelect = (item) => {
emit('select', item);
dialogModel.value = false;
};
</script>
<style scoped lang="scss">
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header {
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
color: var(--color-neutral-100);
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.btn-close {
color: var(--color-neutral-100) !important;
transition: all 0.2s ease;
}
.btn-close:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: rotate(90deg);
}
.dialog-content {
padding: 24px !important;
background: var(--color-neutral-300);
}
.selection-card {
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--color-neutral-400);
background: var(--color-neutral-100);
border-radius: 8px;
}
.selection-card:hover {
border-color: var(--color-secondary-600);
background: var(--color-secondary-300);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(6, 113, 224, 0.15);
}
.selection-name {
font-size: 14px;
line-height: 20px;
font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--color-neutral-900);
}
</style>
@@ -0,0 +1,102 @@
<template>
<v-card class="filter-card" elevation="0">
<v-card-text>
<v-row align="center">
<v-col cols="12" md="6">
<v-text-field
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
prepend-inner-icon="mdi-magnify"
label="Pencarian"
:placeholder="searchPlaceholder"
variant="outlined"
density="comfortable"
hide-details
clearable
class="search-field"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
:model-value="statusFilter"
@update:model-value="$emit('update:statusFilter', $event)"
:items="statusOptions"
label="Status"
variant="outlined"
density="comfortable"
hide-details
clearable
class="filter-select"
/>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="primary-600"
variant="outlined"
block
size="large"
@click="$emit('refresh')"
:loading="loading"
class="refresh-btn"
>
<v-icon start>mdi-refresh</v-icon>
Refresh Data
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
searchQuery: {
type: String,
default: ''
},
statusFilter: {
type: String,
default: null
},
statusOptions: {
type: Array,
default: () => ['Terdaftar', 'Belum Terdaftar', 'Proses']
},
searchPlaceholder: {
type: String,
default: 'Cari nama pasien, No. RM, atau alamat...'
},
loading: {
type: Boolean,
default: false
}
});
defineEmits(['update:searchQuery', 'update:statusFilter', 'refresh']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.filter-card {
border-radius: 12px;
border: 1px solid $neutral-500;
background: $neutral-100;
margin-bottom: 16px;
}
.search-field,
.filter-select {
font-family: $font-family-base;
}
.refresh-btn {
text-transform: none;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,231 @@
<template>
<v-dialog v-model="dialogModel" max-width="600px">
<v-card class="dialog-card">
<v-card-title class="dialog-header" :class="`dialog-header-${type}`">
<v-icon class="mr-2">{{ icon }}</v-icon>
<span>{{ title }}</span>
<v-spacer />
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<!-- patient info card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ patient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ patient.noRM }}</div>
</v-col>
</v-row>
</div>
<!-- search field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
:label="searchLabel"
variant="outlined"
density="comfortable"
hide-details
class="mb-4 search-field"
/>
<!-- options grid -->
<v-row>
<v-col
v-for="option in filteredOptions"
:key="option.id"
cols="6"
>
<v-card
class="option-card"
:class="`option-card-${type}`"
@click="$emit('select', option)"
elevation="0"
>
<v-card-text class="text-center pa-4">
<v-icon :size="32" :color="iconColor" class="mb-2">
{{ icon }}
</v-icon>
<div class="option-title">{{ option.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
type: {
type: String,
default: 'klinik', // 'klinik' | 'penunjang'
validator: (value) => ['klinik', 'penunjang'].includes(value)
},
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
patient: {
type: Object,
default: null
},
options: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
},
searchLabel: {
type: String,
default: 'Cari...'
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredOptions = computed(() => {
if (!searchModel.value) return props.options;
const search = searchModel.value.toLowerCase();
return props.options.filter(opt =>
opt.name.toLowerCase().includes(search)
);
});
const iconColor = computed(() => {
return props.type === 'klinik' ? 'secondary-600' : 'accent-600';
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$neutral-900: #212121;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$accent-600: #8B5CF6;
$accent-700: #7C3AED;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
color: $neutral-100;
padding: 20px 24px;
font-size: 18px;
font-weight: $font-weight-semibold;
display: flex;
align-items: center;
}
.dialog-header-klinik {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
}
.dialog-header-penunjang {
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
}
.close-btn {
color: $neutral-100 !important;
}
.patient-card {
background: $neutral-300;
padding: 16px;
border-radius: 12px;
border: 1px solid $neutral-500;
}
.card-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
margin-bottom: 12px;
font-family: $font-family-base;
}
.info-label {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
margin-bottom: 4px;
font-family: $font-family-base;
}
.info-value {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.search-field {
font-family: $font-family-base;
}
.option-card {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid $neutral-500;
background: $neutral-100;
}
.option-card-klinik:hover {
border-color: $secondary-600;
background: var(--color-secondary-200);
transform: translateY(-2px);
}
.option-card-penunjang:hover {
border-color: $accent-600;
background: var(--color-accent-200);
transform: translateY(-2px);
}
.option-title {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,262 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px">
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<v-icon class="mr-2">mdi-door-open</v-icon>
<span>Pilih Klinik Ruang</span>
<v-spacer />
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<!-- Patient Info Card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ patient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ patient.noRM }}</div>
</v-col>
</v-row>
</div>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
label="Cari Klinik Ruang"
variant="outlined"
density="comfortable"
hide-details
class="mb-4 search-field"
/>
<!-- Ruang List with Expansion Panels -->
<v-expansion-panels class="ruang-panels">
<v-expansion-panel
v-for="klinikRuang in filteredRuang"
:key="klinikRuang.id"
class="panel-item"
>
<v-expansion-panel-title class="panel-title">
<div class="d-flex align-center w-100">
<v-chip size="small" color="primary-600" class="mr-2 kode-chip">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<strong class="klinik-name">{{ klinikRuang.namaKlinik }}</strong>
<v-spacer />
<v-chip size="small" variant="outlined" class="count-chip">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list density="compact" class="ruang-list">
<v-list-item
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
@click="$emit('select', klinikRuang, ruang)"
class="ruang-item"
>
<template #prepend>
<v-icon color="primary-600" size="20">mdi-door</v-icon>
</template>
<v-list-item-title class="ruang-title">
<strong>Ruang {{ ruang.nomorRuang }}</strong> - {{ ruang.namaRuang }}
</v-list-item-title>
<v-list-item-subtitle class="ruang-subtitle">
Screen: {{ ruang.nomorScreen }}
</v-list-item-subtitle>
<template #append>
<v-icon size="20">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
patient: {
type: Object,
default: null
},
ruangList: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredRuang = computed(() => {
if (!searchModel.value) return props.ruangList;
const search = searchModel.value.toLowerCase();
return props.ruangList.filter(k =>
k.namaKlinik.toLowerCase().includes(search) ||
k.kodeKlinik.toLowerCase().includes(search)
);
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
font-size: 18px;
font-weight: $font-weight-semibold;
display: flex;
align-items: center;
}
.close-btn {
color: $neutral-100 !important;
}
.patient-card {
background: $neutral-300;
padding: 16px;
border-radius: 12px;
border: 1px solid $neutral-500;
}
.card-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
margin-bottom: 12px;
font-family: $font-family-base;
}
.info-label {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
margin-bottom: 4px;
font-family: $font-family-base;
}
.info-value {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.search-field {
font-family: $font-family-base;
}
.ruang-panels {
font-family: $font-family-base;
}
.panel-item {
margin-bottom: 8px;
border: 1px solid $neutral-500;
border-radius: 8px;
}
.panel-title {
font-family: $font-family-base;
}
.kode-chip {
font-weight: $font-weight-semibold;
font-size: 12px;
}
.klinik-name {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.count-chip {
font-size: 12px;
font-family: $font-family-base;
}
.ruang-list {
padding: 0;
}
.ruang-item {
cursor: pointer;
border-radius: 8px;
margin-bottom: 4px;
transition: background 0.2s;
font-family: $font-family-base;
}
.ruang-item:hover {
background: $neutral-300;
}
.ruang-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.ruang-subtitle {
font-size: 12px;
color: $neutral-700;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,195 @@
<template>
<v-card elevation="0" class="patient-table-card">
<v-card-title class="table-title">
<v-icon class="mr-2" color="primary-600">mdi-account-group</v-icon>
<span>Daftar Pasien - {{ items.length }} pasien</span>
</v-card-title>
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="items"
:search="searchQuery"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
density="comfortable"
>
<template #item.namaPasien="{ item }">
<div class="patient-info">
<div class="patient-name">{{ item.namaPasien }}</div>
<div class="patient-meta">{{ item.noRM }}</div>
</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
class="status-chip"
>
{{ item.status }}
</v-chip>
</template>
<template #item.buatAntrean="{ item }">
<div class="action-buttons">
<v-btn
size="small"
color="secondary-600"
variant="outlined"
@click="$emit('create-klinik', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-hospital-building</v-icon>
Klinik
</v-btn>
<v-btn
size="small"
color="primary-600"
variant="outlined"
@click="$emit('create-ruang', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-door-open</v-icon>
Klinik Ruang
</v-btn>
<v-btn
size="small"
color="accent-600"
variant="outlined"
@click="$emit('create-penunjang', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-clipboard-pulse</v-icon>
Penunjang
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
items: {
type: Array,
required: true
},
searchQuery: {
type: String,
default: ''
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'Nama Pasien', value: 'namaPasien', sortable: true },
{ title: 'No. RM', value: 'noRM', sortable: true },
{ title: 'Alamat', value: 'alamat', sortable: true },
{ title: 'Status', value: 'status', sortable: true },
{ title: 'Buat Antrean', value: 'buatAntrean', sortable: false, width: '360px' },
];
defineEmits(['create-klinik', 'create-ruang', 'create-penunjang']);
const getStatusColor = (status) => {
const colors = {
'Terdaftar': 'var(--color-success-600)',
'Belum Terdaftar': 'var(--color-primary-600)',
'Proses': 'var(--color-secondary-600)'
};
return colors[status] || 'var(--color-neutral-600)';
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.patient-table-card {
border-radius: 12px;
border: 1px solid $neutral-500;
background: $neutral-100;
}
.table-title {
display: flex;
align-items: center;
padding: 16px 20px;
background: var(--color-neutral-300);
font-size: 16px;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
color: $neutral-900;
}
.patient-info {
padding: 4px 0;
}
.patient-name {
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
color: $neutral-900;
font-family: $font-family-base;
}
.patient-meta {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
font-family: $font-family-base;
}
.status-chip {
font-weight: $font-weight-semibold;
font-size: 11px;
font-family: $font-family-base;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.action-btn {
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 12px;
font-family: $font-family-base;
}
:deep(.data-table) {
font-family: $font-family-base;
}
:deep(.data-table th) {
font-family: $font-family-base;
font-weight: $font-weight-semibold !important;
color: $neutral-600 !important;
}
:deep(.data-table td) {
font-family: $font-family-base;
border-bottom: 1px solid $neutral-400 !important;
}
:deep(.data-table tbody tr:hover) {
background: var(--color-neutral-300) !important;
}
</style>
@@ -0,0 +1,114 @@
<script setup>
/**
* DialogDetailEvent — Dialog untuk menampilkan detail jadwal dokter
* yang diklik di kalender. Mendukung tampilan Rutin dan Adhoc.
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Data event yang sedang ditampilkan */
event: { type: Object, default: null },
});
const emit = defineEmits(['update:modelValue', 'delete']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/**
* Emit event delete ke parent untuk menghapus jadwal.
*/
const handleDelete = () => {
emit('delete', props.event?.id);
dialogOpen.value = false;
};
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="420px">
<v-card rounded="lg" class="pa-0">
<v-card-title class="pa-5" :class="event?.tipe === 'Adhoc' ? 'bg-secondary' : 'bg-primary'"
style="color:white;">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>{{ event?.tipe === 'Adhoc' ? 'mdi-calendar-clock' : 'mdi-calendar-sync'
}}</v-icon>
<span class="ml-2 text-h6 font-weight-bold">Jadwal {{ event?.tipe }}</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" style="color:white;"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6" v-if="event">
<v-list density="compact" class="pa-0">
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-hospital-building</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Poli / Klinik</div>
<div class="text-body-2 font-weight-bold">{{ event.poli }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-doctor</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Dokter</div>
<div class="text-body-2 font-weight-bold">{{ event.dokter }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Tanggal</div>
<div class="text-body-2 font-weight-bold">{{ event.tanggal }}</div>
</v-list-item>
<!-- Rutin: tampilkan hari -->
<template v-if="event.tipe === 'Rutin' && event.hari">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar-week</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Hari Praktek</div>
<div class="text-body-2 font-weight-bold">{{ event.hari }}</div>
</v-list-item>
</template>
<!-- Adhoc: tampilkan shift -->
<template v-if="event.tipe === 'Adhoc' && event.shift">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="secondary" size="small" class="mr-3">mdi-clock-outline</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Waktu</div>
<div class="text-body-2 font-weight-bold">{{ event.shift }}</div>
</v-list-item>
</template>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-space-between">
<v-btn color="error" variant="text" prepend-icon="mdi-trash-can-outline" @click="handleDelete">
Hapus
</v-btn>
<v-btn color="primary" variant="flat" @click="dialogOpen = false">Tutup</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,336 @@
<script setup>
/**
* DialogTambahJadwal — Dialog form untuk menambahkan jadwal dokter baru.
* Mendukung dua tipe jadwal: Rutin (mingguan berulang) dan Adhoc (satu kali).
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const DAYS_OF_WEEK = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Daftar klinik untuk referensi nama poli */
clinicList: { type: Array, default: () => [] },
/** ID poli yang sedang dipilih */
selectedPoli: { type: [String, Number, null], default: null },
/** Nama dokter yang sedang dipilih */
selectedDoctor: { type: [String, null], default: null },
/** Tanggal yang diklik di kalender (opsional, sebagai default adhoc) */
clickedDate: { type: [Date, null], default: null },
/** Mode tampilan saat ini ('list' atau 'calendar') */
viewMode: { type: String, default: 'list' },
/** Daftar jadwal saat ini (untuk preview kalender) */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue', 'save']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/** State form tambah jadwal */
const form = reactive({
poliId: null,
dokter: null,
tipe: 'Rutin',
hari: '',
shifts: [{ jamMulai: '', jamSelesai: '' }],
adhocTanggal: '',
adhocShifts: [{ jamMulai: '', jamSelesai: '' }],
});
/** Computed daftar dokter berdasarkan poli yang dipilih */
const availableDoctors = computed(() => {
if (!form.poliId) return [];
const clinic = props.clinicList.find(c => c.id === form.poliId);
return clinic?.doctors || [];
});
/** Error validasi form */
const formErrors = ref({});
/**
* Format tanggal ke string YYYY-MM-DD.
* @param {Date} date
* @returns {string}
*/
const toDateStr = (date) => {
const d = new Date(date);
const tzOffset = d.getTimezoneOffset() * 60000;
return new Date(d.getTime() - tzOffset).toISOString().slice(0, 10);
};
/** Reset form saat dialog dibuka */
watch(() => props.modelValue, (open) => {
if (open) {
if (props.viewMode === 'calendar') {
form.poliId = props.selectedPoli || null;
form.dokter = props.selectedDoctor || null;
} else {
form.poliId = null;
form.dokter = null;
}
form.tipe = 'Rutin';
form.hari = '';
form.shifts = [{ jamMulai: '', jamSelesai: '' }];
formErrors.value = {};
if (props.clickedDate) {
const dateStr = toDateStr(props.clickedDate);
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
} else {
const dateStr = toDateStr(new Date());
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
}
}
});
/**
* Validasi form sebelum simpan.
* @returns {boolean} true jika valid
*/
const validateForm = () => {
const errors = {};
if (!form.poliId) errors.poli = 'Pilih poli terlebih dahulu';
if (!form.dokter) errors.dokter = 'Pilih dokter terlebih dahulu';
if (form.tipe === 'Rutin') {
if (!form.hari) errors.hari = 'Pilih hari kerja';
form.shifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`shift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`shift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`shift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
} else {
if (!form.adhocTanggal) errors.adhocTanggal = 'Pilih tanggal';
form.adhocShifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`adhocShift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`adhocShift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`adhocShift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
}
formErrors.value = errors;
return Object.keys(errors).length === 0;
};
/**
* Menangani submit form — validasi lalu emit data ke parent.
*/
const handleSave = () => {
if (!validateForm()) return;
const poliName = props.clinicList.find(c => c.id === form.poliId)?.title ?? '';
const emittedData = [];
if (form.tipe === 'Rutin') {
form.shifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Rutin',
hari: [form.hari],
jamMulai: shift.jamMulai || null,
jamSelesai: shift.jamSelesai || null,
});
});
} else {
form.adhocShifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Adhoc',
mulai: `${form.adhocTanggal}T${shift.jamMulai}:00`,
selesai: `${form.adhocTanggal}T${shift.jamSelesai}:00`,
});
});
}
emit('save', emittedData);
dialogOpen.value = false;
};
import MiniCalendarPreview from './MiniCalendarPreview.vue';
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="520px" persistent>
<v-card rounded="lg" class="pa-0">
<!-- Header -->
<v-card-title class="bg-primary text-white pa-5">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>mdi-calendar-plus</v-icon>
<span class="text-h6 font-weight-bold">Tambah Jadwal Dokter</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" class="text-white"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6">
<!-- Tipe Jadwal -->
<div class="text-caption text-grey-darken-1 mb-2 font-weight-medium">Tipe Jadwal</div>
<v-btn-toggle v-model="form.tipe" mandatory color="primary" variant="outlined" class="mb-5 w-100"
divided>
<v-btn value="Rutin" class="flex-grow-1">
<v-icon start>mdi-calendar-sync</v-icon>
Rutin
</v-btn>
<v-btn value="Adhoc" class="flex-grow-1">
<v-icon start>mdi-calendar-clock</v-icon>
Adhoc
</v-btn>
</v-btn-toggle>
<!-- Info poli & dokter -->
<v-row class="mb-4">
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Poli / Klinik <span class="text-error">*</span></div>
<v-select v-model="form.poliId" :items="clinicList" item-title="title" item-value="id"
density="compact" variant="outlined" hide-details="auto" placeholder="Pilih Poli"
prepend-inner-icon="mdi-hospital-building" @update:model-value="form.dokter = null"
:error-messages="formErrors.poli" />
</v-col>
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Dokter <span class="text-error">*</span></div>
<v-select v-model="form.dokter" :items="availableDoctors" density="compact" variant="outlined"
hide-details="auto" placeholder="Pilih Dokter" prepend-inner-icon="mdi-doctor"
:disabled="!form.poliId" :error-messages="formErrors.dokter" />
</v-col>
</v-row>
<!-- Form Rutin -->
<template v-if="form.tipe === 'Rutin'">
<div class="text-caption text-grey-darken-1 mb-3 font-weight-medium">Pilih Hari Kerja</div>
<div class="d-flex flex-wrap gap-2 mb-1">
<v-chip class="mr-1" v-for="day in DAYS_OF_WEEK" :key="day"
:color="form.hari === day ? 'primary' : 'primary'"
:variant="form.hari === day ? 'flat' : 'outlined'" size="small" style="cursor:pointer;"
@click="form.hari = day">
{{ day }}
</v-chip>
</div>
<div v-if="formErrors.hari" class="text-caption text-error mb-4">{{ formErrors.hari }}</div>
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
<!-- Jam required -->
<div v-for="(shift, index) in form.shifts" :key="index" class="d-flex align-center gap-4 mt-4">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamMulai`]" />
<div v-if="formErrors[`shift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamMulai`] }}
</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamSelesai`]" />
<div v-if="formErrors[`shift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamSelesai`]
}}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.shifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.shifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.shifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Jadwal {{ form.hari ? `Hari ${form.hari}` : '' }}
</v-btn>
</div>
</template>
<!-- Form Adhoc -->
<template v-else>
<v-row class="mb-2">
<v-col cols="12">
<v-text-field v-model="form.adhocTanggal" label="Tanggal" type="date" variant="outlined"
density="compact" prepend-inner-icon="mdi-calendar" hide-details="auto"
:error-messages="formErrors.adhocTanggal" />
</v-col>
<v-col cols="12">
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
</v-col>
</v-row>
<div v-for="(shift, index) in form.adhocShifts" :key="index" class="d-flex align-center gap-4 mt-2">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamMulai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamMulai`] }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamSelesai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamSelesai`] }}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.adhocShifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.adhocShifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.adhocShifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Shift Adhoc
</v-btn>
</div>
</template>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-end gap-2">
<v-btn variant="text" color="grey" @click="dialogOpen = false">Batal</v-btn>
<v-btn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="handleSave">
Simpan Jadwal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,344 @@
<script setup>
/**
* JadwalDokterCalendar — Mode tampilan kalender untuk jadwal dokter.
* Menampilkan filter poli/dokter dan v-calendar Vuetify dengan event
* jadwal rutin (primary) dan adhoc (secondary).
* Menerima data dari parent dan emit interaksi kembali ke parent.
*/
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const props = defineProps({
/** Daftar klinik/poli untuk filter */
clinicList: { type: Array, default: () => [] },
/** Daftar event kalender yang sudah di-generate oleh parent */
calendarEvents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedPoli',
'update:selectedDoctor',
'date-click',
'event-click',
]);
// ── Filter State (local, synced ke parent via emit) ──────────────────────
/** Poli yang dipilih di mode calendar */
const selectedPoli = defineModel('selectedPoli', { type: [String, Number, null], default: null });
/** Dokter yang dipilih di mode calendar */
const selectedDoctor = defineModel('selectedDoctor', { type: [String, null], default: null });
const selectedElement = ref(null)
const selectedOpen = ref(false)
/**
* Daftar dokter berdasarkan poli yang dipilih.
* @returns {string[]} Kosong jika poli belum dipilih
*/
const doctorList = computed(() => {
if (!selectedPoli.value) return [];
const clinic = props.clinicList.find(c => c.id === selectedPoli.value);
return clinic?.doctors ?? [];
});
/** Reset dokter saat poli berubah */
watch(selectedPoli, () => { selectedDoctor.value = null; });
// ── Navigasi Kalender ────────────────────────────────────────────────────
const isDailyEventsOpen = ref(false);
const dailyEvents = ref([]);
const selectedDateFormatted = ref('');
/** Tanggal referensi untuk navigasi bulan */
const viewDate = ref(new Date());
/**
* Key dinamis untuk v-calendar. Vuetify v-calendar punya internal navigation
* state sendiri — mengubah :model-value saja tidak cukup. Dengan mengganti :key,
* kita paksa komponen re-mount sehingga bulan yang tampil ikut berubah.
*/
const calendarKey = computed(() =>
`cal-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
/** Judul bulan & tahun aktif */
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
/** Navigasi ke bulan sebelumnya */
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
/** Navigasi ke bulan berikutnya */
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
/**
* Menangani klik pada tanggal di kalender.
* @param {Date|string|object} info - Payload dari @click:date
*/
const handleDateClick = (info, { date }) => {
emit('date-click', date);
};
/**
* Menangani klik pada event di kalender.
* @param {object} payload - Payload dari @click:event v-calendar
*/
const handleEventClick = (nativeEvent, { event }) => {
const ev = event;
if (!ev?.extendedProps) return;
const d = ev.start instanceof Date ? ev.start : new Date(ev.start);
emit('event-click', {
...ev.extendedProps,
tanggal: d.toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
});
};
/**
* Menangani klik "+X lainnya" pada kalender.
* @param {Date|string|object} info - Payload dari @click:more
*/
const handleMoreClick = (nativeEvent, { date }) => {
selectedDateFormatted.value = date;
console.log("date", date)
console.log("calendarEvents.value", props.calendarEvents)
dailyEvents.value = props.calendarEvents.filter(e =>
e.start.toISOString().split("T")[0] === selectedDateFormatted.value
);
const open = () => {
// selectedEvent.value = event
selectedElement.value = nativeEvent.target
requestAnimationFrame(() => requestAnimationFrame(() => selectedOpen.value = true))
}
if (selectedOpen.value) {
selectedOpen.value = false
requestAnimationFrame(() => requestAnimationFrame(() => open()))
} else {
open()
}
nativeEvent.stopPropagation()
};
/**
* Menangani klik pada event di dalam dialog harian.
*/
const handleDailyEventClick = (ev) => {
isDailyEventsOpen.value = false;
const payload = { event: ev };
handleEventClick(payload);
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-row class="mb-2">
<v-col cols="12">
<v-card class="pa-6" color="white">
<div class="d-flex align-center mb-4">
<v-icon color="primary" class="mr-2">mdi-filter-variant</v-icon>
<h3 class="text-subtitle-1 font-weight-bold text-primary">FILTER JADWAL</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-select v-model="selectedPoli" :items="clinicList" item-title="title" item-value="id"
label="Pilih Poli / Klinik" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="6">
<v-select v-model="selectedDoctor" :items="doctorList" label="Pilih Dokter"
variant="outlined" density="compact" clearable prepend-inner-icon="mdi-doctor"
:disabled="!selectedPoli" hide-details
:placeholder="selectedPoli ? 'Pilih dokter...' : 'Pilih poli terlebih dahulu'" />
</v-col>
</v-row>
<div v-if="selectedPoli || selectedDoctor" class="mt-4 d-flex align-center gap-2 flex-wrap">
<span class="text-caption text-grey-darken-1">Filter aktif:</span>
<v-chip v-if="selectedPoli" size="small" color="primary" variant="tonal" closable
@click:close="selectedPoli = null">
<v-icon start size="x-small">mdi-hospital-building</v-icon>
{{clinicList.find(c => c.id === selectedPoli)?.title}}
</v-chip>
<v-chip v-if="selectedDoctor" size="small" color="secondary" variant="tonal" closable
@click:close="selectedDoctor = null">
<v-icon start size="x-small">mdi-doctor</v-icon>
{{ selectedDoctor }}
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<!-- Calendar Card -->
<v-row v-if="selectedDoctor && selectedPoli">
<v-col cols="12">
<v-card elevation="2" rounded="lg" color="white">
<!-- Toolbar -->
<div class="d-flex align-center justify-space-between px-6 pt-5 pb-4">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="small" @click="nextMonth" />
<span class="text-body-1 font-weight-bold" style="min-width:150px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
<!-- Legend -->
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-1">
<span class="legend-dot bg-primary"></span>
<span class="text-caption ml-1">Rutin</span>
</div>
<div class="d-flex align-center gap-1 ml-1">
<span class="legend-dot bg-secondary"></span>
<span class="text-caption ml-1">Adhoc</span>
</div>
</div>
</div>
<v-divider />
<!-- v-calendar Vuetify -->
<v-calendar :interval-height="100" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month" event-more-text="{0} lainnya" class="jadwal-calendar"
@click:date="handleDateClick" @click:event="handleEventClick" @click:more="handleMoreClick" />
<v-menu v-model="selectedOpen" :activator="selectedElement" :close-on-content-click="false"
location="end">
<v-card color="grey-lighten-4" min-width="350px" flat>
<v-toolbar color="primary" dark>
<v-btn icon>
<v-icon>mdi-calendar</v-icon>
</v-btn>
<v-toolbar-title>Jadwal Lainnya</v-toolbar-title>
</v-toolbar>
<v-card-text style="max-height: 300px; overflow-y: auto;">
<v-list v-if="dailyEvents.length > 0" lines="two" bg-color="transparent">
<v-list-item v-for="(ev, idx) in dailyEvents" :key="idx" class="mb-3 rounded border"
:class="ev.color === 'primary' ? 'border-primary' : 'border-secondary'"
@click="handleDailyEventClick(ev)" hover>
<v-list-item-title class="font-weight-bold">
<v-chip :color="ev.color" size="small" class="mr-2">{{
ev.extendedProps?.tipe }}</v-chip>
{{ ev.name }}
</v-list-item-title>
<v-list-item-subtitle class="mt-1">
<v-icon size="x-small" class="mr-1">mdi-doctor</v-icon>{{
ev.extendedProps?.dokter }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-icon size="x-small" class="mr-1">mdi-hospital-building</v-icon>{{
ev.extendedProps?.poli }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-center pa-6 text-grey">
Tidak ada jadwal tambahan.
</div>
</v-card-text>
</v-card>
</v-menu>
</v-card>
</v-col>
</v-row>
<template v-else>
<v-card min-height="400px">
<v-card-text>
<v-row>
<v-col cols="12">
<div class="text-center pa-12 d-flex align-center justify-center" style="height: 400px;">
<div>
<v-icon size="48" color="grey-lighten-1"
class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2 text-grey-lighten-1">Silahkan Pilih Poli dan Dokter terlebih
dahulu</div>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
</div>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 125px;
}
.jadwal-calendar {
border-radius: 0 0 8px 8px;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
/* Header hari */
:deep(.v-calendar-month__weekday) {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #283593;
background-color: #e8eaf6;
padding: 6px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-month__day-label) {
font-size: 0.8rem;
font-weight: 500;
color: #424242;
cursor: pointer;
}
:deep(.v-calendar-month__day-label:hover) {
background-color: #e8eaf6;
border-radius: 50%;
}
/* Hari ini */
:deep(.v-calendar-month__day--today .v-calendar-month__day-label) {
background-color: #1a237e;
color: white;
border-radius: 50%;
}
/* Event */
:deep(.v-calendar-month__event) {
font-size: 0.68rem !important;
font-weight: 600;
border-radius: 4px !important;
cursor: pointer;
transition: opacity 0.15s ease;
}
:deep(.v-calendar-month__event:hover) {
opacity: 0.82;
}
</style>
@@ -0,0 +1,313 @@
<script setup>
/**
* JadwalDokterList — Mode tampilan daftar (tabel) untuk jadwal dokter.
* Menampilkan filter (poli klinik, tanggal, nama dokter) dan tabel
* dengan kolom NO, POLI, NAMA DOKTER, JADWAL RUTIN, AKSI.
* Mode ini menjadi tampilan default halaman Jadwal Dokter.
*/
const props = defineProps({
/** Daftar klinik/poli dari store */
clinicList: { type: Array, default: () => [] },
/** Daftar jadwal yang sudah ditambahkan */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['edit', 'add']);
// ── Filter State ─────────────────────────────────────────────────────────
/** Poli yang dipilih untuk filter */
const filterPoli = ref(null);
/** Tanggal yang dipilih untuk filter */
const filterDate = ref(null);
/** Keyword pencarian nama dokter */
const filterDokter = ref('');
// ── Pagination ───────────────────────────────────────────────────────────
/** Halaman aktif saat ini */
const currentPage = ref(1);
/** Jumlah baris per halaman */
const itemsPerPage = ref(10);
/** Opsi jumlah baris per halaman */
const perPageOptions = [5, 10, 25, 50];
// ── Mapping Hari ─────────────────────────────────────────────────────────
const DAY_MAP = {
0: 'Minggu', 1: 'Senin', 2: 'Selasa', 3: 'Rabu',
4: 'Kamis', 5: 'Jumat', 6: 'Sabtu'
};
// ── Computed: Data Tabel ─────────────────────────────────────────────────
/**
* Membangun data tabel dari jadwalList, dikelompokkan per dokter + poli.
* Setiap baris menampilkan 1 dokter dengan ringkasan jadwal rutinnya.
* @returns {Array<{ id, poliName, poliId, dokter, jadwalRutin, raw }>}
*/
const tableData = computed(() => {
/** Gabungkan jadwal berdasarkan key unik dokter+poli */
const grouped = {};
props.jadwalList.forEach(j => {
const key = `${j.poliId}-${j.dokter}`;
if (!grouped[key]) {
grouped[key] = {
id: j.id,
poliName: j.poliName,
poliId: j.poliId,
dokter: j.dokter,
hariSet: new Set(),
jamMulai: null,
jamSelesai: null,
raw: [],
};
}
grouped[key].raw.push(j);
if (j.tipe === 'Rutin' && j.hari) {
j.hari.forEach(h => grouped[key].hariSet.add(h));
if (j.jamMulai) grouped[key].jamMulai = j.jamMulai;
if (j.jamSelesai) grouped[key].jamSelesai = j.jamSelesai;
}
});
return Object.values(grouped).map(g => {
const hariArr = Array.from(g.hariSet);
/** Format jadwal rutin: "Senin - Kamis\n08:00 - 14:00 WIB" */
let jadwalRutin = '';
if (hariArr.length > 0) {
jadwalRutin = hariArr.join(', ');
if (g.jamMulai && g.jamSelesai) {
jadwalRutin += `\n${g.jamMulai} - ${g.jamSelesai} WIB`;
}
} else {
jadwalRutin = '-';
}
return {
id: g.id,
poliName: g.poliName,
poliId: g.poliId,
dokter: g.dokter,
jadwalRutin,
raw: g.raw,
};
});
});
/**
* Data tabel yang sudah difilter berdasarkan poli, tanggal, dan keyword nama dokter.
* @returns {Array}
*/
const filteredData = computed(() => {
let data = tableData.value;
// Filter poli
if (filterPoli.value) {
data = data.filter(d => d.poliId === filterPoli.value);
}
// Filter tanggal — cocokkan dengan hari dari jadwal rutin
if (filterDate.value) {
const date = new Date(filterDate.value);
const dayName = DAY_MAP[date.getDay()];
data = data.filter(d =>
d.raw.some(j => j.tipe === 'Rutin' && j.hari?.includes(dayName))
);
}
// Filter nama dokter
if (filterDokter.value) {
const keyword = filterDokter.value.toLowerCase();
data = data.filter(d => d.dokter.toLowerCase().includes(keyword));
}
return data;
});
/** Total data setelah filter */
const totalItems = computed(() => filteredData.value.length);
/** Total halaman */
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value)));
/** Data untuk halaman aktif (sliced) */
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
return filteredData.value.slice(start, start + itemsPerPage.value);
});
/** Label info pagination: "Menampilkan 1-10 dari 124 dokter spesialis" */
const paginationLabel = computed(() => {
if (totalItems.value === 0) return 'Tidak ada data';
const start = (currentPage.value - 1) * itemsPerPage.value + 1;
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
return `Menampilkan ${start}-${end} dari ${totalItems.value} dokter spesialis`;
});
/** Reset halaman saat filter berubah */
watch([filterPoli, filterDate, filterDokter], () => {
currentPage.value = 1;
});
/**
* Navigasi ke halaman tertentu.
* @param {number} page
*/
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-card class="pa-5 mb-6" elevation="2" rounded="lg" color="white">
<v-row align="center">
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Poli Klinik</div>
<v-select v-model="filterPoli" :items="clinicList" item-title="title" item-value="id"
placeholder="Semua Poli" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Tanggal</div>
<v-text-field v-model="filterDate" type="date" placeholder="Pilih Tanggal" variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-calendar" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Nama Dokter</div>
<v-text-field v-model="filterDokter" placeholder="Cari Nama Dokter..." variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-account-search" hide-details />
</v-col>
</v-row>
</v-card>
<!-- Tabel Jadwal -->
<v-card elevation="2" rounded="lg" color="white">
<v-table class="jadwal-table">
<thead>
<tr>
<th class="text-left" style="width: 60px;">NO</th>
<th class="text-left" style="width: 140px;">POLI</th>
<th class="text-left">NAMA DOKTER</th>
<th class="text-left" style="width: 200px;">JADWAL RUTIN</th>
<th class="text-center" style="width: 100px;">AKSI</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="5" class="text-center pa-8 text-grey">
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2">Belum ada data jadwal dokter</div>
<div class="text-caption text-grey-lighten-1">Tambahkan jadwal dokter melalui mode
kalender</div>
</td>
</tr>
<tr v-for="(item, index) in paginatedData" :key="item.id" class="jadwal-row">
<td class="text-body-2 font-weight-medium">
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
</td>
<td>
<v-chip size="small" color="primary" variant="tonal">
{{ item.poliName }}
</v-chip>
</td>
<td class="text-body-2 font-weight-bold">
{{ item.dokter }}
</td>
<td>
<div class="text-body-2" style="white-space: pre-line; line-height: 1.5;">
{{ item.jadwalRutin }}
</div>
</td>
<td class="text-center">
<v-btn variant="outlined" size="small" color="primary" rounded="lg"
prepend-icon="mdi-pencil" @click="emit('edit', item)">
Edit
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<!-- Pagination Footer -->
<v-divider />
<div class="d-flex align-center justify-space-between px-4 py-3 pagination-footer">
<span class="text-caption text-grey-darken-1">{{ paginationLabel }}</span>
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-2">
<span class="text-caption text-grey-darken-1 mr-2">Baris per halaman:</span>
<v-select v-model="itemsPerPage" :items="perPageOptions" variant="plain" density="compact"
hide-details style="max-width: 70px;" />
</div>
<div class="d-flex align-center gap-1">
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1" @click="goToPage(1)">
<v-icon size="16">mdi-page-first</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)">
<v-icon size="16">mdi-chevron-left</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)">
<v-icon size="16">mdi-chevron-right</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(totalPages)">
<v-icon size="16">mdi-page-last</v-icon>
</v-btn>
</div>
</div>
</div>
</v-card>
</div>
</template>
<style scoped>
.jadwal-table {
border-radius: 12px 12px 0 0;
}
.jadwal-table thead th {
background-color: #f5f6fa !important;
color: #5a607f !important;
font-weight: 700 !important;
font-size: 0.75rem !important;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 2px solid #e8eaf0 !important;
padding: 14px 16px !important;
}
.jadwal-row {
transition: background-color 0.15s ease;
}
.jadwal-row:hover {
background-color: #f8f9fd;
}
.jadwal-row td {
padding: 16px !important;
border-bottom: 1px solid #f0f1f5 !important;
vertical-align: middle;
}
.pagination-footer {
background-color: #fafbfc;
border-radius: 0 0 12px 12px;
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
tipe: { type: String, default: 'Rutin' },
hari: { type: String, default: '' },
adhocTanggal: { type: String, default: '' },
jadwalList: { type: Array, default: () => [] },
poliId: { type: [String, Number, null], default: null },
dokter: { type: String, default: null }
});
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const DAY_MAP = {
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
};
const viewDate = ref(new Date());
const calendarKey = computed(() =>
`cal-mini-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
const toDateStr = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const calendarEvents = computed(() => {
const events = [];
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
// 1. Generate existing events for the selected doctor
const filtered = props.jadwalList.filter(j => {
if (props.poliId && j.poliId !== props.poliId) return false;
if (props.dokter && j.dokter !== props.dokter) return false;
return true;
});
filtered.forEach(jadwal => {
if (jadwal.tipe === 'Rutin') {
for (let mOffset = -1; mOffset <= 1; mOffset++) {
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
const tMonth = (month + mOffset + 12) % 12;
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(tYear, tMonth, d);
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
let jadwalHariArray = [];
if (Array.isArray(jadwal.hari)) {
jadwalHariArray = jadwal.hari;
} else if (typeof jadwal.hari === 'string') {
jadwalHariArray = [jadwal.hari];
}
if (!jadwalHariArray.includes(dayName)) continue;
const dateStr = toDateStr(date);
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
: '';
events.push({
name: `${jamLabel}`,
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
color: 'primary',
allDay: !jadwal.jamMulai,
});
}
}
} else {
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
: '';
events.push({
name: `${adhocJamLabel}`,
start: new Date(jadwal.mulai),
end: new Date(jadwal.selesai),
color: 'secondary',
allDay: false,
});
}
});
return events;
});
const calendarRef = ref(null);
const updateHighlight = () => {
// Wait for calendar to fully render internally
setTimeout(() => {
const calendarEl = calendarRef.value?.$el || document.querySelector('.mini-calendar');
if (!calendarEl) return;
// Remove previous highlights
const previous = calendarEl.querySelectorAll('.adhoc-highlight, .rutin-highlight');
previous.forEach(el => {
el.classList.remove('adhoc-highlight', 'rutin-highlight');
el.style.border = '';
el.style.borderRadius = '';
el.style.color = '';
el.style.fontWeight = '';
});
const allDays = calendarEl.querySelectorAll('.v-calendar-weekly__day');
if (allDays.length === 0) return;
if (props.tipe === 'Rutin' && props.hari) {
const targetCol = DAY_MAP[props.hari];
allDays.forEach((dayEl, index) => {
// Check if index matches column and it's not from adjacent month
if (index % 7 === targetCol && !dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelBtn) {
labelBtn.classList.add('rutin-highlight');
// Fallback inline styles in case CSS scoped issue occurs
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
} else if (props.tipe === 'Adhoc' && props.adhocTanggal) {
const adhocDate = new Date(props.adhocTanggal);
if (adhocDate.getMonth() !== viewDate.value.getMonth() || adhocDate.getFullYear() !== viewDate.value.getFullYear()) {
return;
}
const dayToHighlight = adhocDate.getDate().toString();
allDays.forEach(dayEl => {
if (!dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelTextEl = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn__content, .v-calendar-weekly__day-label');
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelTextEl && labelTextEl.textContent.trim() === dayToHighlight) {
labelBtn.classList.add('adhoc-highlight');
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
}
}, 150);
};
import { watch, nextTick } from 'vue';
watch([() => props.adhocTanggal, () => props.hari, () => props.tipe, viewDate], () => {
nextTick(() => updateHighlight());
}, { immediate: true });
</script>
<template>
<v-card class="mt-4 border border-grey-lighten-3 rounded-lg" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-3 pb-2">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="x-small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="x-small" @click="nextMonth" />
<span class="text-caption font-weight-bold mx-2" style="min-width:100px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
</div>
<!-- We use a custom class to inject CSS for highlighted days if they have preview event -->
<v-calendar ref="calendarRef" :interval-height="40" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month"
:class="['mini-calendar', props.tipe === 'Rutin' && props.hari ? 'preview-hari-' + props.hari : '']" />
</v-card>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 70px !important;
}
.mini-calendar {
border-radius: 0 0 8px 8px;
background-color: white !important;
}
/* Header hari */
:deep(.v-calendar-weekly__weekday) {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
color: #555;
background-color: #f5f5f5;
padding: 4px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-weekly__day-label) {
font-size: 0.8rem;
font-weight: 600;
color: #424242;
transition: all 0.2s ease;
}
/* Lingkaran Preview via JS Class */
:deep(.rutin-highlight),
:deep(.adhoc-highlight) {
border: 2px solid rgb(var(--v-theme-primary)) !important;
border-radius: 50% !important;
color: rgb(var(--v-theme-primary)) !important;
font-weight: bold !important;
}
/* Event styles */
:deep(.v-calendar-weekly__event) {
font-size: 0.6rem !important;
font-weight: 600;
border-radius: 4px !important;
padding: 0 4px !important;
margin-bottom: 2px !important;
min-height: 16px !important;
}
</style>
@@ -0,0 +1,83 @@
<template>
<div class="day-selector">
<v-chip
v-for="hari in dayList"
:key="hari.hari"
class="day-chip"
:class="{ 'day-chip-active': selectedDays.includes(hari.hari) }"
label
@click="toggleDay(hari.hari)"
>
<v-icon v-if="selectedDays.includes(hari.hari)" left size="16">mdi-check</v-icon>
{{ hari.hari }}
</v-chip>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const dayList = ref([
{ no: 1, hari: 'Senin' },
{ no: 2, hari: 'Selasa' },
{ no: 3, hari: 'Rabu' },
{ no: 4, hari: 'Kamis' },
{ no: 5, hari: 'Jum\'at' },
]);
const selectedDays = ref(props.modelValue);
const toggleDay = (hari) => {
const index = selectedDays.value.indexOf(hari);
if (index > -1) {
selectedDays.value.splice(index, 1);
} else {
selectedDays.value.push(hari);
}
emit('update:modelValue', selectedDays.value);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$neutral-800: #4D4D4D;
$secondary-300: #DBEDFF;
$secondary-600: #0671E0;
$font-weight-medium: 500;
.day-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.day-chip {
cursor: pointer;
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-medium;
border: 1px solid $neutral-500;
background-color: $neutral-100;
color: $neutral-800;
&:hover {
background-color: $secondary-300;
}
}
.day-chip-active {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
border-color: $secondary-600 !important;
}
</style>
@@ -0,0 +1,276 @@
<template>
<v-dialog v-model="dialogModel" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Klinik' : 'Tambah Klinik' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Dasar -->
<FormFieldGroup title="Informasi Dasar">
<v-row dense>
<v-col cols="4">
<v-text-field
v-model="formModel.kode"
label="Kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode harus diisi']"
hide-details="auto"
placeholder="AN"
class="mb-3 input-field"
/>
<small class="caption-2">2 Huruf</small>
</v-col>
<v-col cols="4">
<v-text-field
v-model="formModel.nama"
label="Nama"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama harus diisi']"
hide-details="auto"
placeholder="Gigi dan Mulut"
class="mb-3 input-field"
/>
</v-col>
<v-col cols="4">
<v-text-field
v-model.number="formModel.shift"
label="Jumlah Shift"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Shift harus diisi', v => v > 0 || 'Minimal 1']"
hide-details="auto"
class="mb-3 input-field"
@update:model-value="updateShiftCount"
/>
</v-col>
</v-row>
<v-checkbox
v-model="formModel.autoShift"
label="Auto Shift"
hide-details
color="#0671E0"
density="compact"
class="checkbox-field"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi Shift & Kuota -->
<FormFieldGroup title="Konfigurasi Shift & Kuota" icon="mdi-clock-outline">
<ShiftConfiguration
:shifts="formModel.jamShiftList"
@update:shifts="formModel.jamShiftList = $event"
@remove-shift="removeShift"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Jadwal Klinik -->
<FormFieldGroup title="Jadwal Klinik" icon="mdi-calendar-check">
<DaySelector v-model="formModel.jadwalKlinik" />
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="handleClose"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="handleSubmit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
import ShiftConfiguration from '@/components/master/ShiftConfiguration.vue';
import DaySelector from '@/components/master/DaySelector.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const updateShiftCount = (newShiftCount) => {
const currentCount = formModel.value.jamShiftList.length;
if (newShiftCount > currentCount) {
for (let i = currentCount; i < newShiftCount; i++) {
let defaultDari = '07:00', defaultSampai = '11:00';
if (i === 1) { defaultDari = '13:00'; defaultSampai = '16:00'; }
else if (i === 2) { defaultDari = '18:00'; defaultSampai = '20:00'; }
else if (i > 2) {
const prevShift = formModel.value.jamShiftList[i - 1];
const [prevHour] = prevShift.sampai.split(':');
const nextHour = (parseInt(prevHour) + 1) % 24;
defaultDari = `${String(nextHour).padStart(2, '0')}:00`;
defaultSampai = `${String((nextHour + 4) % 24).padStart(2, '0')}:00`;
}
formModel.value.jamShiftList.push({ dari: defaultDari, sampai: defaultSampai, kuota: 0 });
}
} else if (newShiftCount < currentCount && newShiftCount > 0) {
formModel.value.jamShiftList = formModel.value.jamShiftList.slice(0, newShiftCount);
}
};
const removeShift = (index) => {
if (formModel.value.jamShiftList.length > 1) {
formModel.value.jamShiftList.splice(index, 1);
formModel.value.shift = formModel.value.jamShiftList.length;
}
};
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (valid) {
emit('submit', formModel.value);
}
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.checkbox-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,171 @@
<template>
<div>
<div v-for="(shift, index) in shifts" :key="index" class="shift-item">
<v-row dense align="center">
<v-col cols="2">
<div class="shift-badge body-3">Shift {{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
v-model="shift.dari"
label="Mulai"
type="time"
variant="outlined"
density="compact"
hide-details
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model="shift.sampai"
label="Selesai"
type="time"
variant="outlined"
density="compact"
hide-details
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model.number="shift.kuota"
label="Kuota"
type="number"
variant="outlined"
density="compact"
hide-details
placeholder="0"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="1">
<v-btn
v-if="shifts.length > 1"
icon
size="small"
variant="text"
class="btn-delete-shift"
@click="removeShift(index)"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<div class="total-badge">
<v-icon size="18" class="icon-success">mdi-sigma</v-icon>
<span class="body-3 text-medium">Total Kuota:</span>
<v-chip size="small" variant="flat" class="chip-success">
{{ totalQuota }}
</v-chip>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
shifts: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:shifts', 'remove-shift']);
const totalQuota = computed(() => {
return props.shifts.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
});
const emitUpdate = () => {
emit('update:shifts', props.shifts);
};
const removeShift = (index) => {
emit('remove-shift', index);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$success-200: #F1FBF8;
$success-300: #84DFC1;
$success-600: #009262;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.shift-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.shift-badge {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-delete-shift {
color: $danger-600 !important;
}
.total-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: $success-200;
border-radius: 8px;
margin-top: 8px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.chip-success {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
}
</style>
@@ -0,0 +1,111 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template #item.shift="{ item }">
<v-chip size="small" class="chip-secondary">
{{ item.shift }}
</v-chip>
</template>
<template #item.totalQuota="{ item }">
<span class="body-2 text-semibold">{{ item.totalQuota }}</span>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
class="btn-edit mr-2"
variant="flat"
@click="$emit('edit', item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
class="btn-delete"
variant="outlined"
@click="$emit('delete', item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'No', value: 'no', sortable: true },
{ title: 'Kode', value: 'kode', sortable: true },
{ title: 'Nama Klinik', value: 'nama', sortable: true },
{ title: 'Shift', value: 'shift', sortable: true },
{ title: 'Total Kuota', value: 'totalQuota', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-800: #4D4D4D;
$primary-600: #FFA532;
$secondary-600: #0671E0;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-secondary {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.body-2 {
font-size: 16px;
line-height: 24px;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,327 @@
<template>
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Loket' : 'Tambah Loket Baru' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Loket -->
<FormFieldGroup title="Informasi Loket">
<v-text-field
v-model="formModel.namaLoket"
label="Nama Loket"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama loket harus diisi']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Loket A"
/>
<v-text-field
v-model.number="formModel.kuota"
label="Kuota Bangku"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota minimal 1']"
hide-details="auto"
class="mb-3 input-field"
placeholder="500"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi -->
<FormFieldGroup title="Konfigurasi">
<v-select
v-model="formModel.statusPelayanan"
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
hide-details="auto"
class="mb-3 input-field"
/>
<v-row dense>
<v-col cols="6">
<v-select
v-model="formModel.pembayaran"
label="Pembayaran"
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Pembayaran harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="formModel.keterangan"
label="Keterangan"
:items="['ONLINE', 'OFFLINE']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Keterangan harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
</v-row>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Pelayanan -->
<FormFieldGroup title="Pelayanan" icon="mdi-hospital-box">
<v-select
v-model="formModel.pelayanan"
label="Pilih Pelayanan"
:items="availableServices"
item-title="nama"
item-value="id"
variant="outlined"
density="compact"
multiple
chips
closable-chips
:rules="[v => v.length > 0 || 'Pilih minimal 1 pelayanan']"
hide-details="auto"
class="input-field"
>
<template #chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
class="chip-primary"
>
{{ item.raw.nama }}
</v-chip>
</template>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<v-chip size="x-small" class="chip-primary-small">{{ item.raw.id }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<!-- Selected Services Preview -->
<div v-if="formModel.pelayanan.length > 0" class="selected-preview">
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
<small class="caption-2">{{ formModel.pelayanan.length }} pelayanan dipilih</small>
</div>
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="handleClose"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="handleSubmit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
},
availableServices: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (valid) {
emit('submit', formModel.value);
}
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$success-200: #F1FBF8;
$success-300: #84DFC1;
$success-600: #009262;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
color: $neutral-700;
}
.chip-primary {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-primary-small {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
.selected-preview {
margin-top: 8px;
padding: 8px 12px;
background: $success-200;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,125 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template #item.pelayanan="{ item }">
<v-chip
v-for="(serviceKode, idx) in item.pelayanan.slice(0, 2)"
:key="idx"
size="small"
class="mr-1 mb-1 chip-primary-outline"
>
{{ getKlinikName(serviceKode) }}
</v-chip>
<v-chip
v-if="item.pelayanan.length > 2"
size="small"
class="chip-neutral"
>
+{{ item.pelayanan.length - 2 }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
variant="flat"
class="btn-edit mr-2"
@click="$emit('edit', item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
variant="outlined"
class="btn-delete"
@click="$emit('delete', item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
},
getKlinikName: {
type: Function,
required: true
}
});
const headers = [
{ title: "No", value: "no" },
{ title: "Nama Loket", value: "namaLoket" },
{ title: "Kuota", value: "kuota" },
{ title: "Pelayanan", value: "pelayanan" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Keterangan", value: "keterangan" },
{ title: "Aksi", value: "aksi", sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-600: #89939E;
$primary-600: #FFA532;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-primary-outline {
border: 1px solid $primary-600;
background-color: transparent !important;
color: $primary-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.chip-neutral {
background-color: $neutral-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,245 @@
<template>
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Klinik Ruang' : 'Tambah Klinik Ruang' }}</span>
<v-btn icon variant="text" @click="handleClose" size="small" class="btn-close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Pilih Klinik -->
<FormFieldGroup title="Pilih Klinik" icon="mdi-hospital-building">
<v-autocomplete
label="Kode Klinik"
v-model="formModel.kodeKlinik"
:items="klinikList"
item-title="nama"
item-value="kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode klinik harus dipilih']"
hide-details="auto"
placeholder="Pilih Klinik"
@update:model-value="handleKlinikChange"
class="mb-3 input-field"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-chip size="x-small" class="chip-success-small">{{ item.raw.kode }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-autocomplete>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Daftar Ruangan -->
<FormFieldGroup title="Daftar Ruangan" icon="mdi-door">
<RoomListManager
:rooms="formModel.ruangList"
@update:rooms="formModel.ruangList = $event"
@add-room="addRoom"
@remove-room="removeRoom"
/>
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
@click="handleClose"
class="btn-cancel"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
@click="handleSubmit"
class="btn-submit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
import RoomListManager from '@/components/master/RoomListManager.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
},
klinikList: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close', 'klinik-change']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const handleKlinikChange = (kode) => {
emit('klinik-change', kode);
};
const addRoom = () => {
formModel.value.ruangList.push({
nomorRuang: '',
namaRuang: '',
nomorScreen: ''
});
};
const removeRoom = (index) => {
if (formModel.value.ruangList.length > 1) {
formModel.value.ruangList.splice(index, 1);
}
};
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
if (formModel.value.ruangList.length === 0) {
// Emit validation error
emit('validation-error', 'Tambahkan minimal 1 ruangan');
return;
}
emit('submit', formModel.value);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$success-600: #009262;
$success-700: #1B6E53;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.chip-success-small {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<div>
<div v-for="(ruang, index) in rooms" :key="index" class="ruang-item">
<v-row dense align="center">
<v-col cols="1">
<div class="ruang-badge body-3">{{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Ruang"
v-model="ruang.nomorRuang"
variant="outlined"
density="compact"
hide-details
placeholder="1"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="4">
<v-text-field
label="Nama Ruang"
v-model="ruang.namaRuang"
variant="outlined"
density="compact"
hide-details
placeholder="Ruang Konsultasi"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Screen"
v-model="ruang.nomorScreen"
variant="outlined"
density="compact"
hide-details
placeholder="101"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="1">
<v-btn
v-if="rooms.length > 1"
icon
size="small"
variant="text"
@click="removeRoom(index)"
class="btn-delete-ruang"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<v-btn
variant="outlined"
size="small"
@click="addRoom"
class="btn-add-ruang mt-2"
>
<v-icon left size="18">mdi-plus</v-icon>
Tambah Ruang
</v-btn>
</div>
</template>
<script setup>
const props = defineProps({
rooms: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:rooms', 'add-room', 'remove-room']);
const emitUpdate = () => {
emit('update:rooms', props.rooms);
};
const addRoom = () => {
emit('add-room');
};
const removeRoom = (index) => {
emit('remove-room', index);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$success-600: #009262;
$success-700: #1B6E53;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.ruang-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.ruang-badge {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-delete-ruang {
color: $danger-600 !important;
}
.btn-add-ruang {
border-color: $success-600 !important;
color: $success-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,108 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template v-slot:item.namaKlinik="{ item }">
<v-chip size="small" class="chip-success-outline">
{{ item.kodeKlinik }}
</v-chip>
<span class="ml-2 body-3 text-medium">{{ item.namaKlinik }}</span>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
@click="$emit('edit', item)"
class="btn-edit mr-2"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
@click="$emit('delete', item)"
class="btn-delete"
variant="outlined"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'No', value: 'no', sortable: true },
{ title: 'Nama Klinik', value: 'namaKlinik', sortable: true },
{ title: 'Kode', value: 'kodeKlinik', sortable: true },
{ title: 'Nama Ruang', value: 'namaRuang', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$success-600: #009262;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-success-outline {
border: 1px solid $success-600;
background-color: transparent !important;
color: $success-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.btn-edit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,156 @@
<template>
<v-dialog v-model="internalModel" max-width="500px">
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
<!-- Solid Blue Header -->
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
</v-card-title>
<v-card-text class="dialog-content-premium pa-6 text-center">
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
Antrean Belum Dibuat
</div>
<p class="text-body-1 mb-6">
Tiket antrean ruang atau penunjang <strong>belum dibuat</strong> untuk pasien ini.
</p>
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
<div class="comparison-item current">
<div class="item-label text-center">PASIEN AKTIF</div>
<div class="item-card">
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
<div class="item-status">Sedang Diproses</div>
</div>
</div>
</div>
<p class="text-body-2 text-neutral-600">
Apakah Anda yakin ingin menyelesaikan pelayanan untuk pasien ini?
</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 bg-light justify-center">
<v-btn
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
variant="outlined"
@click="internalModel = false"
size="large"
>
Batal
</v-btn>
<v-btn
class="px-8 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
variant="flat"
@click="confirm"
size="large"
elevation="2"
>
Ya, Selesaikan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
patient: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const internalModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const confirm = () => {
emit('confirm');
internalModel.value = false;
};
</script>
<style scoped>
.dialog-card {
/* Remove border to prevent white outline around blue header */
}
.bg-light {
background-color: var(--color-neutral-50) !important;
}
.comparison-container {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-neutral-300);
}
.comparison-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-label {
font-size: 10px;
font-weight: 800;
color: var(--color-neutral-600);
letter-spacing: 0.05em;
}
.item-card {
background: white;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--color-neutral-400);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.item-number {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.item-status {
font-size: 11px;
color: var(--color-neutral-600);
font-weight: 600;
}
.text-danger-700 {
color: var(--color-danger-700) !important;
}
.btn-batal {
border-color: var(--color-neutral-300) !important;
color: var(--color-neutral-700) !important;
}
.btn-confirm {
background-color: var(--color-primary-600) !important;
color: white !important;
}
</style>
@@ -0,0 +1,510 @@
<template>
<v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-header mb-3">
<div class="section-label">SEDANG DIPROSES</div>
<v-btn
v-if="patient"
class="call-button text-white"
color="primary-500"
variant="flat"
size="small"
@click="$emit('call')"
>
<v-icon start size="18">mdi-bullhorn</v-icon>
Panggil
</v-btn>
</div>
<div v-if="patient" class="patient-details" :class="`patient-details-${theme}`">
<div class="patient-header">
<div class="patient-number-wrapper">
<div class="patient-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<div
v-if="isFastTrack"
class="fast-track-icon-wrapper"
>
<v-icon
color="#E53935"
size="18"
class="fast-track-icon"
>
mdi-flash
</v-icon>
</div>
</div>
<div class="action-icons">
<v-btn
class="action-icon-btn text-white"
color="secondary-600"
variant="flat"
size="small"
@click="$emit('change-klinik')"
>
<v-icon size="16">mdi-swap-horizontal</v-icon>
<span class="btn-label">{{ changeButtonText }}</span>
</v-btn>
<v-btn
class="action-icon-btn text-white"
color="primary-600"
variant="flat"
size="small"
@click="$emit('action', 'terlambat')"
>
<v-icon size="16">mdi-clock-alert</v-icon>
<span class="btn-label">Terlambat</span>
</v-btn>
<v-btn
class="action-icon-btn text-white"
color="danger-600"
variant="flat"
size="small"
@click="$emit('action', 'pending')"
>
<v-icon size="16">mdi-pause</v-icon>
<span class="btn-label">Pending</span>
</v-btn>
<v-btn
class="action-icon-btn text-white"
color="success-600"
variant="flat"
size="small"
@click="$emit('action', 'check-in')"
>
<v-icon size="16">mdi-check</v-icon>
<span class="btn-label">Selesai</span>
</v-btn>
</div>
</div>
<div class="patient-info-text">
<div class="patient-barcode-rm d-flex align-center">
<div class="patient-barcode">{{ patient.barcode }}</div>
<v-chip v-if="patient.noRM" size="x-small" color="primary-600" variant="flat" class="text-white ml-2">
RM: {{ patient.noRM }}
</v-chip>
</div>
<div class="patient-klinik-pembayaran">{{ patient.klinik }} | {{ patient.pembayaran }}</div>
<div v-if="patient.referencePatient" class="patient-reference d-flex align-center gap-1">
<v-icon size="13" color="primary-600">mdi-link</v-icon>
<span class="reference-label">Ref. Loket:</span>
<span class="reference-value text-primary-600">{{ patient.referencePatient }}</span>
</div>
</div>
<div class="create-queue-buttons mt-4">
<div class="create-buttons-container">
<v-btn
class="create-btn-ruang py-6 text-white"
color="primary-600"
:disabled="!patient"
@click="$emit('open-klinik-ruang')"
>
<v-icon size="20" class="mr-2">mdi-door</v-icon>
Buat Antrean Ruang
</v-btn>
<v-btn
class="create-btn-penunjang py-6 text-white"
color="secondary-600"
:disabled="!patient"
@click="$emit('open-penunjang')"
>
<v-icon size="20" class="mr-2">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
</div>
</div>
</div>
<div v-else class="empty-state">
<v-icon size="48" color="grey-lighten-2">mdi-account-off-outline</v-icon>
<div class="empty-text mb-4">Tidak ada pasien yang diproses</div>
<v-btn
block
class="py-6 text-white"
color="primary-600"
size="large"
:disabled="!hasNextQueue"
@click="$emit('process-next')"
>
<v-icon start>mdi-play-circle</v-icon>
Proses Antrian Berikutnya
</v-btn>
<div v-if="nextQueueInfo" class="next-queue-info mt-3">
<div class="info-text">{{ nextQueueInfo }}</div>
</div>
<div v-else-if="!hasNextQueue" class="no-next-queue mt-3">
<div class="info-text">Tidak ada antrian di loket</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
patient: {
type: Object,
default: null
},
theme: {
type: String,
default: 'primary', // 'primary', 'secondary', or 'accent'
validator: (value) => ['primary', 'secondary', 'accent'].includes(value)
},
changeButtonText: {
type: String,
default: 'Ubah Klinik'
},
hasNextQueue: {
type: Boolean,
default: false
},
nextQueueInfo: {
type: String,
default: null
}
});
const isFastTrack = computed(() => {
if (!props.patient) return false;
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
return fastTrack === 'YA';
});
defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-ruang', 'open-penunjang']);
</script>
<style scoped lang="scss">
.current-patient-card {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.call-button {
text-transform: none;
font-weight: 600;
font-size: 14px;
line-height: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.call-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.call-button:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.patient-details {
border-radius: 8px;
padding: 20px;
}
.patient-details-primary {
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-primary-100) 100%);
}
.patient-details-secondary {
background: linear-gradient(135deg, var(--color-secondary-200) 0%, var(--color-secondary-300) 100%);
}
.patient-details-accent {
background: linear-gradient(135deg, var(--color-accent-200) 0%, var(--color-accent-300) 100%);
}
.patient-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 12px;
}
.patient-number-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.patient-number {
font-size: 32px;
line-height: 36px;
font-weight: 700;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--color-neutral-900);
}
.fast-track-icon-wrapper {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 83, 80, 0.15);
border: 2px solid rgba(229, 57, 53, 0.4);
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
}
.fast-track-icon-wrapper:hover {
background: #E53935;
border-color: #E53935;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(229, 57, 53, 0.4);
}
.fast-track-icon-wrapper .fast-track-icon {
color: #E53935 !important;
transition: all 0.2s ease;
}
.fast-track-icon-wrapper:hover .fast-track-icon {
color: white !important;
}
.action-icons {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.action-icon-btn {
min-width: auto !important;
width: auto !important;
height: 32px !important;
padding: 0 12px !important;
border-radius: 16px !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
text-transform: none !important;
display: flex !important;
align-items: center !important;
gap: 4px;
}
.btn-label {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.action-icon-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.18);
}
.action-icon-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.patient-info-text {
font-size: 14px;
line-height: 24px;
color: var(--color-neutral-700);
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.patient-barcode {
font-size: 14px;
line-height: 20px;
color: var(--color-neutral-700);
font-weight: 400;
}
.patient-klinik-pembayaran {
font-size: 15px;
line-height: 22px;
color: var(--color-neutral-800);
font-weight: 600;
}
.patient-reference {
font-size: 12px;
line-height: 18px;
color: var(--color-neutral-700);
}
.reference-label {
font-weight: 600;
color: var(--color-neutral-700);
}
.reference-value {
font-weight: 700;
font-size: 13px;
}
.create-queue-buttons {
margin-top: 0;
}
.create-buttons-container {
display: flex;
gap: 16px;
width: 100%;
}
.create-queue-buttons .v-btn {
flex: 1;
text-transform: none;
font-weight: 600;
font-size: 15px;
line-height: 22px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
border-radius: 8px;
justify-content: center !important;
text-align: center;
display: flex !important;
align-items: center !important;
}
.create-queue-buttons .v-btn :deep(.v-btn__content) {
justify-content: center !important;
width: 100%;
display: flex !important;
align-items: center !important;
flex: 1;
}
.create-queue-buttons .v-btn :deep(.v-icon) {
flex-shrink: 0;
}
.create-btn-ruang {
background-color: var(--color-primary-600) !important;
color: var(--color-neutral-100) !important;
}
.create-btn-ruang:hover:not(:disabled) {
background-color: var(--color-primary-700) !important;
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(58, 97, 201, 0.25);
}
.create-btn-penunjang {
background-color: var(--color-secondary-600) !important;
color: var(--color-neutral-100) !important;
}
.create-btn-penunjang:hover:not(:disabled) {
background-color: var(--color-secondary-700) !important;
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(241, 111, 41, 0.25);
}
.create-queue-buttons .v-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.create-queue-buttons .v-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.empty-state {
text-align: center;
padding: 32px 16px;
}
.empty-state .v-btn {
text-transform: none;
font-weight: 600;
font-size: 16px;
line-height: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.empty-state .v-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.empty-state .v-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.empty-text {
font-size: 14px;
line-height: 20px;
color: var(--color-neutral-600);
margin-top: 8px;
}
.next-queue-info,
.no-next-queue {
text-align: center;
padding: 8px;
background: var(--color-neutral-200);
border-radius: 8px;
}
.info-text {
font-size: 12px;
line-height: 16px;
color: var(--color-neutral-700);
font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (max-width: 960px) {
.patient-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.action-icons {
width: 100%;
justify-content: flex-start;
gap: 6px;
}
.action-icon-btn {
flex: 1 1 auto;
min-width: 0 !important;
max-width: none !important;
}
}
@media (max-width: 600px) {
.create-buttons-container {
flex-direction: column;
gap: 12px;
}
}
</style>
+357
View File
@@ -0,0 +1,357 @@
<template>
<v-card
class="patient-card"
elevation="2"
:class="{
'clickable-card': isClickable,
'fast-track-card': isFastTrack && !isCurrentlyProcessing,
'processing-card': isCurrentlyProcessing || patient.status === 'diproses'
}"
@click="handleCardClick"
>
<v-tooltip
activator="parent"
:text="isClickable ? 'Klik untuk proses pasien' : ''"
location="top"
:disabled="!isClickable"
/>
<v-card-text class="pa-4">
<!-- Header: Queue Number, Status & Fast Track Badge -->
<div class="card-header">
<div class="header-left">
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<div
v-if="isFastTrack"
class="fast-track-icon-wrapper"
>
<v-icon
color="#E53935"
size="14"
class="fast-track-icon"
>
mdi-flash
</v-icon>
</div>
</div>
<div class="header-right d-flex align-center" style="gap: 8px;">
<v-chip
:color="getStatusColor(patient.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(patient.status) }}
</v-chip>
</div>
</div>
<!-- Patient Info - Compact Single Row -->
<div class="patient-info mt-3">
<div class="info-chips-row">
<v-chip
size="small"
variant="outlined"
class="klinik-chip"
>
{{ patient.klinik }}
</v-chip>
<!-- Sub Spesialis -->
<v-chip
v-if="patient.ruang"
color="primary-600"
variant="outlined"
size="small"
class="subspesialis-chip font-weight-bold"
style="height: 24px; font-size: 11px;"
>
{{ patient.ruang }}
</v-chip>
<!-- Tipe Layanan atau Jenis Pasien -->
<v-chip
v-if="displayJenisPasien"
size="small"
:color="getJenisPasienColor(displayJenisPasien)"
:variant="getJenisPasienVariant(displayJenisPasien)"
:class="getJenisPasienClass(displayJenisPasien)"
>
{{ displayJenisPasien }}
</v-chip>
</div>
<!-- Nama Dokter untuk Pasien Eksekutif -->
<div v-if="isEksekutif && patient.namaDokter" class="doctor-info mt-2">
<v-icon size="16" color="primary" class="doctor-icon">mdi-doctor</v-icon>
<span class="doctor-name">{{ patient.namaDokter }}</span>
</div>
</div>
<div v-if="!isClickable && patient.status === 'terlambat'" class="card-actions mt-3">
<v-btn
block
color="success-600"
variant="flat"
size="small"
class="text-white"
@click.stop="$emit('action', patient, 'aktifkan')"
>
<v-icon start size="18">mdi-check-circle</v-icon>
Aktifkan
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
import { useQueueStore } from '~/stores/queueStore';
const queueStore = useQueueStore();
const props = defineProps({
patient: {
type: Object,
required: true
}
});
const emit = defineEmits(['action']);
const isClickable = computed(() => {
return props.patient.status === 'di-loket' || props.patient.status === 'pending';
});
const isFastTrack = computed(() => {
const fastTrack = (props.patient.fastTrack ?? "").toString().trim().toUpperCase();
return fastTrack === 'YA';
});
const isCurrentlyProcessing = computed(() => {
const currentPatient = queueStore.currentProcessingPatient?.loket;
if (!currentPatient || !props.patient) return false;
return currentPatient.no === props.patient.no ||
currentPatient.barcode === props.patient.barcode;
});
const isEksekutif = computed(() => {
const pembayaran = (props.patient.pembayaran ?? "").toString().trim().toUpperCase();
return pembayaran === 'EKSEKUTIF';
});
// Computed untuk menentukan jenis pasien yang ditampilkan
const displayJenisPasien = computed(() => {
// Prioritas 1: Tipe Layanan jika ada
if (props.patient.tipeLayanan) {
return props.patient.tipeLayanan;
}
// Prioritas 2: Eksekutif jika pasien eksekutif
if (isEksekutif.value) {
return 'Eksekutif';
}
// Prioritas 3: Tampilkan pembayaran sebagai fallback
if (props.patient.pembayaran) {
return props.patient.pembayaran;
}
return null;
});
// Fungsi untuk menentukan color chip berdasarkan jenis pasien
const getJenisPasienColor = (jenis) => {
if (jenis === 'Pemeriksaan Awal') return 'primary';
if (jenis === 'Tindakan') return 'secondary';
if (jenis === 'Eksekutif') return 'warning';
if (jenis === 'Fast Track') return 'warning';
// Untuk pembayaran biasa (BPJS, UMUM), gunakan neutral-600 untuk kontras
return 'success-500';
};
// Fungsi untuk menentukan variant chip berdasarkan jenis pasien
const getJenisPasienVariant = (jenis) => {
// Semua jenis pasien menggunakan flat (filled) untuk kontras dengan klinik (outlined)
return 'flat';
};
// Fungsi untuk menentukan class chip berdasarkan jenis pasien
const getJenisPasienClass = (jenis) => {
if (jenis === 'Pemeriksaan Awal' || jenis === 'Tindakan' || jenis === 'Eksekutif' || jenis === 'Fast Track') {
return 'text-white layanan-chip';
}
// Untuk pembayaran biasa (BPJS, UMUM), gunakan text putih untuk kontras dengan background
return 'text-white layanan-chip';
};
const handleCardClick = () => {
if (isClickable.value) {
emit('action', props.patient, 'proses');
}
};
const getStatusColor = (status) => {
const colors = {
'di-loket': "var(--color-primary-600)", // Blue
diproses: "var(--color-primary-600)", // Keep as Blue
terlambat: "var(--color-danger-700)", // Darker Red
pending: "var(--color-secondary-600)" // Orange
};
return colors[status] || "var(--color-neutral-600)";
};
const getStatusLabel = (status) => {
const labels = {
'di-loket': "Di Loket",
diproses: "Diproses",
terlambat: "Terlambat",
pending: "Pending"
};
return labels[status] || status;
};
</script>
<style scoped lang="scss">
.patient-card {
position: relative;
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
transition: all 0.2s ease;
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.clickable-card {
cursor: pointer;
&:hover {
border-color: var(--color-primary-600);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&:active {
transform: translateY(0);
}
}
&.fast-track-card {
background: #FBE9E8 !important;
border-color: rgba(255, 132, 65, 0.5);
&:hover {
background: #F1B3AE !important;
border-color: rgba(255, 132, 65, 0.6);
}
}
&.processing-card {
background: var(--color-primary-100) !important;
border-color: var(--color-primary-400);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2) !important;
&:hover {
background: var(--color-primary-200) !important;
border-color: var(--color-primary-500);
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.25) !important;
}
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 2px solid var(--color-neutral-400);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.queue-number {
font-size: 20px;
font-weight: 700;
color: var(--color-neutral-900);
}
.fast-track-icon-wrapper {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 83, 80, 0.15);
border: 2px solid rgba(229, 57, 53, 0.4);
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
}
.fast-track-icon-wrapper .fast-track-icon {
color: #E53935 !important;
transition: all 0.2s ease;
}
.status-chip {
font-weight: 600;
font-size: 11px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.patient-info {
display: flex;
flex-direction: column;
/* Tinggi dinamis berdasarkan konten - card tanpa dokter akan lebih compact */
}
.info-chips-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
height: 24px;
border-color: var(--color-neutral-600) !important;
color: var(--color-neutral-900) !important;
background-color: transparent !important;
}
.layanan-chip {
font-size: 11px;
font-weight: 600;
height: 24px;
}
.doctor-info {
display: flex;
align-items: center;
gap: 6px;
}
.doctor-icon {
flex-shrink: 0;
}
.doctor-name {
font-size: 12px;
font-weight: 600;
color: var(--color-primary-600);
}
.card-actions .v-btn {
text-transform: none;
font-weight: 600;
font-size: 13px;
height: 36px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>
@@ -0,0 +1,227 @@
<template>
<v-card class="queue-actions-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">PANGGIL ANTREAN</div>
<div class="quota-info mb-3">
<div class="quota-item">
<span class="quota-label">Kuota</span>
<span class="quota-value">{{ totalQuota }}</span>
</div>
<div class="quota-item">
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ availableQuota }}</span>
</div>
<div class="quota-item">
<span class="quota-label">Menunggu</span>
<span class="quota-value quota-waiting">{{ menungguCount }}</span>
</div>
<div class="quota-item full-width">
<v-progress-linear
:model-value="quotaPercentage"
color="success-600"
height="6"
rounded
class="mt-1"
/>
<span class="quota-used">Selesai: {{ usedQuota }}</span>
<span class="quota-callable">Bisa dipanggil: {{ callableQuota }}</span>
</div>
</div>
<div class="call-buttons">
<v-btn
color="success-600"
variant="outlined"
@click="$emit('call', 1)"
>
1
</v-btn>
<v-btn color="secondary-600" variant="outlined" @click="$emit('call', 5)">5</v-btn>
<v-btn color="primary-600" variant="outlined" @click="$emit('call', 10)">10</v-btn>
<v-btn color="danger-600" variant="outlined" @click="$emit('call', 20)">20</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
totalQuota: {
type: Number,
default: 150
},
usedQuota: {
type: Number,
default: 0
},
menungguCount: {
type: Number,
default: 0
},
hasNext: {
type: Boolean,
default: false
}
});
const availableQuota = computed(() => props.totalQuota - props.usedQuota);
const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100);
// Kuota yang bisa dipanggil = Tersedia - Menunggu
const callableQuota = computed(() => {
const callable = availableQuota.value - props.menungguCount;
return Math.max(0, callable);
});
defineEmits(['call']);
</script>
<style scoped lang="scss">
.queue-actions-card {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
box-sizing: border-box !important;
display: block !important;
}
.queue-actions-card :deep(.v-card-text) {
padding: 16px !important;
box-sizing: border-box !important;
width: 100% !important;
max-width: 100% !important;
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.quota-info {
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
gap: 6px !important;
margin-bottom: 12px !important;
width: 100% !important;
box-sizing: border-box !important;
grid-auto-flow: row !important;
grid-auto-rows: auto !important;
}
.quota-item {
background: var(--color-neutral-300) !important;
border-radius: 8px !important;
padding: 10px 6px !important;
text-align: center !important;
min-width: 0 !important;
max-width: 100% !important;
width: 100% !important;
box-sizing: border-box !important;
overflow: hidden !important;
word-wrap: break-word !important;
display: flex !important;
flex-direction: column !important;
justify-content: center !important;
align-items: center !important;
flex: 1 1 0% !important;
}
.quota-item.full-width {
grid-column: 1 / 4 !important;
margin-top: 8px !important;
}
.quota-label {
display: block;
font-size: 11px;
color: var(--color-neutral-600);
margin-bottom: 4px;
font-weight: 600;
}
.quota-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--color-neutral-900);
line-height: 1.2;
white-space: nowrap;
}
.quota-available {
color: var(--color-success-600);
}
.quota-waiting {
color: var(--color-warning-600);
}
.quota-callable {
display: block;
font-size: 11px;
color: var(--color-neutral-900);
margin-top: 4px;
font-weight: 600;
}
.quota-used {
display: block;
font-size: 11px;
color: var(--color-neutral-600);
margin-top: 4px;
}
.call-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.call-buttons .v-btn {
font-size: 16px;
line-height: 24px;
font-weight: 700;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 44px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.call-buttons .v-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.call-buttons .v-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
@media (max-width: 960px) {
.call-buttons {
grid-template-columns: repeat(2, 1fr);
}
.quota-info {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.quota-item {
padding: 8px;
}
.quota-value {
font-size: 20px;
}
}
</style>
@@ -0,0 +1,577 @@
<template>
<v-card class="patient-data-container" elevation="0">
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<!-- Status Filter -->
<v-chip-group v-model="selectedStatusModel" mandatory class="status-filter">
<v-chip
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
:class="{ 'active-chip': selectedStatusModel === status.value }"
>
<v-icon v-if="status.icon" start size="16">{{ status.icon }}</v-icon>
{{ status.label }} ({{ status.count }})
</v-chip>
</v-chip-group>
</div>
<!-- Advanced Filters -->
<div class="advanced-filters mt-3">
<v-select
v-model="selectedKlinik"
:items="klinikOptions"
label="Filter Klinik"
density="compact"
hide-details
clearable
class="filter-select"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-hospital-building</v-icon>
</template>
</v-select>
<v-select
v-model="selectedPembayaran"
:items="pembayaranOptions"
label="Filter Pembayaran"
density="compact"
hide-details
clearable
class="filter-select"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-cash</v-icon>
</template>
</v-select>
<v-select
v-model="selectedShift"
:items="shiftOptions"
label="Filter Shift"
density="compact"
hide-details
clearable
class="filter-select"
variant="outlined"
>
<template #prepend-inner>
<v-icon size="20">mdi-clock-outline</v-icon>
</template>
</v-select>
<v-btn
v-if="hasActiveFilters"
variant="text"
color="primary-600"
size="small"
@click="clearAllFilters"
>
<v-icon start size="18">mdi-filter-off</v-icon>
Reset Filter
</v-btn>
</div>
<!-- Active Filter Tags -->
<div v-if="hasActiveFilters" class="active-filters mt-2">
<v-chip
v-if="selectedKlinik"
size="small"
closable
@click:close="selectedKlinik = null"
>
Klinik: {{ selectedKlinik }}
</v-chip>
<v-chip
v-if="selectedPembayaran"
size="small"
closable
@click:close="selectedPembayaran = null"
>
Pembayaran: {{ selectedPembayaran }}
</v-chip>
<v-chip
v-if="selectedShift"
size="small"
closable
@click:close="selectedShift = null"
>
{{ selectedShift }}
</v-chip>
<v-chip
v-if="selectedFastTrackModel"
size="small"
closable
@click:close="selectedFastTrackModel = null"
>
Fast Track: {{ selectedFastTrackModel }}
</v-chip>
</div>
</div>
<div v-if="filteredAndSearchedItems.length > 0" key="patient-list-wrapper" class="patient-list-wrapper">
<!-- Results Info -->
<div class="results-info mb-3">
<span class="results-text">
Menampilkan {{ paginatedItems.length }} dari {{ filteredAndSearchedItems.length }} pasien
</span>
</div>
<!-- Patient Cards Grid -->
<div class="patient-grid">
<PatientCard
v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`"
:patient="patient"
@action="handleAction"
/>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state" key="patient-empty-state">
<v-icon size="64" color="neutral-500">mdi-account-search</v-icon>
<div class="empty-text mt-3">
{{ searchModel || hasActiveFilters ? 'Tidak ada pasien yang sesuai' : 'Tidak ada data pasien' }}
</div>
<div class="empty-subtext">
{{ searchModel || hasActiveFilters ? 'Coba ubah filter atau kata kunci pencarian' : 'Data akan muncul ketika ada pasien yang terdaftar' }}
</div>
</div>
<!-- Pagination -->
<div v-if="filteredAndSearchedItems.length > itemsPerPage" class="pagination-container mt-4">
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
rounded="circle"
/>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue';
import PatientCard from './PatientCard.vue';
const props = defineProps({
items: {
type: Array,
default: () => []
},
selectedStatus: {
type: String,
default: 'all'
},
searchQuery: {
type: String,
default: ''
},
diLoketCount: {
type: Number,
default: 0
},
diprosesCount: {
type: Number,
default: 0
},
terlambatCount: {
type: Number,
default: 0
},
pendingCount: {
type: Number,
default: 0
},
anjunganCount: {
type: Number,
default: 0
},
showDiproses: {
type: Boolean,
default: true
},
itemsPerPage: {
type: Number,
default: 12
},
statusLabels: {
type: Object,
default: () => ({
all: 'Semua',
diloket: 'Di Loket',
diproses: 'Diproses',
terlambat: 'Terlambat',
pending: 'Pending'
})
},
selectedFastTrack: {
type: String,
default: null
},
fastTrackOptions: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:selectedStatus', 'update:searchQuery', 'update:selectedFastTrack', 'action']);
const currentPage = ref(1);
const selectedKlinik = ref(null);
const selectedPembayaran = ref(null);
const selectedShift = ref(null);
const selectedFastTrackModel = computed({
get: () => props.selectedFastTrack,
set: (value) => {
currentPage.value = 1;
emit('update:selectedFastTrack', value);
}
});
const selectedStatusModel = computed({
get: () => {
// If Fast Track is selected, return 'fasttrack' as status
if (selectedFastTrackModel.value === 'YA') {
return 'fasttrack';
}
return props.selectedStatus;
},
set: (value) => {
currentPage.value = 1;
// Handle Fast Track selection
if (value === 'fasttrack') {
selectedFastTrackModel.value = 'YA';
// Emit 'all' as status since Fast Track is a separate filter
emit('update:selectedStatus', 'all');
return;
}
// Reset Fast Track when other status is selected
if (selectedFastTrackModel.value) {
selectedFastTrackModel.value = null;
}
emit('update:selectedStatus', value);
}
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => {
currentPage.value = 1;
emit('update:searchQuery', value);
}
});
const statusOptions = computed(() => {
const baseOptions = [
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
{ value: 'di-loket', label: props.statusLabels.diloket, count: props.diLoketCount }
];
// Tampilkan "Diproses" hanya jika:
// - label-nya didefinisikan, DAN
// - komponen mengizinkan (showDiproses = true)
if (props.showDiproses && props.statusLabels.diproses) {
baseOptions.push({
value: 'diproses',
label: props.statusLabels.diproses,
count: props.diprosesCount
});
}
// Tambahkan kategori "Anjungan" (pasien yang sudah dipanggil tapi belum check-in)
if (props.anjunganCount > 0) {
baseOptions.push({
value: 'anjungan',
label: 'Anjungan',
count: props.anjunganCount,
icon: 'mdi-clock-outline'
});
}
baseOptions.push(
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
);
// Add Fast Track as a status option
if (fastTrackYaCount.value > 0) {
baseOptions.push({
value: 'fasttrack',
label: 'Fast Track',
count: fastTrackYaCount.value,
icon: 'mdi-flash'
});
}
return baseOptions;
});
// Generate filter options from items
const klinikOptions = computed(() => {
const kliniks = [...new Set(props.items.map(p => p.klinik))];
return kliniks.sort();
});
const pembayaranOptions = computed(() => {
const pembayaran = [...new Set(props.items.map(p => p.pembayaran))];
return pembayaran.sort();
});
const shiftOptions = computed(() => {
const shifts = [...new Set(props.items.map(p => p.shift))];
return shifts.sort();
});
// Count Fast Track "YA"
const fastTrackYaCount = computed(() => {
return props.items.filter(p => {
const patientFastTrack = (p.fastTrack ?? "").toString().trim().toUpperCase();
return patientFastTrack === 'YA';
}).length;
});
const hasActiveFilters = computed(() => {
return !!(selectedKlinik.value || selectedPembayaran.value || selectedShift.value || selectedFastTrackModel.value);
});
const clearAllFilters = () => {
selectedKlinik.value = null;
selectedPembayaran.value = null;
selectedShift.value = null;
selectedFastTrackModel.value = null;
currentPage.value = 1;
};
const filteredItems = computed(() => {
// Handle Fast Track as a status category
if (selectedStatusModel.value === 'fasttrack') {
return props.items.filter(p => {
const patientFastTrack = (p.fastTrack ?? "").toString().trim().toUpperCase();
return patientFastTrack === 'YA';
});
}
if (selectedStatusModel.value === 'all') return props.items;
return props.items.filter(p => p.status === selectedStatusModel.value);
});
const filteredAndSearchedItems = computed(() => {
let result = filteredItems.value;
// Apply attribute filters
if (selectedKlinik.value) {
result = result.filter(p => p.klinik === selectedKlinik.value);
}
if (selectedPembayaran.value) {
result = result.filter(p => p.pembayaran === selectedPembayaran.value);
}
if (selectedShift.value) {
result = result.filter(p => p.shift === selectedShift.value);
}
// Fast Track filtering is now handled in filteredItems as a status category
// Apply search
if (searchModel.value) {
const searchLower = searchModel.value.toLowerCase();
result = result.filter(patient =>
patient.barcode?.toLowerCase().includes(searchLower) ||
patient.noAntrian?.toLowerCase().includes(searchLower) ||
patient.klinik?.toLowerCase().includes(searchLower)
);
}
return result;
});
const totalPages = computed(() =>
Math.ceil(filteredAndSearchedItems.value.length / props.itemsPerPage)
);
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * props.itemsPerPage;
const end = start + props.itemsPerPage;
return filteredAndSearchedItems.value.slice(start, end);
});
const handleAction = (patient, action) => {
emit('action', patient, action);
};
</script>
<style scoped lang="scss">
.patient-data-container {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.data-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
min-width: 300px;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
color: var(--color-neutral-600);
transition: all 0.2s ease;
&:hover {
background: var(--color-neutral-300);
}
}
.status-filter .v-chip.active-chip {
background: var(--color-secondary-600);
color: var(--color-neutral-100);
border-color: var(--color-secondary-600);
}
.advanced-filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.filter-select {
min-width: 180px;
max-width: 220px;
}
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.results-info {
padding: 8px 12px;
background: var(--color-neutral-200);
border-radius: 8px;
display: inline-flex;
align-items: center;
border: 1px solid var(--color-neutral-400);
border-left: 3px solid var(--color-primary-600);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.results-text {
font-size: 13px;
font-weight: 600;
color: var(--color-neutral-700);
}
.patient-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-text {
font-size: 16px;
font-weight: 600;
color: var(--color-neutral-700);
}
.empty-subtext {
font-size: 13px;
color: var(--color-neutral-600);
margin-top: 4px;
}
.pagination-container {
display: flex;
justify-content: center;
padding-top: 16px;
border-top: 1px solid var(--color-neutral-400);
}
:deep(.v-pagination__item) {
font-weight: 600;
}
@media (max-width: 960px) {
.filters {
flex-direction: column;
align-items: stretch;
}
.status-filter {
min-width: 100%;
}
.advanced-filters {
flex-direction: column;
}
.filter-select {
width: 100%;
max-width: 100%;
}
.patient-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
@media (min-width: 961px) and (max-width: 1264px) {
.patient-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
@media (min-width: 1265px) {
.patient-grid {
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
}
}
</style>
@@ -0,0 +1,157 @@
<template>
<v-dialog v-model="internalModel" max-width="500px">
<v-card class="dialog-card overflow-hidden rounded-xl" elevation="0">
<!-- Solid Blue Header -->
<v-card-title class="bg-primary-600 text-white py-4 px-6 d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon start icon="mdi-alert-circle-outline" class="mr-2"></v-icon>
<span class="text-h6 font-weight-bold">Konfirmasi Selesai</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="internalModel = false" class="text-white"></v-btn>
</v-card-title>
<v-card-text class="dialog-content-premium pa-6 text-center">
<div class="text-center mb-6 text-uppercase font-weight-bold text-caption text-neutral-600" style="letter-spacing: 1px;">
Tindakan Diperlukan
</div>
<p class="text-body-1 mb-6">
Pasien ini <strong>sudah dibuatkan</strong> tiket antrean ruang atau penunjang, namun statusnya belum diselesaikan di Loket.
</p>
<div class="comparison-container mb-6 mx-auto" style="max-width: 220px; justify-content: center;">
<div class="comparison-item current">
<div class="item-label text-center">PASIEN AKTIF</div>
<div class="item-card">
<div class="item-number text-danger-700">{{ patient?.noAntrian?.split(' |')[0] || '-' }}</div>
<div class="item-status">Menunggu Selesai</div>
</div>
</div>
</div>
<p class="text-body-2 text-neutral-600">
Apakah Anda ingin menyelesaikan pasien ini sekarang dan melanjutkan ke antrean berikutnya?
</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 bg-light justify-center">
<v-btn
class="px-8 font-weight-bold rounded-lg text-none btn-batal"
variant="outlined"
@click="internalModel = false"
size="large"
>
Batal
</v-btn>
<v-btn
class="px-6 font-weight-bold ml-4 rounded-lg text-none btn-confirm"
variant="flat"
@click="confirm"
size="large"
elevation="2"
>
Ya, Selesaikan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
patient: {
type: Object,
default: null
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const internalModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const confirm = () => {
emit('confirm');
internalModel.value = false;
};
</script>
<style scoped>
.dialog-card {
/* No border to prevent white outline */
}
.bg-light {
background-color: var(--color-neutral-50) !important;
}
.comparison-container {
display: flex;
align-items: center;
gap: 12px;
background: white;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-neutral-300);
}
.comparison-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-label {
font-size: 10px;
font-weight: 800;
color: var(--color-neutral-600);
letter-spacing: 0.05em;
}
.item-card {
background: white;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--color-neutral-400);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: center;
}
.item-number {
font-size: 24px;
font-weight: 800;
line-height: 1;
margin-bottom: 4px;
}
.item-status {
font-size: 11px;
color: var(--color-neutral-600);
font-weight: 600;
}
.text-danger-700 {
color: var(--color-danger-700) !important;
}
.btn-batal {
border-color: var(--color-neutral-300) !important;
color: var(--color-neutral-700) !important;
}
.btn-confirm {
background-color: var(--color-primary-600) !important;
color: white !important;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
offset="8"
origin="bottom right"
transition="slide-y-transition"
>
<template v-slot:activator="{ props: menuProps }">
<div
v-bind="menuProps"
class="d-flex align-center cursor-pointer pa-2 rounded-lg hover-bg"
>
<v-avatar size="40">
<v-img
:src="user.picture"
:alt="`${user.name} Profile`"
></v-img>
<v-badge
dot
color="orange"
location="bottom right"
offset-x="2"
offset-y="2"
></v-badge>
</v-avatar>
<div v-show="!rail" class="ml-3 flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">
{{ user.name }}
</div>
<div class="text-caption text-grey">
{{ user.email }}
</div>
</div>
<v-btn v-show="!rail" icon size="small" variant="text">
<v-icon size="20">mdi-dots-vertical</v-icon>
</v-btn>
</div>
</template>
<v-card width="300" elevation="8" rounded="lg">
<v-card-text class="pa-4">
<div class="d-flex align-center mb-4">
<v-avatar size="48">
<v-img :src="user.picture"></v-img>
</v-avatar>
<div class="ml-3">
<div class="text-subtitle-1 font-weight-bold">
{{ user.name }}
</div>
<div class="text-caption text-grey">
{{ user.email }}
</div>
</div>
</div>
<v-list density="compact" class="pa-0">
<v-list-item
prepend-icon="mdi-account-circle"
class="px-2 rounded-lg"
link
@click="handleAction('profile')"
>
<v-list-item-title class="text-body-2">Profil</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-cog-outline"
class="px-2 rounded-lg"
link
@click="handleAction('setting')"
>
<v-list-item-title class="text-body-2">Pengaturan</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
prepend-icon="mdi-logout"
class="px-2 rounded-lg text-red"
link
@click="signOut"
:disabled="isLoggingOut"
>
<v-list-item-title class="text-body-2">
{{ isLoggingOut ? 'Log out...' : 'Log out' }}
</v-list-item-title>
</v-list-item>
</v-list>
<div class="text-caption text-grey mt-3 text-center">
v2.5.18 · Terms & Conditions
</div>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
// --- PROPS & EMITS ---
const props = defineProps({
user: {
type: Object,
// Diubah menjadi required: false dengan default yang aman
required: false,
default: () => ({
name: 'Guest',
email: '[email protected]',
picture: 'https://i.pravatar.cc/150?img=33'
})
},
rail: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['logout']); // Hanya emit logout
// --- STATE ---
const menu = ref(false);
const isLoggingOut = ref(false);
// --- METHODS ---
const signOut = () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
// Memicu event untuk ditangani oleh komponen induk (SideBar)
emit('logout');
// Biarkan parent yang mengurus redirect/state global
setTimeout(() => isLoggingOut.value = false, 1000);
};
const handleAction = (action) => {
console.log(`[PopupSidebar] Action: ${action} triggered.`);
switch(action) {
case 'profile':
navigateTo('/Profile/Profil');
break;
case 'setting':
navigateTo('/Profile/Pengaturan');
break;
}
menu.value = false;
};
</script>
<style scoped>
/* Tambahkan kembali style yang relevan untuk aktivator di sini */
.hover-bg:hover {
background-color: #f5f5f5;
}
.cursor-pointer {
cursor: pointer;
}
.text-red {
color: rgb(244, 67, 54) !important;
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="scale-transition"
open-on-hover
>
<template v-slot:activator="{ props: menuProps }">
<v-list-item
v-if="!rail"
v-bind="menuProps"
:title="user?.name || user?.preferred_username || 'User'"
:subtitle="user?.email || 'No email'"
class="pa-3 ma-1 rounded-xl profile-activator"
link
>
<template v-slot:prepend>
<div class="profile-avatar-container">
<v-avatar size="44" class="profile-avatar elevation-8">
<v-img
:src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</template>
<template v-slot:append>
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
</template>
</v-list-item>
<v-btn
v-else
icon
v-bind="menuProps"
size="large"
variant="flat"
class="avatar-btn"
:title="user?.name || 'Profile'"
>
<div class="profile-avatar-container">
<v-avatar size="44" class="profile-avatar elevation-8">
<v-img
:src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</v-btn>
</template>
<v-card class="profile-card rounded-xl elevation-12" width="340">
<!-- Decorative Header -->
<div class="card-header">
<div class="header-pattern"></div>
<div class="header-content pa-6 text-center">
<div class="profile-avatar-container mb-4">
<v-avatar size="90" class="profile-avatar elevation-8">
<v-img
:src="getAvatarSrc(user?.picture)"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-badge">
<v-icon size="12" color="white">mdi-check</v-icon>
</div>
</div>
<h3 class="text-h6 font-weight-bold text-white mb-1">
{{ user?.name || user?.preferred_username || 'User' }}
</h3>
<p class="text-body-2 text-white opacity-90 mb-2">
{{ user?.email || 'No email' }}
</p>
<v-chip
size="small"
class="glass-chip"
prepend-icon="mdi-identifier"
>
{{ user?.id?.substring(0, 10) }}...
</v-chip>
</div>
</div>
<!-- Menu Items -->
<v-card-text class="pa-4">
<div class="menu-grid">
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('profile')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="primary-600">mdi-account-circle</v-icon>
</div>
<div class="text-caption font-weight-medium">Profil</div>
</div>
</div>
<v-divider class="my-4"></v-divider>
<!-- Logout Button -->
<v-btn
block
class="rounded-lg logout-btn"
variant="flat"
color="error"
prepend-icon="mdi-logout"
@click="signOut"
:disabled="isLoggingOut"
:loading="isLoggingOut"
>
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
import { DEFAULT_AVATAR } from '@/constants/avatar';
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
const menu = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const getAvatarSrc = (userPicture) => {
return userPicture || DEFAULT_AVATAR;
};
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
switch(action) {
case 'profile':
navigateTo('/Profile/Profil')
break;
}
menu.value = false;
};
</script>
<style scoped lang="scss">
@import '@/assets/scss/_colors.scss';
.profile-activator {
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
border: 1px solid $primary-200;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-activator:hover {
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
border-color: $primary-300;
transform: translateY(-2px);
}
.profile-avatar-container {
position: relative;
display: inline-block;
}
.profile-avatar {
border: 3px solid white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: linear-gradient(135deg, $success-600 0%, $success-400 100%);
border: 2px solid white;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
.chevron-icon {
transition: transform 0.3s ease;
}
.profile-activator:hover .chevron-icon {
transform: translateY(2px);
}
.avatar-btn {
background: transparent !important;
}
.profile-card {
overflow: hidden;
border: 1px solid $primary-200;
}
.card-header {
position: relative;
background: linear-gradient(135deg, $primary-700 0%, $primary-500 100%);
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.header-content {
position: relative;
z-index: 1;
}
.status-badge {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
background: linear-gradient(135deg, $success-600 0%, $success-400 100%);
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.menu-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.menu-tile {
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
border: 1px solid $primary-200;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.menu-tile:hover {
background: linear-gradient(135deg, $primary-200 0%, $primary-100 100%);
border-color: $primary-300;
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.tile-icon-wrapper {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.action-item {
transition: all 0.2s ease;
background: transparent;
}
.action-item:hover {
background: linear-gradient(135deg, $primary-100 0%, $primary-50 100%);
transform: translateX(4px);
}
.logout-btn {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(222, 71, 59, 0.3);
}
.logout-btn:hover {
box-shadow: 0 6px 20px rgba(222, 71, 59, 0.4);
}
.cursor-pointer {
cursor: pointer;
}
.opacity-90 {
opacity: 0.9;
}
</style>
+596
View File
@@ -0,0 +1,596 @@
<template>
<v-navigation-drawer
:model-value="drawer"
:rail="rail"
permanent
app
class="bg-white d-flex flex-column"
@update:model-value="emit('update:drawer', $event)"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<v-list-item class="py-4 px-3 sidebar-logo-container" :class="{ 'text-center': rail }">
<NuxtLink to="/dashboard" class="sidebar-logo-link">
<div class="d-flex align-center sidebar-logo-wrapper" :class="{ 'justify-center': rail }">
<div class="sidebar-logo-image-wrapper">
<img
src="/love logo biru small.png"
alt="Antrean Logo"
class="sidebar-logo"
:class="{ 'sidebar-logo-rail': rail }"
width="32"
height="32"
style="width: 32px; height: 32px; object-fit: contain;"
/>
</div>
<v-list-item-title
v-if="!rail"
class="ml-2 text-h6 font-weight-bold sidebar-title"
>
<span class="sidebar-title-accent">Antrean</span> RSSA
</v-list-item-title>
</div>
</NuxtLink>
</v-list-item>
<v-divider></v-divider>
<v-list
density="compact"
nav
class="mt-2 flex-grow-1"
v-model:opened="openedGroups"
>
<template v-for="item in items" :key="item.name">
<!-- Popup Menu ketika locked dan rail -->
<v-menu
v-if="item.children && rail && isLocked"
location="end"
offset="8"
>
<template #activator="{ props: menuProps }">
<v-tooltip
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="{ ...menuProps, ...tooltipProps }"
:prepend-icon="item.icon"
:title="item.name"
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
</template>
<v-list density="compact" class="sidebar-popup-menu">
<v-tooltip
v-for="child in item.children"
:key="child.name"
open-on-hover
location="end"
:text="child.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="tooltipProps"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
@click="closePopup"
></v-list-item>
</template>
</v-tooltip>
</v-list>
</v-menu>
<!-- Dropdown normal ketika tidak locked atau expanded -->
<v-list-group
v-else-if="item.children"
:value="item.name"
:disabled="rail"
>
<template v-slot:activator="{ props: groupProps }">
<v-list-item
v-bind="groupProps"
:prepend-icon="item.icon"
:title="item.name"
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
<v-tooltip
v-for="child in item.children"
:key="child.name"
open-on-hover
location="end"
:text="child.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="tooltipProps"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="pl-8 sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
</v-list-group>
<v-tooltip
v-else
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="tooltipProps"
:prepend-icon="item.icon"
:title="item.name"
:to="item.path"
link
class="sidebar-nav-item"
active-class="sidebar-nav-item-active"
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
<v-divider></v-divider>
<!-- Bottom section wrapper for profile and lock button -->
<div class="sidebar-bottom-section">
<v-list v-if="user" nav density="compact" class="pa-2">
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
</v-list>
<v-list v-else nav density="compact">
<v-list-item
@click="redirectToLogin"
prepend-icon="mdi-login"
title="Login"
link
class="sidebar-login-item"
></v-list-item>
</v-list>
<!-- Custom Lock Sidebar Toggle (Lock-to-Rail) -->
<div class="sidebar-lock-wrapper" :class="{ 'sidebar-lock-rail': rail }">
<v-tooltip
open-on-hover
location="end"
:text="isLocked ? 'Aktifkan Hover' : 'Matikan Hover'"
>
<template #activator="{ props: tooltipProps }">
<div
v-bind="tooltipProps"
class="custom-dock-lock"
:class="{ 'is-locked': isLocked }"
@click="handleLockToggle(!isLocked)"
>
<div class="dock-lock-icon-box">
<v-icon size="20">
{{ isLocked ? 'mdi-dock-right' : 'mdi-dock-left' }}
</v-icon>
</div>
<div v-if="!rail" class="dock-lock-details">
<span class="dock-lock-label">Sidebar Auto-Expand</span>
<span class="dock-lock-status">{{ isLocked ? 'Nonaktif' : 'Aktif' }}</span>
</div>
</div>
</template>
</v-tooltip>
</div>
</div>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { navigateTo } from "#app";
import { useLocalStorage } from "@vueuse/core";
import ProfilePopup from "./ProfilePopup.vue";
import { useAuth } from "~/composables/useAuth";
const { user, logout, checkAuth } = useAuth();
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
const props = defineProps({
items: {
type: Array as () => NavItem[],
required: true,
},
rail: {
type: Boolean,
required: true,
},
drawer: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:drawer", "toggle-rail"]);
const openedGroups = ref<string[]>([]);
watch(
() => props.rail,
(newRailState) => {
if (newRailState === true) {
openedGroups.value = [];
}
}
);
const isHovering = ref(false);
const isLocked = useLocalStorage("sidebar-locked", false);
// Reset hover state when locked
watch(isLocked, (val) => {
if (val) isHovering.value = false;
});
const handleMouseEnter = () => {
if (props.rail && !isLocked.value) {
isHovering.value = true;
emit("toggle-rail");
}
};
const handleMouseLeave = () => {
if (isHovering.value) {
isHovering.value = false;
// Only toggle rail if not locked
if (!isLocked.value) {
emit("toggle-rail");
}
}
};
const handleLockToggle = (value?: boolean | null) => {
const lockValue = value ?? !isLocked.value;
isLocked.value = lockValue;
// Cleanup hovering state
isHovering.value = false;
// Jika di-lock, pastikan sidebar kembali ke rail mode
if (lockValue && !props.rail) {
emit("toggle-rail");
}
};
const closePopup = () => {
// Popup akan otomatis tertutup saat item diklik
};
// --- AUTH LOGIC (Kept the same) ---
const handleLogout = async () => {
console.log("🚪 SideBar logout initiated...");
try {
await logout();
} catch (error) {
console.error("❌ SideBar logout error:", error);
}
};
const redirectToLogin = () => {
navigateTo("/LoginPage");
};
onMounted(async () => {
await checkAuth();
});
</script>
<style lang="scss" scoped>
@use '~/assets/scss/_colors.scss' as *;
:deep(.v-navigation-drawer) {
position: fixed !important;
top: 0 !important;
left: 0 !important;
height: 100vh !important;
z-index: 1000 !important;
}
.sidebar-bottom-section {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-logo-container {
overflow: visible !important;
height: 80px !important;
:deep(.v-list-item__content) {
overflow: visible !important;
}
&.text-center {
display: flex;
justify-content: center;
align-items: center;
padding-left: 8px !important;
padding-right: 8px !important;
}
}
.sidebar-logo-link {
text-decoration: none;
color: inherit;
display: block;
width: 100%;
cursor: pointer;
transition: opacity 0.2s ease;
overflow: visible;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.6;
}
}
.sidebar-logo-wrapper {
width: 100%;
overflow: visible;
&.justify-center {
justify-content: center;
width: 100%;
margin: 0 auto;
}
}
.sidebar-logo-image-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 2px;
overflow: visible;
}
.sidebar-logo {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
object-fit: contain;
display: block;
cursor: pointer;
transition: transform 0.2s ease;
flex-shrink: 0;
&:hover {
transform: scale(1.05);
}
}
.sidebar-logo-rail {
margin: 0 auto;
width: auto;
max-width: none;
}
.sidebar-title {
color: var(--color-neutral-800);
}
.sidebar-title-accent {
color: var(--color-primary-600);
}
.sidebar-nav-item {
color: var(--color-neutral-700);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-neutral-600);
}
}
&:hover {
background-color: var(--color-neutral-300);
color: var(--color-primary-600);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-primary-600);
}
}
}
}
.sidebar-nav-item-active {
background-color: var(--color-primary-50) !important;
color: var(--color-primary-600) !important;
font-weight: bold;
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-primary-600) !important;
}
}
}
.sidebar-login-item {
color: var(--color-neutral-800);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-neutral-700);
}
}
&:hover {
background-color: var(--color-neutral-300);
color: var(--color-primary-600);
:deep(.v-list-item__prepend) {
.v-icon {
color: var(--color-primary-600);
}
}
}
}
.sidebar-lock-wrapper {
padding: 12px;
display: flex;
justify-content: flex-start;
align-items: center;
transition: all 0.3s ease;
border-top: 1px solid var(--color-neutral-300);
&.sidebar-lock-rail {
padding: 12px 6px;
justify-content: center;
border-top: none;
}
}
.custom-dock-lock {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 8px;
border-radius: 12px;
transition: all 0.2s ease;
user-select: none;
width: 100%;
border: 1px solid transparent;
&:hover {
background-color: var(--color-neutral-300);
}
&.is-locked {
background-color: var(--color-danger-100);
border-color: var(--color-danger-200);
.dock-lock-icon-box {
color: var(--color-danger-600);
}
.dock-lock-status {
color: var(--color-danger-600);
}
&:hover {
background-color: var(--color-danger-200);
}
}
}
.dock-lock-icon-box {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-600);
transition: all 0.3s ease;
flex-shrink: 0;
}
.dock-lock-details {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.dock-lock-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
}
.dock-lock-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-primary-600);
}
.sidebar-lock-rail {
.custom-dock-lock {
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
border-radius: 10px;
}
.dock-lock-details {
display: none;
}
}
.sidebar-popup-menu {
min-width: 200px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: var(--color-neutral-100);
:deep(.v-list-item) {
min-height: 40px;
padding-left: 16px;
padding-right: 16px;
&.sidebar-nav-item {
color: var(--color-neutral-700);
.v-list-item__prepend {
.v-icon {
color: var(--color-neutral-600);
}
}
&:hover {
background-color: var(--color-neutral-300);
color: var(--color-primary-600);
.v-list-item__prepend {
.v-icon {
color: var(--color-primary-600);
}
}
}
&.sidebar-nav-item-active {
background-color: var(--color-primary-50) !important;
color: var(--color-primary-600) !important;
font-weight: bold;
.v-list-item__prepend {
.v-icon {
color: var(--color-primary-600) !important;
}
}
}
}
}
}
</style>
+541
View File
@@ -0,0 +1,541 @@
<template>
<div class="loket-preview-container">
<!-- Loading State -->
<div v-if="isLoading" class="loading-overlay">
<v-progress-circular indeterminate color="primary" size="60"></v-progress-circular>
<p class="mt-4">Memuat data loket...</p>
</div>
<!-- Header -->
<div v-else class="preview-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="40" color="primary">mdi-hospital-box</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN LOKET</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
<p v-if="loketData" class="loket-info">{{ loketData.namaLoket }}</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Main Grid - Display Klinik -->
<div v-if="!isLoading" class="kliniks-grid">
<div
v-for="klinik in displayedClinics"
:key="klinik.name"
class="klinik-box"
>
<!-- Klinik Header -->
<div class="klinik-header-bar">
<span class="klinik-title">{{ klinik.name }}</span>
<v-chip size="small" class="klinik-count">
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
<span class="count-text">{{ klinik.totalQueues }}</span>
</v-chip>
</div>
<!-- Queue Content -->
<div class="klinik-content">
<!-- Current Serving Queue (Large) -->
<div class="current-serving-section">
<div class="current-label">SEDANG DILAYANI</div>
<div
v-if="klinik.currentQueue"
class="current-number-large"
>
{{ klinik.currentQueue.noAntrian.split(' |')[0] }}
</div>
<div v-else class="current-waiting-text">
MENUNGGU PANGGILAN
</div>
</div>
<!-- All Queues Grid (Small) -->
<div
v-if="klinik.allQueues && klinik.allQueues.length > 0"
class="all-queues-grid"
:style="getGridStyle(klinik.allQueues.length)"
>
<div
v-for="queue in klinik.allQueues"
:key="`queue-${queue.no}`"
class="queue-grid-item"
:class="{
'is-current': queue.no === klinik.currentQueue?.no,
}"
>
{{ queue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Empty State -->
<div v-if="!klinik.currentQueue && (!klinik.allQueues || klinik.allQueues.length === 0)" class="empty-state">
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useLoketStore } from '@/stores/loketStore'
const props = defineProps({
loketId: {
type: Number,
required: true
}
})
const loketStore = useLoketStore()
const currentTime = ref('')
const currentDate = ref('')
const isLoading = ref(true)
let timeInterval = null
// Get loket data
const loketData = computed(() => {
return loketStore.getLoketById(props.loketId) || null
})
// Mock queue data untuk preview
const mockQueues = ref([
{ no: 1, noAntrian: 'RT001', klinik: 'R. TINDAKAN', status: 'current' },
{ no: 2, noAntrian: 'RT002', klinik: 'R. TINDAKAN', status: 'anjungan' },
{ no: 3, noAntrian: 'RD001', klinik: 'RADIOTERAPI', status: 'anjungan' },
{ no: 4, noAntrian: 'RM001', klinik: 'REHAB MEDIK', status: 'anjungan' },
])
// Simulasi klinik dengan queue
const displayedClinics = computed(() => {
// Gunakan pelayanan dari loketData jika available, atau gunakan contoh default
const pelayanan = loketData.value?.pelayanan || ['RT', 'RD', 'RM']
// Map pelayanan ke nama klinik untuk preview
const klinikNames = {
'RT': 'R. TINDAKAN',
'RD': 'RADIOTERAPI',
'RM': 'REHAB MEDIK',
'RA': 'RADIOTERAPI',
'IP': 'RAWAT INAP',
'RJ': 'RAWAT JALAN',
}
const clinicsMap = new Map()
// Create mock clinic entries berdasarkan pelayanan
pelayanan.forEach(kode => {
const klinikName = klinikNames[kode] || kode
if (!clinicsMap.has(klinikName)) {
clinicsMap.set(klinikName, {
name: klinikName,
code: kode,
allQueues: [],
currentQueue: null,
totalQueues: 0
})
}
})
// Distribute mock queues
mockQueues.value.forEach(queue => {
const klinikEntry = clinicsMap.get(queue.klinik)
if (klinikEntry) {
klinikEntry.allQueues.push(queue)
if (queue.status === 'current') {
klinikEntry.currentQueue = queue
}
}
})
// Calculate totals
clinicsMap.forEach(clinic => {
clinic.totalQueues = clinic.allQueues.length
})
return Array.from(clinicsMap.values())
})
const getGridStyle = (queueCount) => {
if (queueCount <= 3) return { gridTemplateColumns: `repeat(${queueCount}, 1fr)` }
if (queueCount <= 6) return { gridTemplateColumns: 'repeat(3, 1fr)' }
return { gridTemplateColumns: 'repeat(4, 1fr)' }
}
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(async () => {
try {
// Fetch loket data dari API terlebih dahulu
console.log('📊 [LoketPreview] Fetching loket data for ID:', props.loketId);
const result = await loketStore.fetchLoketFromAPI(true)
if (result.success) {
console.log('✅ [LoketPreview] Loket data fetched successfully:', result.message);
} else {
console.warn('⚠️ [LoketPreview] Failed to fetch loket data:', result.message);
}
} catch (error) {
console.error('❌ [LoketPreview] Error fetching loket data:', error);
} finally {
isLoading.value = false
// Start timer setelah data di-fetch
updateTime()
timeInterval = setInterval(updateTime, 1000)
}
})
</script>
<style scoped lang="scss">
// Color variables
$primary-600: #3A61C9;
$primary-700: #3556AE;
$primary-50: #F0F4FF;
$primary-200: #9AB2F1;
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$warning-600: #FF9800;
$warning-700: #F57C00;
$warning-100: #FFF3E0;
.loket-preview-container {
background: $neutral-300;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100%;
position: relative;
}
/* ========== LOADING STATE ========== */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
border-radius: 8px;
backdrop-filter: blur(5px);
}
.loading-overlay p {
color: $neutral-700;
font-size: 16px;
font-weight: 500;
}
/* ========== HEADER ========== */
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 16px;
padding: 24px 40px;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.95);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: $neutral-100;
margin: 0;
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.display-subtitle {
font-size: 20px;
font-weight: 600;
color: $neutral-100;
margin: 6px 0 0 0;
opacity: 0.95;
}
.loket-info {
font-size: 16px;
font-weight: 500;
color: $neutral-100;
margin: 4px 0 0 0;
opacity: 0.85;
}
.header-right {
text-align: right;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
font-weight: 900;
color: $neutral-100;
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.date-small {
font-size: 18px;
color: $neutral-100;
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== KLINIKS GRID ========== */
.kliniks-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
flex: 1;
}
.klinik-box {
background: $neutral-100;
border: 2px solid $primary-200;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
min-height: 320px;
display: flex;
flex-direction: column;
}
.klinik-header-bar {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
padding: 18px 22px;
display: flex;
justify-content: space-between;
align-items: center;
}
.klinik-title {
font-size: 24px;
font-weight: 800;
color: $neutral-100;
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
flex: 1;
}
.klinik-count {
background: rgba(255, 255, 255, 0.2) !important;
color: $neutral-100 !important;
font-weight: 700;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px !important;
}
.count-text {
font-size: 18px;
font-weight: 800;
}
.klinik-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 22px;
background: $primary-50;
gap: 12px;
}
/* Current Serving Section */
.current-serving-section {
text-align: center;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: $primary-600;
margin-bottom: 12px;
letter-spacing: 1px;
text-transform: uppercase;
}
.current-number-large {
font-size: 80px;
font-weight: 900;
color: $neutral-900;
letter-spacing: 4px;
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.current-waiting-text {
font-size: 32px;
font-weight: 700;
color: $neutral-600;
letter-spacing: 1px;
padding: 40px 0;
opacity: 0.7;
}
/* All Queues Grid */
.all-queues-grid {
display: grid;
gap: 10px;
margin-top: 12px;
}
.queue-grid-item {
background: $neutral-100;
border: 2px solid $neutral-600;
border-radius: 8px;
padding: 10px 8px;
text-align: center;
font-size: 16px;
font-weight: 700;
color: $neutral-700;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&.is-current {
background: $warning-100;
border: 3px solid $warning-600;
color: $warning-700;
font-weight: 800;
box-shadow: 0 2px 6px rgba(255, 152, 0, 0.3);
}
}
.empty-state {
text-align: center;
padding: 32px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 16px;
color: $neutral-600;
margin-top: 12px;
font-weight: 600;
}
/* Responsive */
@media (max-width: 1366px) {
.hospital-name {
font-size: 36px;
}
.time-large {
font-size: 48px;
}
.klinik-title {
font-size: 20px;
}
.current-number-large {
font-size: 72px;
}
.current-waiting-text {
font-size: 24px;
padding: 30px 0;
}
}
@media (max-width: 768px) {
.loket-preview-container {
padding: 16px;
}
.preview-header {
flex-direction: column;
text-align: center;
gap: 16px;
}
.header-left {
flex-direction: column;
}
.header-right {
width: 100%;
}
.kliniks-grid {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.current-number-large {
font-size: 60px;
}
}
</style>
@@ -0,0 +1,365 @@
<template>
<v-card-text class="pa-0 bg-white">
<v-table class="patient-table">
<!-- Table Body -->
<tbody>
<tr
v-for="patient in patients"
:key="patient.rm"
class="table-row"
:class="{ 'verified-row': patient.status === 'Terverifikasi' }"
>
<!-- Status Avatar -->
<td class="table-cell status-cell">
<div class="status-wrapper">
<v-avatar
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
size="48"
class="patient-avatar"
>
<v-icon size="24" color="white">
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
</v-icon>
</v-avatar>
</div>
</td>
<!-- Nama -->
<td class="table-cell">
<span class="patient-name">{{ patient.nama }}</span>
</td>
<!-- RM -->
<td class="table-cell">
<v-chip size="small" color="primary-200" class="chip-rm" variant="flat">
<v-icon start size="14" color="secondary-600">mdi-file-document</v-icon>
<span class="chip-rm-text">{{ patient.rm }}</span>
</v-chip>
</td>
<!-- Alamat -->
<td class="table-cell">
<div class="info-item">
<v-icon size="14" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
<span>{{ patient.alamat }}</span>
</div>
</td>
<!-- Nomor Telepon -->
<td class="table-cell">
<div class="info-item">
<v-icon size="14" class="mr-2" color="neutral-600">mdi-phone</v-icon>
<span>{{ patient.telepon || 'Belum diisi' }}</span>
</div>
</td>
<!-- Aksi -->
<td class="table-cell action-col">
<div class="action-wrapper">
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
variant="flat"
class="action-btn"
@click="$emit('verify', patient)"
>
<v-icon start size="16">mdi-qrcode-scan</v-icon>
VERIFIKASI
</v-btn>
<v-btn
v-else
color="secondary-600"
variant="flat"
class="verified-btn"
disabled
>
<v-icon start size="16">mdi-shield-check</v-icon>
VERIFIED
</v-btn>
</div>
</td>
</tr>
<!-- Empty State -->
<tr v-if="patients.length === 0">
<td colspan="6" class="empty-state-cell">
<div class="empty-state">{{ emptyMessage }}</div>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</template>
<script setup>
defineProps({
patients: {
type: Array,
required: true
},
emptyMessage: {
type: String,
default: 'Tidak ada data pasien'
}
});
defineEmits(['verify']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-100: #FFE8CC;
$primary-200: #FFDCAF;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$secondary-200: #EDF5FF;
$secondary-300: #DBEDFF;
$secondary-400: #B3D9FF;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-extra-bold: 800;
.patient-table {
font-family: $font-family-base;
width: 100%;
border-collapse: collapse;
table-layout: auto;
:deep(thead),
:deep(tbody),
:deep(tr) {
margin: 0;
padding: 0;
}
}
// ============================================
// TABLE HEADER
// ============================================
.table-header {
background-color: $neutral-400;
}
.header-cell {
padding: 16px 24px !important;
text-align: left;
font-weight: $font-weight-bold;
font-size: 14px;
color: $neutral-900;
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: $font-family-base;
border-bottom: 2px solid $neutral-500;
vertical-align: middle;
&.status-header-hidden {
padding: 16px 24px !important;
width: 96px;
min-width: 96px;
max-width: 96px;
}
&.action-col {
text-align: right;
padding-right: 24px !important;
}
}
// ============================================
// TABLE BODY
// ============================================
.table-row {
transition: all 0.3s ease;
position: relative;
&:hover {
background-color: $primary-100 !important;
&::before {
transform: scaleY(1);
}
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background: $primary-600;
transform: scaleY(0);
transition: transform 0.3s ease;
}
}
.verified-row {
background-color: $secondary-200 !important;
&::before {
background: $secondary-600 !important;
}
&:hover {
background-color: $secondary-300 !important;
}
}
.table-cell {
padding: 16px 24px !important;
vertical-align: middle;
font-family: $font-family-base;
border-bottom: 1px solid $neutral-400;
&.status-cell {
text-align: center;
padding: 16px 24px !important;
width: 96px;
min-width: 96px;
max-width: 96px;
}
&.action-col {
text-align: right;
padding-right: 24px !important;
}
}
.status-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.patient-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid $neutral-100;
margin: 0 auto;
}
.patient-name {
font-size: 18px;
font-weight: $font-weight-extra-bold;
color: $neutral-900;
font-family: $font-family-base;
}
.chip-rm {
border: 1px solid $secondary-400;
font-weight: $font-weight-bold;
}
.chip-rm-text {
color: $secondary-700;
font-weight: $font-weight-bold;
font-family: $font-family-base;
}
.info-item {
color: $neutral-700;
display: flex;
align-items: center;
font-size: 14px;
font-family: $font-family-base;
}
.action-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
}
.action-btn {
background-color: $primary-700 !important;
text-transform: none;
letter-spacing: 0.5px;
font-weight: $font-weight-extra-bold;
font-size: 14px;
color: $neutral-100 !important;
padding: 8px 24px !important;
height: 40px !important;
min-width: 150px;
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.4), 0 2px 6px rgba(255, 155, 27, 0.3);
font-family: $font-family-base;
transition: all 0.3s ease;
}
.action-btn:hover {
background-color: #E68A00 !important;
box-shadow: 0 6px 16px rgba(255, 155, 27, 0.5), 0 3px 8px rgba(255, 155, 27, 0.4);
transform: translateY(-2px);
}
.verified-btn {
text-transform: none;
letter-spacing: 0.5px;
font-weight: $font-weight-extra-bold;
font-size: 14px;
color: $neutral-100 !important;
padding: 8px 24px !important;
height: 40px !important;
min-width: 150px;
font-family: $font-family-base;
opacity: 1 !important;
}
.empty-state-cell {
padding: 40px 24px;
text-align: center;
}
.empty-state {
font-size: 18px;
color: $neutral-600;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 960px) {
.patient-table {
display: block;
overflow-x: auto;
}
.table-cell {
padding: 20px 16px;
white-space: nowrap;
}
.header-cell {
padding: 12px 16px;
font-size: 12px;
}
.action-btn,
.verified-btn {
font-size: 12px;
}
.patient-avatar {
width: 44px !important;
height: 44px !important;
}
.patient-name {
font-size: 16px;
}
.info-item {
font-size: 12px;
}
}
</style>
@@ -0,0 +1,315 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="600"
transition="dialog-bottom-transition"
scrollable
>
<v-card class="modal-card">
<!-- Modal Header -->
<div class="modal-header">
<v-icon size="72" color="white" class="mb-3">mdi-qrcode-scan</v-icon>
<h2 class="modal-title">Aktivasi Akun</h2>
<p class="modal-subtitle">{{ patient.nama }}</p>
<v-chip color="white" class="modal-chip" variant="flat" size="default">
<span class="modal-chip-text">RM: {{ patient.rm }}</span>
</v-chip>
</div>
<!-- Modal Content -->
<v-card-text class="pa-7">
<v-text-field
:model-value="phoneNumber"
@update:model-value="$emit('update:phoneNumber', $event)"
:disabled="qrGenerated"
label="Nomor Telepon Pasien"
placeholder="08xxxxxxxxxx"
variant="outlined"
density="comfortable"
:color="qrGenerated ? 'grey' : 'primary-600'"
:rules="[v => v.length >= 8 || 'Min. 8 digit']"
prepend-inner-icon="mdi-phone"
class="mb-3 phone-field"
base-color="grey-darken-1"
/>
<!-- Generate Button -->
<v-btn
v-if="!qrGenerated"
:disabled="!isPhoneValid"
color="secondary-600"
block
size="x-large"
class="generate-btn"
@click="$emit('generate')"
rounded="xl"
>
<v-icon left size="32">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
<!-- QR Code Container -->
<div v-else class="qr-container">
<div class="pulse-icon">
<v-icon color="secondary-600" :size="isMobile ? 48 : 64">
mdi-cellphone-check
</v-icon>
</div>
<h3 class="qr-title">Pindai QR Code</h3>
<p class="qr-subtitle">Arahkan kamera smartphone ke QR code</p>
<div class="d-flex justify-center mb-3 mb-sm-4">
<div class="qr-frame">
<slot name="qr-code" />
</div>
</div>
</div>
</v-card-text>
<!-- Modal Actions -->
<v-card-actions v-if="qrGenerated" class="modal-actions">
<v-btn
color="secondary-600"
variant="outlined"
@click="$emit('reload')"
prepend-icon="mdi-reload"
size="x-large"
class="modal-action-btn"
rounded="xl"
>
Reload QR
</v-btn>
<v-btn
color="primary-600"
variant="flat"
@click="$emit('complete')"
prepend-icon="mdi-check-circle"
size="x-large"
class="modal-action-btn-primary"
rounded="xl"
>
Selesai
</v-btn>
</v-card-actions>
<v-card-actions v-else class="modal-actions">
<v-btn
color="neutral-600"
variant="text"
@click="$emit('close')"
size="x-large"
block
rounded="xl"
class="modal-cancel-btn"
>
Batal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
patient: {
type: Object,
required: true
},
phoneNumber: {
type: String,
default: ''
},
qrGenerated: {
type: Boolean,
default: false
},
isMobile: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'update:modelValue',
'update:phoneNumber',
'generate',
'reload',
'complete',
'close'
]);
const isPhoneValid = computed(() => props.phoneNumber.length >= 8);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-600: #89939E;
$neutral-700: #717171;
$primary-600: #FFA532;
$secondary-200: #EDF5FF;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-extra-bold: 800;
$font-weight-black: 900;
.modal-card {
border-radius: 16px;
overflow: hidden;
padding: 0;
font-family: $font-family-base;
}
.modal-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
text-align: center;
padding: 28px;
box-shadow: 0 4px 12px rgba(6, 99, 199, 0.2);
}
.modal-title {
font-size: 32px;
font-weight: $font-weight-black;
color: $neutral-100;
margin: 8px 0;
font-family: $font-family-base;
}
.modal-subtitle {
font-size: 20px;
color: $neutral-100;
opacity: 0.95;
font-weight: $font-weight-semibold;
margin: 0;
font-family: $font-family-base;
}
.modal-chip {
margin-top: 8px;
font-weight: $font-weight-bold;
}
.modal-chip-text {
color: $primary-600;
font-weight: $font-weight-bold;
font-family: $font-family-base;
}
.phone-field {
font-family: $font-family-base;
}
.generate-btn {
margin-top: 12px;
font-weight: $font-weight-extra-bold;
font-size: 18px;
color: $neutral-100;
box-shadow: 0 2px 8px rgba(6, 99, 199, 0.25);
font-family: $font-family-base;
}
.qr-container {
background: $secondary-200;
border: 3px solid $secondary-600;
border-radius: 16px;
padding: 28px 16px;
margin-top: 24px;
box-shadow: 0 6px 20px rgba(6, 99, 199, 0.15);
text-align: center;
}
@media (min-width: 600px) {
.qr-container {
padding: 28px;
}
}
.pulse-icon {
margin-bottom: 16px;
}
.qr-title {
font-size: 24px;
font-weight: $font-weight-black;
color: $secondary-700;
margin-bottom: 8px;
font-family: $font-family-base;
}
@media (min-width: 600px) {
.qr-title {
font-size: 28px;
margin-bottom: 12px;
}
}
.qr-subtitle {
font-size: 13px;
color: $neutral-700;
margin-bottom: 16px;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
@media (min-width: 600px) {
.qr-subtitle {
font-size: 14px;
margin-bottom: 24px;
}
}
.qr-frame {
padding: 16px;
background: $neutral-100;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: inline-block;
border: 3px solid $secondary-600;
}
@media (min-width: 600px) {
.qr-frame {
padding: 24px;
border-radius: 16px;
border: 4px solid $secondary-600;
}
}
.modal-actions {
padding: 28px;
padding-top: 0;
}
.modal-action-btn,
.modal-action-btn-primary {
font-weight: $font-weight-bold;
font-size: 14px;
text-transform: none;
flex-grow: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-family: $font-family-base;
}
.modal-action-btn-primary {
color: $neutral-100;
font-weight: $font-weight-extra-bold;
}
.modal-cancel-btn {
font-weight: $font-weight-bold;
font-size: 14px;
text-transform: none;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,120 @@
<template>
<v-row class="search-section">
<v-col cols="12" sm="6" class="py-2">
<v-text-field
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
density="comfortable"
label="Cari pasien berdasarkan nama atau RM..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
hide-details
single-line
color="primary-600"
bg-color="white"
class="search-field"
/>
</v-col>
<v-col cols="12" sm="6" class="py-2">
<v-tabs
:model-value="selectedTab"
@update:model-value="$emit('update:selectedTab', $event)"
color="primary-600"
align-tabs="end"
class="filter-tabs"
slider-color="primary-600"
>
<v-tab :value="0" class="tab-item">
SEMUA
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
{{ allCount }}
</v-chip>
</v-tab>
<v-tab :value="1" class="tab-item">
PENDING
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
{{ pendingCount }}
</v-chip>
</v-tab>
<v-tab :value="2" class="tab-item">
VERIFIED
<v-chip size="small" color="primary-600" class="ml-2 chip-count">
{{ verifiedCount }}
</v-chip>
</v-tab>
</v-tabs>
</v-col>
</v-row>
</template>
<script setup>
defineProps({
searchQuery: {
type: String,
default: ''
},
selectedTab: {
type: Number,
default: 0
},
allCount: {
type: Number,
default: 0
},
pendingCount: {
type: Number,
default: 0
},
verifiedCount: {
type: Number,
default: 0
}
});
defineEmits(['update:searchQuery', 'update:selectedTab']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$neutral-600: #89939E;
$neutral-300: #F5F7FA;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
$font-weight-bold: 700;
.search-section {
background: $neutral-300;
border-bottom: 1px solid $neutral-500;
margin: 0;
padding: 24px;
}
.search-field {
border-radius: 8px;
font-family: $font-family-base;
}
.filter-tabs {
background: transparent;
font-family: $font-family-base;
}
.tab-item {
font-weight: $font-weight-bold;
font-size: 14px;
text-transform: none;
letter-spacing: 0;
font-family: $font-family-base;
}
.chip-count {
color: #000 !important;
font-weight: $font-weight-bold;
font-family: $font-family-base;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
// composables/useAPI.ts
// Generic API composable untuk semua API calls
// Base composable yang bisa di-extend untuk specific resources
export interface APIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export const useAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api';
/**
* Generic GET request
*/
const get = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'GET',
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to fetch data');
} catch (error: any) {
console.error(`❌ Error GET ${endpoint}:`, error);
throw error;
}
};
/**
* Generic POST request
*/
const post = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'POST',
body,
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to create data');
} catch (error: any) {
console.error(`❌ Error POST ${endpoint}:`, error);
throw error;
}
};
/**
* Generic PATCH request
*/
const patch = async <T = any>(endpoint: string, body: any, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'PATCH',
body,
...options,
});
if (response.success && response.data !== undefined) {
return response.data;
}
throw new Error(response.message || 'Failed to update data');
} catch (error: any) {
console.error(`❌ Error PATCH ${endpoint}:`, error);
throw error;
}
};
/**
* Generic DELETE request
*/
const del = async <T = any>(endpoint: string, options: any = {}): Promise<T> => {
try {
const response = await $fetch<APIResponse<T>>(`${baseURL}${endpoint}`, {
method: 'DELETE',
...options,
});
if (response.success) {
return response.data as T;
}
throw new Error(response.message || 'Failed to delete data');
} catch (error: any) {
console.error(`❌ Error DELETE ${endpoint}:`, error);
throw error;
}
};
return {
get,
post,
patch,
delete: del,
};
};
+148
View File
@@ -0,0 +1,148 @@
// composables/useAuth.ts - Enhanced version with better error handling
import type { User, SessionResponse, LoginResponse, LogoutResponse } from '~/types/auth'
export const useAuth = () => {
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
const clearError = () => {
error.value = null
}
const checkAuth = async (): Promise<User | null> => {
try {
isLoading.value = true
clearError()
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
// The session API returns SessionResponse, or throws 401 if not authenticated
// $fetch automatically sends cookies for same-origin requests
const response = await $fetch<SessionResponse>('/api/auth/session', {
headers,
credentials: 'include' // Explicitly include cookies (though $fetch does this by default)
})
// Handle response structure - session API returns { success, user, ... }
if (response && response.user) {
user.value = response.user
return response.user
}
// If response exists but no user, clear and return null
user.value = null
return null
} catch (fetchError: any) {
console.error('Session check failed:', fetchError)
// 401 errors are expected when not authenticated
if (fetchError.statusCode === 401 || fetchError.status === 401) {
error.value = null // Don't show error for expected unauthenticated state
} else {
error.value = 'Failed to check authentication status'
}
user.value = null
return null
} finally {
isLoading.value = false
}
}
const login = async (): Promise<void> => {
try {
clearError()
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
if (response?.success && response?.data?.authUrl) {
console.log('🔗 Redirecting to Keycloak login...')
window.location.href = response.data.authUrl
} else {
const errorMsg = response?.error || 'Failed to get authorization URL'
error.value = errorMsg
throw new Error(errorMsg)
}
} catch (loginError: any) {
console.error('❌ Login error:', loginError)
error.value = loginError.message || 'Login failed'
throw loginError
}
}
const logout = async (): Promise<void> => {
try {
isLoading.value = true
clearError()
console.log('🚪 Starting logout process...')
const response = await $fetch<LogoutResponse>('/api/auth/logout', {
method: 'POST'
})
// Clear user immediately regardless of response
user.value = null
if (response?.success && response?.logoutUrl) {
console.log('🔗 Redirecting to Keycloak logout...')
window.location.href = response.logoutUrl
} else {
const warningMsg = response?.error || response?.message || 'No logout URL received'
console.warn('⚠️', warningMsg)
error.value = warningMsg
await navigateTo('/LoginPage')
}
} catch (logoutError: any) {
console.error('❌ Logout error:', logoutError)
error.value = logoutError.message || 'Logout failed'
user.value = null
await navigateTo('/LoginPage')
} finally {
isLoading.value = false
}
}
// Helper function to refresh user data
const refreshUser = async (): Promise<boolean> => {
const userData = await checkAuth()
return !!userData
}
// Helper function to check if user has specific role
const hasRole = (role: string): boolean => {
if (!user.value) return false
// Check in roles array
if (user.value.roles?.includes(role)) return true
// Check in realm_access.roles
if (user.value.realm_access?.roles?.includes(role)) return true
return false
}
// Helper function to check if user has any of the specified roles
const hasAnyRole = (roles: string[]): boolean => {
return roles.some(role => hasRole(role))
}
return {
// State
user: readonly(user),
isAuthenticated,
isLoading: readonly(isLoading),
error: readonly(error),
// Actions
checkAuth,
login,
logout,
refreshUser,
clearError,
// Utilities
hasRole,
hasAnyRole
}
}
+203
View File
@@ -0,0 +1,203 @@
import { ref, readonly } from 'vue'
import type { CheckInResult } from '~/types/checkin'
export interface UseCheckInOptions {
showSnackbar: (title: string, message: string, color: string, icon: string) => void
saveToHistory: (item: {
patientId: string
queueNumber?: string
status: string
checkInTime: string
checkInDate: string
method: string
}) => void
saveSuccessfulScan: (qrData: string) => void
onCheckInSuccess: (result: {
success: boolean
patientId: string
status: string
message: string
action: 'checkin' | 'kembali'
}) => void
autoCloseDialog: () => void
}
export const useCheckIn = (options: UseCheckInOptions) => {
const { showSnackbar, saveToHistory, saveSuccessfulScan, onCheckInSuccess, autoCloseDialog } = options
// State
const lastCheckInResult = ref<CheckInResult | null>(null)
// Perform check-in
const performCheckIn = async (data: string, method: string = 'QR Scan'): Promise<boolean> => {
await new Promise(resolve => setTimeout(resolve, 1000))
const success = Math.random() < 0.8
// History akan disimpan di processQRCode setelah performCheckIn selesai
// Jadi kita hanya perlu return hasil check-in
return success
}
// Process QR code (rename dari onDetect)
const processQRCode = async (decodedText: string) => {
const [patientId, status] = decodedText.split('|')
// Validasi format QR code
if (!patientId || !status) {
showSnackbar('Error', 'QR Code tidak valid. Format harus: ID_PASIEN|STATUS', 'error', 'mdi-close-circle')
return
}
// Cek apakah pasien diperbolehkan check-in
const isAllowed = status === 'ALLOWED'
if (isAllowed) {
// Jika diperbolehkan, langsung proses check-in
const checkinSuccess = await performCheckIn(decodedText, 'QR Scan')
// Simpan hasil check-in
lastCheckInResult.value = {
success: checkinSuccess,
patientId: patientId,
status: status
}
// Simpan ke history check-in dengan status hasil check-in
saveToHistory({
patientId: patientId || 'Unknown',
status: checkinSuccess ? 'success' : 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
})
// Tampilkan dialog dengan hasil check-in
if (checkinSuccess) {
// Simpan QR code yang berhasil di-scan untuk mencegah scan ulang
saveSuccessfulScan(decodedText)
onCheckInSuccess({
success: true,
patientId: patientId,
status: status,
message: `✅ Check-in Berhasil!\n\nPasien ${patientId} berhasil melakukan check-in.`,
action: 'checkin'
})
} else {
onCheckInSuccess({
success: false,
patientId: patientId,
status: status,
message: `❌ Check-in Gagal!\n\nPasien ${patientId} diperbolehkan check-in, namun proses check-in gagal. Silakan coba lagi.`,
action: 'checkin'
})
}
autoCloseDialog()
} else {
// Jika belum diperbolehkan, tampilkan pesan
lastCheckInResult.value = {
success: false,
patientId: patientId,
status: status
}
// Simpan ke history check-in untuk NOT_ALLOWED
saveToHistory({
patientId: patientId || 'Unknown',
status: 'NOT_ALLOWED',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'QR Scan'
})
onCheckInSuccess({
success: false,
patientId: patientId,
status: status,
message: `⏳ Belum Diizinkan Check-in\n\nAntrean Pasien ${patientId} belum diperbolehkan check-in. Mohon menunggu hingga antrean Anda dipanggil.`,
action: 'kembali'
})
autoCloseDialog()
}
}
// Check-in manual
const checkInManual = async (
patientId: string,
onSuccess: () => void,
onError: () => void
) => {
try {
if (!patientId || !patientId.trim()) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert')
onError()
return
}
const trimmedPatientId = patientId.trim()
// Simulasi check-in manual
const success = await performCheckIn(`${trimmedPatientId}|ALLOWED`, 'Manual')
// Simpan hasil check-in
lastCheckInResult.value = {
success: success,
patientId: trimmedPatientId,
status: 'ALLOWED',
}
// Simpan ke history check-in
saveToHistory({
patientId: trimmedPatientId,
status: success ? 'success' : 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: 'Manual'
})
if (success) {
// Simpan QR code yang berhasil untuk mencegah double antrian (jika menggunakan format yang sama)
saveSuccessfulScan(`${trimmedPatientId}|ALLOWED`)
showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle')
// Update info dialog
onCheckInSuccess({
success: true,
patientId: trimmedPatientId,
status: 'ALLOWED',
message: `✅ Check-in Berhasil!\n\nPasien ${trimmedPatientId} berhasil melakukan check-in secara manual.`,
action: 'checkin'
})
autoCloseDialog()
onSuccess()
} else {
showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle')
// Update info dialog untuk gagal
onCheckInSuccess({
success: false,
patientId: trimmedPatientId,
status: 'ALLOWED',
message: `❌ Check-in Gagal!\n\nPasien ${trimmedPatientId} gagal melakukan check-in secara manual. Silakan coba lagi.`,
action: 'checkin'
})
autoCloseDialog()
onError()
}
} catch (error) {
console.error('Error in checkInManual:', error)
showSnackbar('Error', 'Terjadi kesalahan saat melakukan check-in. Silakan coba lagi.', 'error', 'mdi-alert')
onError()
}
}
return {
lastCheckInResult: readonly(lastCheckInResult),
performCheckIn,
processQRCode,
checkInManual,
}
}
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff. Show more