Author SHA1 Message Date
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
258 changed files with 93069 additions and 10149 deletions

No files matched your search

+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
dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies
node_modules
@@ -22,3 +28,6 @@ logs
.env
.env.*
!.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>
<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 />
</v-app>
</NuxtLayout>
</NuxtLayout>
</v-app>
</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;
+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
}
})
+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,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,
}
}
+278
View File
@@ -0,0 +1,278 @@
import { ref, computed, readonly } from 'vue'
import type { CheckInHistoryItem, ScannedQRHistoryItem, HistoryStatus } from '~/types/checkin'
import { HISTORY_STORAGE_KEY, SCANNED_QR_STORAGE_KEY, MAX_HISTORY_ITEMS, MAX_SCANNED_QR_ITEMS, HISTORY_STATUS_OPTIONS } from '~/constants/checkin'
export const useCheckInHistory = () => {
const checkInHistory = ref<CheckInHistoryItem[]>([])
const scannedQRHistory = ref<ScannedQRHistoryItem[]>([])
const historySearch = ref('')
const historyStatusFilter = ref<HistoryStatus | ''>('')
const historyDateFilter = ref<string>('')
const loadHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY)
if (stored) {
try {
checkInHistory.value = JSON.parse(stored)
} catch (e) {
console.error('Error loading history:', e)
checkInHistory.value = []
}
}
}
}
const saveToHistory = (item: CheckInHistoryItem) => {
const historyItem = {
...item,
queueNumber: item.queueNumber || `ANT-${Date.now()}`,
}
checkInHistory.value.unshift(historyItem)
// Simpan maksimal item sesuai constant
if (checkInHistory.value.length > MAX_HISTORY_ITEMS) {
checkInHistory.value = checkInHistory.value.slice(0, MAX_HISTORY_ITEMS)
}
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value))
}
}
const deleteHistoryItem = (index: number) => {
checkInHistory.value.splice(index, 1)
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value))
}
}
const clearHistory = () => {
checkInHistory.value = []
if (typeof window !== 'undefined') {
localStorage.removeItem(HISTORY_STORAGE_KEY)
}
}
const loadScannedQRHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SCANNED_QR_STORAGE_KEY)
if (stored) {
try {
scannedQRHistory.value = JSON.parse(stored)
} catch (e) {
console.error('Error loading QR history:', e)
scannedQRHistory.value = []
}
} else {
scannedQRHistory.value = []
}
}
}
const saveScannedQRData = (qrData: string) => {
if (typeof window !== 'undefined') {
const scannedQRs = JSON.parse(localStorage.getItem(SCANNED_QR_STORAGE_KEY) || '[]')
scannedQRs.unshift({
data: qrData,
timestamp: new Date().toISOString(),
date: new Date().toLocaleDateString('id-ID'),
time: new Date().toLocaleTimeString('id-ID').replace(/\./g, ':')
})
// Simpan maksimal item sesuai constant
if (scannedQRs.length > MAX_SCANNED_QR_ITEMS) {
scannedQRs.pop()
}
localStorage.setItem(SCANNED_QR_STORAGE_KEY, JSON.stringify(scannedQRs))
}
}
const clearQRHistory = () => {
scannedQRHistory.value = []
if (typeof window !== 'undefined') {
localStorage.removeItem(SCANNED_QR_STORAGE_KEY)
}
}
const deleteQRHistoryItem = (index: number) => {
scannedQRHistory.value.splice(index, 1)
if (typeof window !== 'undefined') {
localStorage.setItem(SCANNED_QR_STORAGE_KEY, JSON.stringify(scannedQRHistory.value))
}
}
const filteredHistory = computed(() => {
let filtered = [...checkInHistory.value]
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase()
filtered = filtered.filter(item =>
item.patientId.toLowerCase().includes(search) ||
(item.queueNumber && item.queueNumber.toLowerCase().includes(search))
)
}
// Filter by status
if (historyStatusFilter.value) {
filtered = filtered.filter(item => {
if (historyStatusFilter.value === 'success') {
return item.status === 'ALLOWED' || item.status === 'success'
} else if (historyStatusFilter.value === 'failed') {
return item.status === 'NOT_ALLOWED' || item.status === 'failed'
}
return true
})
}
// Filter by date
if (historyDateFilter.value && historyDateFilter.value.trim() !== '') {
filtered = filtered.filter(item => {
if (!item.checkInDate) return false
try {
const itemDate = new Date(item.checkInDate)
const filterDate = new Date(historyDateFilter.value)
// Validate dates
if (isNaN(itemDate.getTime()) || isNaN(filterDate.getTime())) {
return false
}
// Compare dates (year, month, day only, ignore time)
const itemDateStr = itemDate.toISOString().substring(0, 10)
const filterDateStr = filterDate.toISOString().substring(0, 10)
return itemDateStr === filterDateStr
} catch (e) {
console.error('Error filtering by date:', e)
return false
}
})
}
return filtered
})
const filteredQRHistory = computed(() => {
let filtered = [...scannedQRHistory.value]
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase()
filtered = filtered.filter(item =>
item.data.toLowerCase().includes(search)
)
}
// Filter by date
if (historyDateFilter.value && historyDateFilter.value.trim() !== '') {
filtered = filtered.filter(item => {
if (!item.timestamp && !item.date) return false
try {
let itemDate: Date
if (item.timestamp) {
itemDate = new Date(item.timestamp)
} else {
// Parse Indonesian date format (dd/mm/yyyy or dd mmm yyyy)
itemDate = new Date(item.date)
}
const filterDate = new Date(historyDateFilter.value)
// Validate dates
if (isNaN(itemDate.getTime()) || isNaN(filterDate.getTime())) {
return false
}
// Compare dates (year, month, day only, ignore time)
const itemDateStr = itemDate.toISOString().substring(0, 10)
const filterDateStr = filterDate.toISOString().substring(0, 10)
return itemDateStr === filterDateStr
} catch (e) {
console.error('Error filtering QR history by date:', e)
return false
}
})
}
return filtered
})
const getStatusColor = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'success'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'error'
return 'warning'
}
const getStatusIcon = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle'
return 'mdi-clock-alert'
}
const getStatusText = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'Berhasil'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal'
return 'Pending'
}
const getStatusClass = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'history-success'
if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed'
return 'history-pending'
}
const formatDateTime = (dateString: string) => {
const date = new Date(dateString)
// Format jam dengan titik dua (HH:MM:SS)
const timeString = date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
return timeString.replace(/\./g, ':')
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
}
// Load history on initialization
if (typeof window !== 'undefined') {
loadHistory()
loadScannedQRHistory()
}
return {
checkInHistory: readonly(checkInHistory),
scannedQRHistory: readonly(scannedQRHistory),
historySearch,
historyStatusFilter,
historyDateFilter,
filteredHistory,
filteredQRHistory,
loadHistory,
saveToHistory,
deleteHistoryItem,
clearHistory,
loadScannedQRHistory,
saveScannedQRData,
clearQRHistory,
deleteQRHistoryItem,
getStatusColor,
getStatusIcon,
getStatusText,
getStatusClass,
formatDateTime,
formatDate,
historyStatusOptions: [...HISTORY_STATUS_OPTIONS] as Array<{ title: string; value: string }>,
}
}
+160
View File
@@ -0,0 +1,160 @@
// composables/useClinicAPI.ts
// Composable untuk API calls terkait klinik/poli
import { useAPI } from './useAPI';
export interface Clinic {
id: number;
kode: string;
name: string;
subtitle?: string;
icon?: string;
shift?: string;
schedule?: string;
available?: boolean;
doctors?: string[];
shifts?: Array<{ name: string; quota: number }>;
totalQuota?: number;
jamShiftList?: Array<{ dari: string; sampai: string; kuota: number }>;
autoShift?: boolean;
jadwalKlinik?: string[];
}
export const useClinicAPI = () => {
const api = useAPI();
/**
* Fetch all clinics from database
*/
const fetchAllClinics = async (): Promise<Clinic[]> => {
try {
return await api.get<Clinic[]>('/clinics');
} catch (error: any) {
console.error('❌ Error fetching clinics:', error);
throw error;
}
};
/**
* Fetch single clinic by ID
*/
const fetchClinic = async (id: number): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/${id}`);
} catch (error: any) {
console.error('❌ Error fetching clinic:', error);
return null;
}
};
/**
* Fetch clinics by kode
*/
const fetchClinicByKode = async (kode: string): Promise<Clinic | null> => {
try {
return await api.get<Clinic>(`/clinics/kode/${kode}`);
} catch (error: any) {
console.error('❌ Error fetching clinic by kode:', error);
return null;
}
};
/**
* Create new clinic
*/
const createClinic = async (clinicData: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.post<Clinic>('/clinics', clinicData);
} catch (error: any) {
console.error('❌ Error creating clinic:', error);
throw error;
}
};
/**
* Update clinic
*/
const updateClinic = async (id: number, updates: Partial<Clinic>): Promise<Clinic> => {
try {
return await api.patch<Clinic>(`/clinics/${id}`, updates);
} catch (error: any) {
console.error('❌ Error updating clinic:', error);
throw error;
}
};
/**
* Delete clinic
*/
const deleteClinic = async (id: number): Promise<boolean> => {
try {
await api.delete(`/clinics/${id}`);
return true;
} catch (error: any) {
console.error('❌ Error deleting clinic:', error);
return false;
}
};
/**
* Get clinics for dropdown (formatted for dropdown use)
*/
const fetchClinicsForDropdown = async (): Promise<Clinic[]> => {
try {
const clinics = await fetchAllClinics();
// Format sesuai kebutuhan dropdown
return clinics.map(clinic => ({
id: clinic.id,
name: clinic.name,
kode: clinic.kode,
icon: clinic.icon,
available: clinic.available ?? true,
}));
} catch (error: any) {
console.error('❌ Error fetching clinics for dropdown:', error);
return [];
}
};
/**
* Sync local clinics with database
*/
const syncWithDatabase = async (localClinics: Clinic[]): Promise<Clinic[]> => {
try {
const dbClinics = await fetchAllClinics();
// Merge strategy: prefer database data
const merged = new Map<number, Clinic>();
// Add database clinics
dbClinics.forEach(clinic => {
merged.set(clinic.id, clinic);
});
// Add local clinics that don't exist in DB
localClinics.forEach(localClinic => {
if (!merged.has(localClinic.id)) {
merged.set(localClinic.id, localClinic);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing clinics with database:', error);
// Return local clinics as fallback
return localClinics;
}
};
return {
fetchAllClinics,
fetchClinic,
fetchClinicByKode,
createClinic,
updateClinic,
deleteClinic,
fetchClinicsForDropdown,
syncWithDatabase,
};
};
+119
View File
@@ -0,0 +1,119 @@
// 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: Roles, Groups, and their own Username
const entities = [...new Set([
(currentUser as any).namaUser, // Individual user mapping support
...roles,
...groups
])].filter(Boolean);
if (entities.length === 0) {
return [];
}
try {
const headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
// Fetch all hak akses data
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses', { headers });
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 headers = import.meta.server ? useRequestHeaders(['cookie']) as Record<string, string> : {}
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses', { headers });
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
};
};
+81
View File
@@ -0,0 +1,81 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
/**
* Reusable Infinite Scroll Composable
* @param {import('vue').Ref<Array>} sourceData - The full source array of data
* @param {number} pageSize - Number of items to load per "page"
* @param {Object} options - Additional options
* @returns {Object} { visibleItems, targetRef, loadMore, reset }
*/
export function useInfiniteScroll(sourceData, pageSize = 20, options = {}) {
const visibleCount = ref(pageSize);
const targetRef = ref(null);
// Compute visible items based on current count
const visibleItems = computed(() => {
const data = sourceData.value || [];
return data.slice(0, visibleCount.value);
});
// Check if there are more items to load
const hasMore = computed(() => {
const data = sourceData.value || [];
return visibleCount.value < data.length;
});
const loadMore = () => {
if (hasMore.value) {
visibleCount.value += pageSize;
}
};
const reset = () => {
visibleCount.value = pageSize;
};
// Setup IntersectionObserver
let observer = null;
onMounted(() => {
observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore.value) {
loadMore();
}
}, {
root: null,
rootMargin: '100px', // Preload before reaching bottom
threshold: 0.1,
...options
});
if (targetRef.value) {
observer.observe(targetRef.value);
}
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
// Watch for targetRef changes (e.g., if valid status changes)
watch(targetRef, (el) => {
if (observer) {
observer.disconnect();
if (el) observer.observe(el);
}
});
// Reset when source data changes significantly (optional, depending on use case)
// watch(sourceData, () => {
// reset(); // Uncomment if you want to reset scroll on data refresh
// });
return {
visibleItems,
targetRef,
loadMore,
reset,
hasMore
};
}
+39
View File
@@ -0,0 +1,39 @@
// /composables/usePermissions.ts
export const usePermission = () => {
/**
* Extract group and role from path
* Example: "/Instalasi STIM/Devops/Superadmin"
* { group: "STIM", role: "superadmin" }
*/
const parsePath = (path: string) => {
const parts = path.split("/").filter(Boolean);
const group = parts[1] || ""; // "STIM"
const role = parts.at(-1)?.toLowerCase() || ""; // "superadmin"
return { group, role };
};
/**
* Fetch permission from backend API
*/
const fetchPermission = async (path: string) => {
const { group, role } = parsePath(path);
const config = useRuntimeConfig();
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
const origin = new URL(apiBase).origin;
const url = `${origin}/api/permission?roles=${role}&groups=${group}`;
const { data, error } = await useFetch(url, {
method: "GET",
});
if (error.value) {
console.error("❌ Gagal mengambil permission:", error.value);
throw error.value;
}
console.log("✅ Permission data:", data.value);
return data.value;
};
return { parsePath, fetchPermission };
};
+211
View File
@@ -0,0 +1,211 @@
import { ref, readonly, computed } from 'vue'
import { nextTick } from 'vue'
import type { CheckInStatus } from '~/types/checkin'
export interface UseQRGeneratorOptions {
showSnackbar: (title: string, message: string, color: string, icon: string) => void
generateRandomPatientId: () => string
}
export const useQRGenerator = (options: UseQRGeneratorOptions) => {
const { showSnackbar, generateRandomPatientId } = options
// State
const generatePatientId = ref(generateRandomPatientId())
const generateStatus = ref<CheckInStatus>('ALLOWED')
const generatedQRData = ref('')
// Generate random patient ID
const generateRandomId = () => {
generatePatientId.value = generateRandomPatientId()
}
// Quick generate QR for testing
const generateQuickQR = (patientId: string, status: string) => {
generatePatientId.value = patientId
generateStatus.value = status as CheckInStatus
generateQRCode()
}
// Generate QR Code function
const generateQRCode = async () => {
if (!generatePatientId.value) {
showSnackbar('Error', 'Mohon isi ID Pasien', 'error', 'mdi-alert')
return
}
generatedQRData.value = `${generatePatientId.value}|${generateStatus.value}`
await nextTick()
// Clear previous QR code
const qrContainer = document.getElementById('qrcode')
if (qrContainer) {
qrContainer.innerHTML = ''
try {
// Use qrcode package that's already installed
const QRCode = (await import('qrcode')).default
// Create QR code as data URL
const qrDataUrl = await QRCode.toDataURL(generatedQRData.value, {
errorCorrectionLevel: 'M',
type: 'image/png',
quality: 0.92,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
width: 300
})
// Create img element and append to container
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'
qrContainer.appendChild(img)
console.log('QR Code created successfully:', generatedQRData.value)
showSnackbar('Berhasil!', 'QR Code berhasil di-generate. Silakan scan untuk testing', 'success', 'mdi-check-circle')
} catch (error) {
console.error('Error creating QR code:', error)
showSnackbar('Error', 'Gagal membuat QR Code. Silakan coba lagi', 'error', 'mdi-alert')
}
} else {
showSnackbar('Error', 'Container QR Code tidak ditemukan', 'error', 'mdi-alert')
}
}
// Download QR Code
const downloadQR = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement
if (img && img.src) {
try {
// Convert img src (data URL) to blob
const response = await fetch(img.src)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
const fileName = `QR-Test-${generatePatientId.value}-${generateStatus.value}-${Date.now()}.png`
link.download = fileName
link.href = url
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showSnackbar('Berhasil!', `QR Code berhasil didownload: ${fileName}`, 'success', 'mdi-download')
} catch (error) {
console.error('Download error:', error)
// Fallback: use img src directly
const link = document.createElement('a')
link.download = `QR-Test-${generatePatientId.value}-${generateStatus.value}.png`
link.href = img.src
link.click()
showSnackbar('Berhasil!', 'QR Code berhasil didownload', 'success', 'mdi-download')
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert')
}
}
// Copy QR Code to Clipboard
const copyQRToClipboard = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement
if (img && img.src) {
try {
// Convert img src to blob
const response = await fetch(img.src)
const blob = await response.blob()
try {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
])
showSnackbar('Berhasil!', 'QR Code berhasil disalin ke clipboard', 'success', 'mdi-content-copy')
} catch (err: any) {
console.error('Clipboard error:', err)
// Fallback: download instead
showSnackbar('Info', 'Copy ke clipboard tidak didukung. Gunakan tombol Download.', 'info', 'mdi-information')
}
} catch (error) {
console.error('Copy error:', error)
showSnackbar('Error', 'Gagal menyalin QR Code', 'error', 'mdi-alert')
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert')
}
}
// Share QR Code
const shareQR = async () => {
const img = document.querySelector('#qrcode img') as HTMLImageElement
if (img && img.src) {
try {
// Convert img src to blob
const response = await fetch(img.src)
const blob = await response.blob()
const file = new File([blob], `QR-Test-${generatePatientId.value}-${generateStatus.value}.png`, { type: 'image/png' })
if (navigator.share && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: 'QR Code Check-in Test',
text: `QR Code untuk testing: ${generatedQRData.value}`
})
showSnackbar('Berhasil!', 'QR Code berhasil dibagikan', 'success', 'mdi-share')
} catch (err: any) {
if (err.name !== 'AbortError') {
// Fallback to copy or download
copyQRToClipboard()
}
}
} else {
// Fallback: try copy to clipboard
copyQRToClipboard()
}
} catch (error) {
console.error('Share error:', error)
showSnackbar('Error', 'Gagal membagikan QR Code', 'error', 'mdi-alert')
}
} else {
showSnackbar('Error', 'QR Code belum di-generate. Silakan generate terlebih dahulu', 'error', 'mdi-alert')
}
}
// Computed untuk v-model
const generatePatientIdModel = computed({
get: () => generatePatientId.value,
set: (value: string) => {
generatePatientId.value = value
}
})
const generateStatusModel = computed({
get: () => generateStatus.value,
set: (value: CheckInStatus) => {
generateStatus.value = value
}
})
return {
generatePatientId: generatePatientIdModel,
generateStatus: generateStatusModel,
generatedQRData: readonly(generatedQRData),
generateRandomId,
generateQuickQR,
generateQRCode,
downloadQR,
copyQRToClipboard,
shareQR,
}
}
+574
View File
@@ -0,0 +1,574 @@
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { QR_CODE_ID, SCAN_DEBOUNCE_MS, SUCCESSFUL_SCANS_KEY } from '~/constants/checkin'
export const useQRScanner = (
onQRDetected: (decodedText: string) => void,
showSnackbar: (title: string, message: string, color: string, icon: string) => void
) => {
// Scanner state
const isScanning = ref(false)
const hasCamera = ref(false)
const cameraChecking = ref(true)
const cameraReady = ref(false)
let html5QrCode: any = null
const qrCodeId = QR_CODE_ID
let lastScannedQR: string | null = null
let lastScanTime: number = 0
let isProcessing = false // Flag untuk mencegah pemrosesan berulang
// Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian)
const successfulScans = ref<Set<string>>(new Set())
// Detect mobile device
const isMobile = ref(false)
if (typeof window !== 'undefined') {
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(window.innerWidth <= 768)
}
// Load successful scans dari localStorage
const loadSuccessfulScans = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SUCCESSFUL_SCANS_KEY)
if (stored) {
try {
const scansArray = JSON.parse(stored)
successfulScans.value = new Set(scansArray)
} catch (e) {
console.error('Error loading successful scans:', e)
successfulScans.value = new Set()
}
}
}
}
// Simpan successful scan ke localStorage
const saveSuccessfulScan = (qrData: string) => {
if (typeof window !== 'undefined') {
successfulScans.value.add(qrData)
const scansArray = Array.from(successfulScans.value)
localStorage.setItem(SUCCESSFUL_SCANS_KEY, JSON.stringify(scansArray))
}
}
// Cek apakah QR code sudah pernah berhasil di-scan
const isQRCodeAlreadyScanned = (qrData: string): boolean => {
return successfulScans.value.has(qrData)
}
// Check camera availability
const checkCameraAvailability = async () => {
cameraChecking.value = true
hasCamera.value = false
// Check if browser supports mediaDevices
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
console.error('navigator.mediaDevices is not supported')
showSnackbar('Error', 'Browser tidak mendukung akses kamera. Pastikan menggunakan HTTPS atau localhost.', 'error', 'mdi-camera-off')
cameraChecking.value = false
return
}
try {
// First, request permission by trying to get user media
// This is required for enumerateDevices to return device labels
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
// Stop the stream immediately after getting permission
stream.getTracks().forEach(track => track.stop())
// Now enumerate devices (will have labels after permission granted)
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
hasCamera.value = videoDevices.length > 0
if (hasCamera.value) {
console.log(`Found ${videoDevices.length} camera(s):`, videoDevices.map(d => d.label || d.deviceId))
} else {
console.warn('No video input devices found')
showSnackbar('Warning', 'Tidak ada kamera yang terdeteksi', 'warning', 'mdi-camera-off')
}
} catch (err: any) {
console.error('Error checking camera:', err)
hasCamera.value = false
let errorMessage = 'Gagal mengakses kamera. '
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage += 'Izin kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.'
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage += 'Tidak ada kamera yang ditemukan.'
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
errorMessage += 'Kamera sedang digunakan aplikasi lain.'
} else if (err.name === 'OverconstrainedError' || err.name === 'ConstraintNotSatisfiedError') {
errorMessage += 'Kamera tidak memenuhi persyaratan.'
} else {
errorMessage += `Error: ${err.message || err.name}`
}
showSnackbar('Error', errorMessage, 'error', 'mdi-camera-off')
} finally {
cameraChecking.value = false
}
}
// Test camera function for debugging
const testCamera = async () => {
try {
showSnackbar('Info', 'Menguji akses kamera...', 'info', 'mdi-camera')
if (typeof navigator === 'undefined') {
showSnackbar('Error', 'navigator is undefined - pastikan di browser, bukan SSR', 'error', 'mdi-alert')
return
}
if (!navigator.mediaDevices) {
showSnackbar('Error', 'navigator.mediaDevices is undefined - pastikan menggunakan HTTPS atau localhost', 'error', 'mdi-alert')
return
}
if (!navigator.mediaDevices.getUserMedia) {
showSnackbar('Error', 'getUserMedia is not supported', 'error', 'mdi-alert')
return
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user',
width: { ideal: 640 },
height: { ideal: 480 }
}
})
showSnackbar('Success', 'Kamera berhasil diakses!', 'success', 'mdi-check-circle')
// Stop stream after 2 seconds
setTimeout(() => {
stream.getTracks().forEach(track => track.stop())
}, 2000)
} catch (err: any) {
console.error('Test camera error:', err)
let errorMsg = `Error: ${err.name || 'Unknown'}`
if (err.message) errorMsg += ` - ${err.message}`
showSnackbar('Error', errorMsg, 'error', 'mdi-alert')
}
}
// Handle QR scan success
const handleQRScanSuccess = (decodedText: string) => {
// Validasi data QR - cek di awal sebelum log apapun
if (!decodedText || decodedText.trim() === '') {
return // Silent return untuk empty QR code
}
// Cegah pemrosesan berulang jika sedang memproses
if (isProcessing) {
return // Silent return untuk mencegah spam log
}
// Debounce: cegah scan berulang dalam waktu singkat - cek sebelum log
const now = Date.now()
if (lastScannedQR === decodedText && (now - lastScanTime) < SCAN_DEBOUNCE_MS) {
return // Silent return untuk debounce
}
// Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian)
if (isQRCodeAlreadyScanned(decodedText)) {
console.log('⏭️ Scan diabaikan: QR code sudah pernah berhasil di-scan')
showSnackbar('Peringatan', 'QR Code ini sudah pernah berhasil di-scan. Tidak dapat digunakan lagi untuk mencegah double antrian.', 'warning', 'mdi-alert-circle')
return
}
// Set flag processing dan update last scan info
isProcessing = true
lastScannedQR = decodedText
lastScanTime = now
console.log('🎯 QR Scan Success! Data:', decodedText)
// Scanner tetap berjalan untuk memungkinkan scan QR code berikutnya
// Proses data QR melalui callback
try {
onQRDetected(decodedText)
} catch (error) {
console.error('Error processing QR data:', error)
showSnackbar('Error', 'Gagal memproses data QR Code', 'error', 'mdi-alert')
} finally {
// Reset flag setelah selesai memproses (dengan delay kecil untuk memastikan callback selesai)
setTimeout(() => {
isProcessing = false
}, SCAN_DEBOUNCE_MS)
}
}
// QR Scanner Functions
const startScanning = async () => {
// Check camera first
if (!hasCamera.value) {
await checkCameraAvailability()
if (!hasCamera.value) {
showSnackbar('Error', 'Kamera tidak tersedia. Silakan gunakan input manual atau pastikan kamera terhubung.', 'error', 'mdi-camera-off')
return
}
}
try {
// Set scanning state first to render the element
isScanning.value = true
// Wait for DOM to update and element to be rendered
await nextTick()
// Wait a bit more to ensure element is fully rendered
await new Promise(resolve => setTimeout(resolve, 100))
// Check if element exists
const qrElement = document.getElementById(qrCodeId)
if (!qrElement) {
console.error(`Element with id "${qrCodeId}" not found in DOM`)
isScanning.value = false
showSnackbar('Error', 'Element scanner tidak ditemukan. Silakan refresh halaman.', 'error', 'mdi-alert')
return
}
// Dynamic import html5-qrcode
if (!html5QrCode) {
const { Html5Qrcode: Html5QrcodeClass } = await import('html5-qrcode')
html5QrCode = new Html5QrcodeClass(qrCodeId)
console.log('Html5Qrcode initialized')
}
if (html5QrCode) {
cameraReady.value = false
// Konfigurasi kamera untuk mobile dan desktop
const cameraConfig = isMobile.value
? { facingMode: "environment" } // Mobile: gunakan kamera belakang
: { facingMode: "user" } // Desktop: gunakan kamera depan (webcam)
// Video constraints yang lebih fleksibel
const baseVideoConstraints = isMobile.value
? {
facingMode: "environment",
width: { ideal: 640, min: 320 },
height: { ideal: 480, min: 240 }
}
: {
facingMode: "user",
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 }
}
// Tambahkan advanced constraints hanya jika didukung
const videoConstraints: any = { ...baseVideoConstraints }
console.log('Starting QR scanner with config:', {
isMobile: isMobile.value,
cameraConfig,
videoConstraints
})
try {
await html5QrCode.start(
cameraConfig,
{
fps: isMobile.value ? 15 : 20, // FPS optimal untuk deteksi
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
// QR box dengan ukuran lebih besar untuk deteksi lebih mudah
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
// Gunakan 80-90% dari viewfinder untuk area scanning yang lebih besar
const percentageSize = Math.floor(minEdgeSize * 0.80)
// Atau ukuran tetap yang lebih besar
const fixedSize = isMobile.value ? 250 : 250
// Gunakan yang lebih besar antara percentage atau fixed
const qrboxSize = Math.max(percentageSize, fixedSize)
// Pastikan tidak melebihi viewfinder
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80)
console.log('QR Box size:', finalSize, 'Viewfinder:', viewfinderWidth, 'x', viewfinderHeight)
return {
width: Math.max(finalSize, 250), // Minimum 250px untuk deteksi lebih baik
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: videoConstraints,
rememberLastUsedCamera: true,
showTorchButtonIfSupported: true,
// Tambahkan opsi untuk meningkatkan deteksi
verbose: false // Set true untuk debugging
},
(decodedText: string, decodedResult: any) => {
// QR Code berhasil di-scan
console.log('✅ QR Code detected:', decodedText)
console.log('📊 Decoded result:', decodedResult)
// Validasi dan proses QR code
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
} else {
console.warn('Empty QR code detected')
}
},
(errorMessage: string) => {
// Log error untuk debugging - hanya log error penting
// Error ini biasanya muncul terus menerus saat tidak ada QR code yang terdeteksi
// Jadi kita filter untuk menghindari spam console
if (errorMessage) {
// Filter out common non-critical errors
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code parse error, error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner warning:', errorMessage)
}
}
}
)
// Set camera ready setelah sedikit delay untuk memastikan video sudah dimuat
setTimeout(() => {
cameraReady.value = true
console.log('Camera ready, scanner is active')
}, 500)
showSnackbar('Info', 'Scanner aktif. Arahkan kamera ke QR code dan pastikan pencahayaan cukup', 'info', 'mdi-camera')
} catch (cameraError: any) {
console.error('Camera error:', cameraError)
// Jika kamera belakang tidak tersedia di mobile, coba kamera depan
if (isMobile.value && cameraError.name === 'NotFoundError') {
try {
console.log('Trying front camera as fallback...')
await html5QrCode.start(
{ facingMode: "user" },
{
fps: 15,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
const percentageSize = Math.floor(minEdgeSize * 0.85)
const fixedSize = 300
const qrboxSize = Math.max(percentageSize, fixedSize)
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.95)
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
facingMode: "user",
width: { ideal: 640, min: 320 },
height: { ideal: 480, min: 240 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (front camera):', decodedText)
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner (front) warning:', errorMessage)
}
}
}
)
setTimeout(() => {
cameraReady.value = true
}, 500)
showSnackbar('Info', 'Menggunakan kamera depan. Arahkan QR code ke kamera', 'info', 'mdi-camera')
} catch (fallbackError: any) {
console.error('Fallback camera also failed:', fallbackError)
throw cameraError // Throw original error
}
} else {
// Coba menggunakan deviceId langsung jika facingMode gagal
try {
console.log('Trying to get camera devices...')
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
if (videoDevices.length > 0) {
// Gunakan kamera pertama yang tersedia
const deviceId = videoDevices[0].deviceId
console.log('Using device:', deviceId, videoDevices[0].label)
await html5QrCode.start(
{ deviceId: { exact: deviceId } },
{
fps: isMobile.value ? 15 : 20,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
const percentageSize = Math.floor(minEdgeSize * 0.80)
const fixedSize = isMobile.value ? 300 : 400
const qrboxSize = Math.max(percentageSize, fixedSize)
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80)
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
deviceId: { exact: deviceId },
width: { ideal: isMobile.value ? 640 : 1280, min: isMobile.value ? 320 : 640 },
height: { ideal: isMobile.value ? 480 : 720, min: isMobile.value ? 240 : 480 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (deviceId):', decodedText)
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner (deviceId) warning:', errorMessage)
}
}
}
)
setTimeout(() => {
cameraReady.value = true
}, 500)
showSnackbar('Info', 'Scanner aktif menggunakan kamera yang tersedia', 'info', 'mdi-camera')
} else {
throw cameraError
}
} catch (deviceError: any) {
console.error('DeviceId approach also failed:', deviceError)
throw cameraError
}
}
}
}
} catch (err: any) {
console.error('Error starting scanner:', err)
isScanning.value = false
cameraReady.value = false
// Don't set hasCamera to false if it was detected, just log the error
let errorMessage = 'Gagal memulai scanner. '
if (err.message && err.message.includes('not found')) {
errorMessage = 'Element scanner tidak ditemukan. Silakan refresh halaman dan coba lagi.'
console.error('Element not found error. Element ID:', qrCodeId)
} else if (err.name === 'NotAllowedError') {
errorMessage = 'Akses kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.'
} else if (err.name === 'NotFoundError') {
errorMessage = 'Kamera tidak ditemukan. Silakan gunakan input manual.'
hasCamera.value = false
} else if (err.name === 'NotReadableError') {
errorMessage = 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi lain yang menggunakan kamera.'
} else {
errorMessage += err.message || err.name || 'Unknown error'
}
showSnackbar('Error', errorMessage, 'error', 'mdi-alert')
}
}
const stopScanning = async () => {
const scannerInstance = html5QrCode
// Reset state immediately to update UI
isScanning.value = false
cameraReady.value = false
// Clear reference immediately to prevent re-entry
html5QrCode = null
// If no instance, nothing to clean up
if (!scannerInstance) {
return
}
try {
// Stop the scanner (this stops the camera stream)
if (typeof scannerInstance.stop === 'function') {
await scannerInstance.stop().catch((err: any) => {
// Ignore stop errors - scanner might already be stopped
console.warn('Scanner stop error (ignored):', err?.message || err)
})
}
} catch (err: any) {
// Ignore errors from stop
console.warn('Error stopping scanner (ignored):', err?.message || err)
}
try {
// Clear the scanner (this cleans up DOM and resources)
// Only try to clear if stop was successful or if clear method exists
if (typeof scannerInstance.clear === 'function') {
await scannerInstance.clear().catch((err: any) => {
// Ignore clear errors - DOM might already be cleaned up
console.warn('Scanner clear error (ignored):', err?.message || err)
})
}
} catch (err: any) {
// Ignore errors from clear
console.warn('Error clearing scanner (ignored):', err?.message || err)
}
// Show notification after cleanup attempts
try {
showSnackbar('Info', 'Scanner dihentikan', 'info', 'mdi-camera-off')
} catch (e) {
// Ignore snackbar errors
}
}
// Initialize on mount
if (typeof window !== 'undefined') {
loadSuccessfulScans()
}
return {
// State
isScanning,
hasCamera,
cameraChecking,
cameraReady,
// Functions
checkCameraAvailability,
testCamera,
startScanning,
stopScanning,
saveSuccessfulScan,
isQRCodeAlreadyScanned,
}
}
+350
View File
@@ -0,0 +1,350 @@
// composables/useQueue.js
import { ref, computed } from "vue";
import { useQueueStore } from "../stores/queueStore";
import { useLoketStore } from "../stores/loketStore";
export const useQueue = (adminType = "loket", specificId = null) => {
const queueStore = useQueueStore();
const loketStore = useLoketStore();
// Normalize specificId to a reactive value
const idValue = computed(() => {
if (typeof specificId === 'function') return specificId();
if (specificId && typeof specificId === 'object' && 'value' in specificId) return specificId.value;
return specificId;
});
// Local state
const snackbar = ref(false);
const snackbarText = ref("");
const snackbarColor = ref("success");
// Dialog states
const showKlinikDialog = ref(false);
const showPenunjangDialog = ref(false);
const showChangeKlinikDialog = ref(false);
const klinikSearch = ref("");
const penunjangSearch = ref("");
const changeKlinikSearch = ref("");
const selectedPatientForPenunjang = ref(null);
// FIXED: Get stage patients once, then derive others from it
const stagePatients = computed(() => {
// Don't use .value here - getPatientsByStage returns a computed already
const patients = queueStore.getPatientsByStage(adminType);
return patients.value;
});
// Computed from store - isolated by specificId if provided
const currentProcessingPatient = computed(() => {
const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
return queueStore.currentProcessingPatient[key];
});
// Derive from stagePatients - ADD DEBUG LOGS
const diLoketPatients = computed(() => {
const patients = stagePatients.value.diLoket || [];
console.log('🔍 useQueue - diLoketPatients:', patients.length);
if (patients.length > 0) {
console.log('🔍 First diLoket patient:', patients[0]);
console.log('🔍 First diLoket patient fastTrack:', patients[0].fastTrack);
}
return patients;
});
const terlambatPatients = computed(() => {
const patients = stagePatients.value.terlambat || [];
console.log('🔍 useQueue - terlambatPatients:', patients.length);
return patients;
});
const pendingPatients = computed(() => {
const patients = stagePatients.value.pending || [];
console.log('🔍 useQueue - pendingPatients:', patients.length);
return patients;
});
const anjunganPatients = computed(() => stagePatients.value.anjungan || []);
// Pasien yang belum dipanggil (status "menunggu")
const menungguPatients = computed(() => stagePatients.value.menunggu || []);
const nextPatient = computed(() => {
// Prioritaskan pasien menunggu, baru anjungan
return menungguPatients.value[0] || anjunganPatients.value[0] || null;
});
// Total pasien hanya untuk stage admin ini
const totalPasien = computed(() => {
const total = queueStore.getTotalPasienByStage(adminType);
return total.value;
});
// Helper to check if a patient is relevant to the current admin view
const isPatientRelevant = (p) => {
if (p.processStage !== adminType) return false;
const targetId = idValue.value;
if (!targetId) return true; // No specific ID, show all for this stage
if (adminType === 'loket') {
const thisLoket = loketStore.getLoketById(parseInt(targetId));
if (!thisLoket) return String(p.loketId) === String(targetId);
// Must be assigned to this loket OR unassigned but clinic is served by this loket
const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId);
if (isAssignedToThis) return true;
const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik);
return isServedByThis;
}
if (adminType === 'klinik') {
// Filter by clinic code or clinic name
return String(p.kodeKlinik) === String(targetId) || String(p.klinik) === String(targetId);
}
if (adminType === 'penunjang') {
// Penunjang usually uses clinic name as the identifier in seeds
return String(p.klinik) === String(targetId) || String(p.kodeKlinik) === String(targetId);
}
return true;
};
// 1. Menunggu Count (Backlog ONLY - Strictly status 'menunggu')
const menungguCount = computed(() => {
let list = menungguPatients.value || [];
return list.filter(isPatientRelevant).length;
});
// 2. Calculated Quota Used (Dynamic - any ticket NOT 'menunggu' counts as used)
const calculatedQuotaUsed = computed(() => {
const list = queueStore.allPatients || [];
return list.filter(p => {
if (!isPatientRelevant(p)) return false;
// Status is NOT 'menunggu' (meaning it's being served or finished)
return p.status !== 'menunggu';
}).length;
});
const quotaUsed = computed(() => calculatedQuotaUsed.value);
// Expose dev helper to refresh seed data
const resetPatients = () => queueStore.resetPatients();
// Filtered lists
const filteredKliniks = computed(() => {
let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
if (klinikSearch.value) {
result = result.filter((k) =>
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
);
}
// Sort alphabetically by name
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
});
const filteredPenunjangs = computed(() => {
let result = queueStore.penunjangs;
if (penunjangSearch.value) {
result = result.filter((p) =>
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
);
}
// Sort alphabetically by name
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
});
const filteredChangeKliniks = computed(() => {
let result = (queueStore.kliniks.value || queueStore.kliniks || []).filter(k => k.jenisLayanan === 'Reguler');
if (changeKlinikSearch.value) {
result = result.filter((k) =>
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
);
}
// Sort alphabetically by name
return [...result].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
});
// Methods
const showSnackbar = (text, color = "success") => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
const callNext = (specificIdOverride = null) => {
const targetId = specificIdOverride || idValue.value;
const result = queueStore.callNext(adminType, targetId);
showSnackbar(result.message, result.success ? "success" : "warning");
};
const callMultiplePatients = (count, specificIdOverride = null) => {
const targetId = specificIdOverride || idValue.value;
const result = queueStore.callMultiplePatients(count, adminType, targetId);
showSnackbar(result.message, result.success ? "success" : "warning");
};
const processPatient = async (patient, action) => {
const result = await queueStore.processPatient(patient, action, adminType, idValue.value);
let color = "success";
if (action === "terlambat") color = "warning";
else if (action === "pending") color = "info";
showSnackbar(result.message, color);
return result;
};
// Helper function untuk mendapatkan pasien yang sedang diproses dari store
const getCurrentProcessingPatientFromStore = () => {
try {
const key = idValue.value ? `${adminType}-${idValue.value}` : adminType;
const processingPatient = queueStore.currentProcessingPatient?.[key];
if (!processingPatient) return null;
// Dapatkan data terbaru dari allPatients langsung (bukan dari getPatientsByStage yang sudah difilter)
// allPatients adalah ref yang di-export dari Pinia store, bisa diakses langsung atau dengan .value
// Coba akses langsung dulu, jika undefined baru coba dengan .value
let allPatients = [];
if (queueStore.allPatients) {
// Jika allPatients adalah ref (punya .value)
allPatients = Array.isArray(queueStore.allPatients)
? queueStore.allPatients
: (queueStore.allPatients.value || []);
}
// Cari pasien berdasarkan no, barcode, atau noAntrian
const latestPatient = allPatients.find(
p => (p && p.no === processingPatient.no) ||
(p && p.barcode && p.barcode === processingPatient.barcode) ||
(p && p.noAntrian && p.noAntrian === processingPatient.noAntrian)
);
// Jika ditemukan, return data terbaru, jika tidak return data dari currentProcessingPatient
return latestPatient || processingPatient;
} catch (error) {
console.error('Error in getCurrentProcessingPatientFromStore:', error);
// Fallback: return processingPatient dari store jika ada
return queueStore.currentProcessingPatient?.[adminType] || null;
}
};
const selectKlinik = (klinik) => {
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
showKlinikDialog.value = false;
return;
}
const result = queueStore.createAntreanKlinik(klinik, patient, adminType);
showSnackbar(result.message, "success");
showKlinikDialog.value = false;
};
const selectPenunjang = (penunjang) => {
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
showPenunjangDialog.value = false;
selectedPatientForPenunjang.value = null;
return;
}
const result = queueStore.createAntreanPenunjang(
penunjang,
patient,
adminType
);
showSnackbar(result.message, "success");
showPenunjangDialog.value = false;
selectedPatientForPenunjang.value = null;
};
const openPenunjangDialog = (patient = null) => {
selectedPatientForPenunjang.value = patient;
showPenunjangDialog.value = true;
};
const changeKlinik = (klinik) => {
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value || getCurrentProcessingPatientFromStore();
if (!patient) {
showSnackbar("Tidak ada pasien yang sedang diproses", "error");
showChangeKlinikDialog.value = false;
return { success: false };
}
const result = queueStore.changeKlinik(
patient,
klinik,
adminType,
idValue.value
);
showSnackbar(result.message, result.success ? "success" : "error");
showChangeKlinikDialog.value = false;
return result;
};
const processNextQueue = async () => {
const result = await queueStore.processNextQueue(adminType, idValue.value);
showSnackbar(result.message, result.success ? "success" : "warning");
return result;
};
const getRowClass = (item) => {
if (item.status === "current") {
return "text-success font-weight-bold";
}
return "";
};
return {
// State
snackbar,
snackbarText,
snackbarColor,
showKlinikDialog,
showPenunjangDialog,
showChangeKlinikDialog,
klinikSearch,
penunjangSearch,
changeKlinikSearch,
selectedPatientForPenunjang,
// Computed
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
anjunganPatients,
menungguPatients,
nextPatient,
totalPasien,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
filteredChangeKliniks,
stagePatients,
// Methods
showSnackbar,
callNext,
callMultiplePatients,
processPatient,
selectKlinik,
selectPenunjang,
openPenunjangDialog,
changeKlinik,
processNextQueue,
getRowClass,
};
};
+193
View File
@@ -0,0 +1,193 @@
// composables/useQueueAPI.ts
// Composable untuk API calls terkait antrian pasien
export interface Patient {
no: number;
jamPanggil: string;
barcode: string;
noAntrian: string;
shift: string;
klinik: string;
fastTrack: string;
pembayaran: string;
status: 'anjungan' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
processStage: 'loket' | 'klinik' | 'penunjang';
createdAt: string;
registrationType?: 'online' | 'onsite';
visitType?: string;
visitDate?: string;
}
export interface QueueAPIResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export const useQueueAPI = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl || '/api/queue';
/**
* Fetch all patients from database
*/
const fetchAllPatients = async (): Promise<Patient[]> => {
try {
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to fetch patients');
} catch (error: any) {
console.error('❌ Error fetching patients:', error);
throw error;
}
};
/**
* Fetch single patient by ID or barcode
*/
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
method: 'GET',
});
if (response.success && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching patient:', error);
return null;
}
};
/**
* Create new patient (register from Anjungan)
*/
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
method: 'POST',
body: patientData,
});
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to create patient');
} catch (error: any) {
console.error('❌ Error creating patient:', error);
throw error;
}
};
/**
* Update patient status (check-in, process, etc)
*/
const updatePatient = async (
idOrBarcode: string,
updates: Partial<Patient>
): Promise<Patient> => {
try {
const response = await $fetch<QueueAPIResponse<Patient>>(
`${baseURL}/patients/${idOrBarcode}`,
{
method: 'PATCH',
body: updates,
}
);
if (response.success && response.data) {
return response.data;
}
throw new Error(response.message || 'Failed to update patient');
} catch (error: any) {
console.error('❌ Error updating patient:', error);
throw error;
}
};
/**
* Check-in patient (update status to di-loket)
*/
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
return updatePatient(idOrBarcode, { status: 'di-loket' });
};
/**
* Process patient at loket (update status and processStage)
*/
const processPatientAtLoket = async (
idOrBarcode: string,
updates: { status?: string; processStage?: string }
): Promise<Patient> => {
return updatePatient(idOrBarcode, updates as Partial<Patient>);
};
/**
* Sync local state with database
*/
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
try {
// Fetch latest from database
const dbPatients = await fetchAllPatients();
// Merge strategy: prefer database data, but keep local if newer
const merged = new Map<string, Patient>();
// Add database patients
dbPatients.forEach(patient => {
merged.set(patient.barcode, patient);
});
// Add local patients that don't exist in DB or are newer
localPatients.forEach(localPatient => {
const existing = merged.get(localPatient.barcode);
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
merged.set(localPatient.barcode, localPatient);
}
});
return Array.from(merged.values());
} catch (error: any) {
console.error('❌ Error syncing with database:', error);
// Return local patients as fallback
return localPatients;
}
};
/**
* Batch sync: save multiple patients to database
*/
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
try {
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
method: 'POST',
body: { patients },
});
return response.success || false;
} catch (error: any) {
console.error('❌ Error batch syncing patients:', error);
return false;
}
};
return {
fetchAllPatients,
fetchPatient,
createPatient,
updatePatient,
checkInPatient,
processPatientAtLoket,
syncWithDatabase,
batchSyncPatients,
};
};
+44
View File
@@ -0,0 +1,44 @@
import { ref, computed, readonly } from 'vue'
import type { SnackbarState } from '~/types/checkin'
export const useSnackbar = () => {
const snackbar = ref<SnackbarState>({
show: false,
title: '',
message: '',
color: '',
icon: '',
timeout: 4000,
})
const showSnackbar = (
title: string,
message: string,
color: string,
icon: string,
timeout = 4000
) => {
snackbar.value = {
show: true,
title,
message,
color,
icon,
timeout,
}
}
// Computed untuk snackbar.show agar v-model bisa bekerja
const snackbarShow = computed({
get: () => snackbar.value.show,
set: (value: boolean) => {
snackbar.value.show = value
}
})
return {
snackbar: readonly(snackbar),
snackbarShow,
showSnackbar,
}
}
+636
View File
@@ -0,0 +1,636 @@
import { ref } from 'vue';
import QRCode from 'qrcode';
export interface ThermalPrintData {
noAntrian: string;
barcode: string;
klinik: string;
ruang?: string;
nomorRuang?: string;
shift: string;
pembayaran: string;
tanggal: string;
waktu: string;
namaDokter?: string;
status?: 'ALLOWED' | 'NOT_ALLOWED';
}
export const useThermalPrint = () => {
const isPrinting = ref(false);
// NOTE: generateBarcode telah dihapus dari useThermalPrint
// Gunakan generateBarcode dari queueStore.js untuk konsistensi
// Semua barcode generation harus melalui queueStore.generateBarcode()
/**
* Generate QR Code data URL
*/
const generateQRCode = async (data: string): Promise<string> => {
try {
const qrDataUrl = await QRCode.toDataURL(data, {
errorCorrectionLevel: 'M', // Medium error correction (better for standard cameras)
type: 'image/png',
quality: 1,
margin: 2, // Added margin (quiet zone) for better scanner detection
width: 400, // High quality width for clear printing
version: 3,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
return qrDataUrl;
} catch (error) {
console.error('Error generating QR Code:', error);
throw error;
}
};
/**
* Format date untuk tampilan
* Format: senin, 12 Jan 2026
*/
const formatDate = (dateString: string): string => {
try {
let date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
date = new Date();
}
// Array nama hari dalam bahasa Indonesia (lowercase)
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
// Array nama bulan singkat (title case)
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
const dayName = days[date.getDay()];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear(); // Tahun 4 digit
return `${dayName}, ${day} ${month} ${year}`;
} catch (error) {
// Fallback jika error
const date = new Date();
const days = ['minggu', 'senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
const dayName = days[date.getDay()];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
return `${dayName}, ${day} ${month} ${year}`;
}
};
/**
* Format time untuk tampilan
*/
const formatTime = (dateString: string): string => {
try {
let date = dateString ? new Date(dateString) : new Date();
if (isNaN(date.getTime())) {
date = new Date();
}
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
} catch (error) {
const date = new Date();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
};
/**
* Generate HTML untuk thermal print
*/
const generatePrintHTML = async (data: ThermalPrintData): Promise<string> => {
// QR Code data: hanya barcode saja (status akan dicek real-time saat check-in dari queueStore)
// Format: BARCODE (contoh: 250811100163)
// Alasan: Status bisa berubah setelah tiket di-print (dari 'menunggu' jadi 'waiting' setelah dipanggil)
// Status akan dicek langsung dari queueStore saat scan QR untuk akurasi real-time
// Normalize barcode - pastikan tidak ada whitespace dan valid
const qrData = String(data.barcode || '').trim();
if (!qrData) {
throw new Error('Barcode tidak valid untuk QR code');
}
console.log('📱 Generating QR code for ticket with barcode:', qrData);
const qrCodeImage = await generateQRCode(qrData);
console.log('✅ QR code generated successfully');
// Format nomor antrian (hilangkan bagian "| Onsite - barcode")
const noAntrianDisplay = data.noAntrian.split(' |')[0];
// Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi"
let ruangInfo = '';
// 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis)
if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) {
ruangInfo = `${data.klinik} - ${data.ruang}`;
}
// 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll)
else if (data.klinik && data.nomorRuang) {
const ruangNumber = parseInt(data.nomorRuang) || 1;
const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A'
ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`;
}
// 3. Fallback: gunakan data.ruang yang ada
else if (data.klinik && data.ruang) {
ruangInfo = `${data.klinik} - ${data.ruang}`;
}
// 4. Default: hanya nama klinik
else if (data.klinik) {
ruangInfo = data.klinik;
}
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Nomor Antrian - ${noAntrianDisplay}</title>
<style>
@page {
size: 80mm auto;
margin: 0;
padding: 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
width: 80mm;
max-width: 80mm;
margin: 0;
padding: 2mm 4mm 6mm 4mm;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
font-weight: bold;
line-height: 1.0;
background: white;
color: black;
}
.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;
}
/* Feed lines untuk auto cutter (space setelah footer) */
.cut-section {
margin-top: 0;
padding-top: 0;
height: 0;
display: block;
page-break-after: always;
visibility: hidden;
}
@media print {
body {
margin: 0;
padding: 2mm 4mm 6mm 4mm;
}
.ticket-table {
border: none;
}
.ticket-content {
padding: 2mm 4mm 4mm 4mm;
}
/* Feed setelah print untuk auto cutter */
.ticket-table::after {
content: '';
display: block;
height: 10mm;
min-height: 10mm;
page-break-after: always;
}
@page {
size: 80mm auto;
margin: 2mm 0 6mm 0;
/* Margin top kecil, margin bottom besar untuk feed auto cutter */
}
/* Feed tambahan di akhir untuk memastikan kertas cukup untuk dipotong */
.cut-section {
height: 0 !important;
min-height: 0 !important;
display: block !important;
page-break-after: always !important;
visibility: hidden !important;
}
/* Pastikan tidak ada page break di tengah content */
.ticket-content > * {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<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>
${ruangInfo ? `<div class="klinik-name">${ruangInfo}</div>` : `<div class="klinik-name">${data.klinik}</div>`}
<div class="qr-code-section">
<div class="qr-code">
<img src="${qrCodeImage}" alt="QR Code" />
</div>
<div class="barcode-number">${data.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">${data.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">${data.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">${data.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">${data.pembayaran}</span>
</div>
${data.namaDokter ? `
<div 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">${data.namaDokter}</span>
</div>
` : ''}
</div>
<div class="footer">
<div class="footer-text">Tunjukkan tiket ini saat check-in</div>
</div>
<!-- Feed lines untuk auto cutter (space setelah footer untuk cutter) -->
<div class="cut-section"></div>
<!-- ESC/POS Auto Cut Command Comment (untuk printer driver yang support) -->
<!-- ESC/POS Commands for EPSON M352A: -->
<!-- ESC i = Full Cut (0x1B 0x69) -->
<!-- ESC m = Partial Cut (0x1B 0x6D) -->
<!-- GS V 0 = Full Cut (0x1D 0x56 0x00) -->
<!-- GS V 1 = Partial Cut (0x1D 0x56 0x01) -->
</td>
</tr>
</table>
</body>
</html>
`;
return html;
};
/**
* Print thermal ticket
*/
const printTicket = async (data: ThermalPrintData): Promise<boolean> => {
try {
isPrinting.value = true;
// Generate HTML
const html = await generatePrintHTML(data);
// Create print window
const printWindow = window.open('', '_blank', 'width=300,height=400');
if (!printWindow) {
throw new Error('Tidak dapat membuka window print. Pastikan pop-up diizinkan.');
}
printWindow.document.write(html);
printWindow.document.close();
// Wait for images to load
await new Promise((resolve) => {
printWindow.onload = () => {
setTimeout(() => {
printWindow.focus();
// Add event listener untuk afterprint (auto cut trigger)
const handleAfterPrint = () => {
console.log('Print completed. Auto cutter should activate.');
console.log('Note: For EPSON M352A, ensure printer settings have auto cut enabled.');
console.log('ESC/POS Cut Command: ESC i (0x1B 0x69) for full cut');
};
printWindow.addEventListener('afterprint', handleAfterPrint);
// Print ke printer default
// Catatan: Window.print() akan menggunakan printer default yang sudah di-set di sistem
// Jika user ingin mengubah printer, mereka perlu mengubah default printer di Windows/OS Settings terlebih dahulu
printWindow.print();
// Note: Untuk printer thermal EPSON M352A, auto cutter akan otomatis aktif jika:
// 1. Driver printer sudah dikonfigurasi untuk auto cut
// 2. Printer settings di Windows/OS sudah enable auto cut
// 3. CSS feed lines di atas sudah cukup untuk trigger auto cut
//
// ESC/POS Commands yang didukung:
// - ESC i (0x1B 0x69): Full Cut
// - ESC m (0x1B 0x6D): Partial Cut
// - GS V 0 (0x1D 0x56 0x00): Full Cut
// - GS V 1 (0x1D 0x56 0x01): Partial Cut
// Close window after print (optional)
// Delay lebih lama untuk memastikan print dan cut selesai
setTimeout(() => {
printWindow.removeEventListener('afterprint', handleAfterPrint);
printWindow.close();
}, 1500);
resolve(true);
}, 500);
};
});
return true;
} catch (error) {
console.error('Error printing ticket:', error);
throw error;
} finally {
isPrinting.value = false;
}
};
/**
* Print ticket dengan data dari queueStore patient object
*/
const printTicketFromPatient = async (patient: any): Promise<boolean> => {
// Determine tanggal kunjungan
let tanggalKunjungan: string;
if (patient.visitDate) {
// Jika ada visitDate (untuk jadwal lain), gunakan itu
tanggalKunjungan = formatDate(patient.visitDate);
} else if (patient.visitType === 'SEKARANG' || !patient.visitType) {
// Jika kunjungan hari ini
tanggalKunjungan = formatDate(new Date().toISOString());
} else {
// Fallback ke tanggal sekarang
tanggalKunjungan = formatDate(new Date().toISOString());
}
// Determine waktu
let waktu: string;
if (patient.createdAt) {
waktu = formatTime(patient.createdAt);
} else if (patient.jamPanggil) {
// Gunakan jamPanggil jika ada
waktu = patient.jamPanggil;
} else {
waktu = formatTime(new Date().toISOString());
}
// Status tidak lagi disertakan dalam QR code
// Status akan dicek real-time dari queueStore saat scan QR
// Format QR code: hanya BARCODE saja untuk fleksibilitas status yang bisa berubah
// Normalize barcode - pastikan format konsisten (trim whitespace)
const barcode = String(patient.barcode || '').trim();
if (!barcode) {
console.error('❌ Barcode pasien tidak valid untuk print ticket:', patient);
throw new Error('Barcode pasien tidak valid');
}
console.log('🖨️ Printing ticket for patient:', {
noAntrian: patient.noAntrian,
barcode: barcode,
status: patient.status,
klinik: patient.klinik
});
const printData: ThermalPrintData = {
noAntrian: patient.noAntrian || '',
barcode: barcode,
klinik: patient.klinik || '',
ruang: patient.ruang || undefined,
nomorRuang: patient.nomorRuang || undefined,
shift: patient.shift || 'Shift 1',
pembayaran: patient.pembayaran || '',
tanggal: tanggalKunjungan,
waktu: waktu,
namaDokter: patient.namaDokter || undefined,
// Status tidak digunakan lagi, hanya untuk kompatibilitas interface
status: undefined
};
return await printTicket(printData);
};
return {
isPrinting,
printTicket,
printTicketFromPatient,
generateQRCode
};
};
+54
View File
@@ -0,0 +1,54 @@
// composables/useVisitAPI.ts
export interface VisitStats {
today_patients: number;
today_active_queues_by_service: Record<string, number>;
average_waiting_seconds: number;
monthly_trend: Array<{
count: number;
month: string;
payment_type: string;
}>;
total_by_payment_type: Record<string, number>;
payment_type_distribution_percent: Record<string, number>;
average_waiting_by_service_seconds: Record<string, number>;
from: string;
to: string;
}
export const useVisitAPI = () => {
const config = useRuntimeConfig();
// We use the configured external API URL directly
const statsURL = `${config.public.externalApiBaseUrl}/visit/stats`;
/**
* Fetch visit statistics
*/
const fetchStats = async (filters: { from?: string; to?: string; service_code?: string; payment_type?: string } = {}): Promise<VisitStats | null> => {
try {
const queryParams = new URLSearchParams();
if (filters.from) queryParams.append('from', filters.from);
if (filters.to) queryParams.append('to', filters.to);
if (filters.service_code) queryParams.append('service_code', filters.service_code);
if (filters.payment_type) queryParams.append('payment_type', filters.payment_type);
const url = `${statsURL}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const response = await $fetch<{ message: string; data: VisitStats }>(url, {
method: 'GET',
});
if (response && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching visit stats:', error);
return null;
}
};
return {
fetchStats,
};
};
+205
View File
@@ -0,0 +1,205 @@
// composables/useWebSocket.ts
import { ref, computed, onUnmounted, getCurrentInstance, toValue } from 'vue'
export interface WebSocketConfig {
url: string | any
clientId: string | any
onMessage?: (data: any) => void
onOpen?: () => void
onClose?: () => void
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
fallbackPostUrl?: string // Optional: use a specific URL for the POST fallback (e.g., a proxy)
}
export interface WebSocketMessage {
to_client: string
data: any
}
export const useWebSocket = (config: WebSocketConfig) => {
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
const currentUrl = computed(() => toValue(config.url))
const currentClientId = computed(() => toValue(config.clientId))
const wsUrl = computed(() => {
let baseUrl = currentUrl.value
// Automatically upgrade to secure protocols if caller uses HTTPS
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'wss://')
} else if (baseUrl.startsWith('http://')) {
baseUrl = baseUrl.replace('http://', 'https://')
}
}
const url = new URL(baseUrl)
url.searchParams.set('client_id', currentClientId.value)
return url.toString()
})
const clearHandlers = (wsInstance: WebSocket | null) => {
if (wsInstance) {
wsInstance.onopen = null
wsInstance.onmessage = null
wsInstance.onclose = null
wsInstance.onerror = null
}
}
const connect = () => {
try {
// Clear any pending reconnect timer first
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
if (ws.value) {
if (ws.value.readyState !== WebSocket.CLOSED) {
console.log('🔌 Closing existing WebSocket before new connection...')
clearHandlers(ws.value)
ws.value.close()
}
ws.value = null
}
const connectionUrl = wsUrl.value
ws.value = new WebSocket(connectionUrl)
ws.value.onopen = () => {
console.log('✅ WebSocket connected:', currentClientId.value)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
config.onMessage?.(data)
} catch (error) {
console.error('Error parsing WebSocket message:', error)
}
}
ws.value.onclose = () => {
console.log('❌ WebSocket closed:', currentClientId.value)
isConnected.value = false
config.onClose?.()
// Attempt to reconnect only if we didn't just reach max attempts
// The reconnectTimer is cleared in disconnect() and at start of connect()
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
reconnectTimer.value = setTimeout(() => {
connect()
}, interval)
} else {
console.error('Max reconnection attempts reached')
}
}
ws.value.onerror = (error) => {
console.error('⚠️ WebSocket error:', error)
config.onError?.(error)
}
} catch (error) {
console.error('❌ Error creating WebSocket:', error)
config.onError?.(error as Event)
}
}
const disconnect = () => {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
if (ws.value) {
console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
clearHandlers(ws.value)
ws.value.close()
ws.value = null
}
isConnected.value = false
reconnectAttempts.value = 0
}
const sendMessage = (message: any) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(message))
return true
}
console.warn('WebSocket is not connected')
return false
}
// Send data via POST to WebSocket server
const sendViaPost = async (message: WebSocketMessage) => {
try {
let postUrl = ''
if (config.fallbackPostUrl) {
// Use the explicitly provided fallback URL (likely a relative proxy path)
postUrl = config.fallbackPostUrl
} else {
// Derive post URL from WebSocket URL (legacy behavior)
let baseUrl = currentUrl.value
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
baseUrl = baseUrl.replace('wss://', 'https://')
}
const urlObj = new URL(baseUrl)
postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
}
const response = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
})
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
console.error('❌ POST Error response:', errorText)
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
}
const result = await response.json().catch(() => ({ success: true }))
return result
} catch (error) {
console.error('❌ Error sending message via POST:', error)
throw error
}
}
if (getCurrentInstance()) {
onUnmounted(() => {
disconnect()
})
}
return {
ws: computed(() => ws.value),
isConnected: computed(() => isConnected.value),
connect,
disconnect,
sendMessage,
sendViaPost,
reconnectAttempts: computed(() => reconnectAttempts.value),
}
}
+5
View File
@@ -0,0 +1,5 @@
// Default avatar image path
// Simpan gambar avatar default di: assets/images/default-avatar.png
import defaultAvatarImage from '@/assets/images/default-avatar.png'
export const DEFAULT_AVATAR = defaultAvatarImage
+23
View File
@@ -0,0 +1,23 @@
export const PRIMARY_COLOR = '#1565C0'
export const SECONDARY_COLOR = '#FB8C00'
export const QR_CODE_ID = 'qr-reader'
export const SCAN_DEBOUNCE_MS = 2000 // 2 detik debounce untuk mencegah scan berulang
export const HISTORY_STORAGE_KEY = 'checkin_history'
export const SCANNED_QR_STORAGE_KEY = 'scanned_qr_data'
export const SUCCESSFUL_SCANS_KEY = 'successful_qr_scans'
export const MAX_HISTORY_ITEMS = 100
export const MAX_SCANNED_QR_ITEMS = 50
export const HISTORY_STATUS_OPTIONS = [
{ title: 'Berhasil', value: 'success' },
{ title: 'Gagal', value: 'failed' },
{ title: 'Pending', value: 'pending' }
] as const
export const QR_STATUS_OPTIONS = [
{ title: 'Diizinkan Check-in', value: 'ALLOWED' },
{ title: 'Belum Diizinkan', value: 'NOT_ALLOWED' }
] as const
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://10.10.150.175:3000",
supportFile: false,
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
},
component: {
devServer: {
framework: "vue",
bundler: "vite",
},
},
});
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff. Show more