Hakakses dan verifikasi akun

This commit is contained in:
Fanrouver
2026-01-06 14:26:19 +07:00
parent 7c8ba8d638
commit 71b9de8d21
11 changed files with 8872 additions and 711 deletions
+700
View File
@@ -0,0 +1,700 @@
# 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.150.131: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.
@@ -1,83 +1,93 @@
<template>
<v-card-text class="pa-0 bg-white">
<v-list lines="two" class="pa-0">
<v-list-item
v-for="patient in patients"
:key="patient.rm"
class="patient-item"
:class="{ 'verified-item': patient.status === 'Terverifikasi' }"
>
<template #prepend>
<v-avatar
:color="patient.status === 'Terverifikasi' ? 'secondary-600' : 'primary-600'"
size="64"
class="patient-avatar"
>
<v-icon size="36" color="white">
{{ patient.status === 'Terverifikasi' ? 'mdi-check-decagram' : 'mdi-clock-alert' }}
</v-icon>
</v-avatar>
</template>
<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>
<v-list-item-title class="patient-name">
{{ patient.nama }}
</v-list-item-title>
<!-- Nama -->
<td class="table-cell">
<span class="patient-name">{{ patient.nama }}</span>
</td>
<v-list-item-subtitle class="mt-2">
<v-row dense class="patient-info">
<v-col cols="12" sm="3" md="2" class="py-1">
<v-chip size="default" color="primary-200" class="chip-rm" variant="flat">
<v-icon start size="18" color="secondary-600">mdi-file-document</v-icon>
<span class="chip-rm-text">{{ patient.rm }}</span>
</v-chip>
</v-col>
<v-col cols="12" sm="5" md="6" class="py-1 info-item">
<v-icon size="18" class="mr-2" color="neutral-600">mdi-map-marker</v-icon>
<!-- 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>
</v-col>
<v-col cols="12" sm="4" md="4" class="py-1 info-item">
<v-icon size="18" class="mr-2" color="neutral-600">mdi-phone</v-icon>
<span>{{ patient.telepon || 'Belum diisi' }}</span>
</v-col>
</v-row>
</v-list-item-subtitle>
</div>
</td>
<template #append>
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
color="primary-600"
size="x-large"
@click="$emit('verify', patient)"
prepend-icon="mdi-qrcode-scan"
variant="flat"
class="action-btn"
rounded="xl"
>
VERIFIKASI
</v-btn>
<v-chip
v-else
color="secondary-600"
size="x-large"
variant="flat"
class="verified-chip"
rounded="xl"
>
<v-icon start size="24">mdi-shield-check</v-icon>
VERIFIED
</v-chip>
</template>
</v-list-item>
<v-list-item v-if="patients.length === 0">
<v-list-item-title class="empty-state">
{{ emptyMessage }}
</v-list-item-title>
</v-list-item>
</v-list>
<!-- 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>
@@ -99,12 +109,14 @@ defineEmits(['verify']);
<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;
@@ -116,63 +128,129 @@ $font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-extra-bold: 800;
.patient-item {
border-bottom: 1px solid $neutral-400;
padding: 24px;
.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;
overflow: hidden;
&: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;
}
}
.patient-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background: $primary-600;
transform: scaleY(0);
transition: transform 0.3s ease;
}
.patient-item:hover::before {
transform: scaleY(1);
}
.patient-item:hover {
background: $primary-100 !important;
transform: translateX(4px);
}
.verified-item {
background: $secondary-200 !important;
}
.verified-item::before {
background: $secondary-600 !important;
}
.verified-item:hover {
background: $secondary-300 !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: 24px;
font-size: 18px;
font-weight: $font-weight-extra-bold;
color: $neutral-900;
margin-bottom: 8px;
font-family: $font-family-base;
}
.patient-info {
font-size: 14px;
font-family: $font-family-base;
}
@@ -195,51 +273,93 @@ $font-weight-extra-bold: 800;
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: 16px;
color: $neutral-100;
padding: 12px 32px;
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.25);
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;
}
.verified-chip {
.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: 16px;
color: $neutral-100;
padding: 12px 24px;
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 {
text-align: center;
padding: 32px 0;
font-size: 18px;
color: $neutral-600;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 960px) {
.patient-item {
padding: 20px;
.patient-table {
display: block;
overflow-x: auto;
}
.action-btn {
font-size: 14px;
padding: 10px 20px;
.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: 56px !important;
height: 56px !important;
width: 44px !important;
height: 44px !important;
}
.patient-name {
font-size: 20px;
font-size: 16px;
}
.info-item {
font-size: 12px;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+296 -78
View File
@@ -1,27 +1,43 @@
<template>
<v-container fluid class="pa-0">
<div class="pa-4 pb-0">
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding"> <v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
<v-icon left class="mr-2" color="blue-darken-3">mdi-heart-pulse</v-icon>
Monitoring Pasien
</v-toolbar-title>
<v-spacer></v-spacer>
<v-chip color="blue-darken-1" text-color="white" class="font-weight-medium admin-chip"> <v-icon start>mdi-account-tie</v-icon>
Admin
</v-chip>
</v-toolbar>
</div>
<v-container>
<v-card>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-heart-pulse</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Monitoring Pasien</h2>
<p class="page-subtitle">{{ currentDate }} - Manajemen Data Pasien</p>
</div>
</div>
<div class="header-stats">
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-account-group</v-icon>
{{ totalCount }} Total
</v-chip>
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-clock-outline</v-icon>
{{ waitingCount }} Menunggu
</v-chip>
<v-chip color="white" variant="flat" class="stat-chip">
<v-icon start size="16">mdi-check-circle</v-icon>
{{ doneCount }} Selesai
</v-chip>
</div>
</div>
</div>
<v-container class="mt-6">
<v-card flat class="mb-4">
<!-- Tabs -->
<v-card-text class="tabs-section">
<v-tabs
v-model="activeTab"
color="primary"
align-tabs="start"
show-arrows
class="mb-4"
class="status-tabs"
>
<v-tab value="all" @click="filterByStatus('all')">
Semua Pasien <v-badge color="grey-lighten-1" :content="totalCount" inline class="ml-2"></v-badge>
@@ -32,56 +48,99 @@
<v-tab value="Diperiksa Dokter" @click="filterByStatus('Diperiksa Dokter')">
Diperiksa Dokter <v-badge color="green-lighten-1" :content="examiningCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Selesai Pelayanan" @click="filterByStatus('Selesai Pelayanan')">
<v-tab value="Selesai Pelayanan" @click="filterByStatus('Selesai Pelayanan')">
Selesai Pelayanan <v-badge color="blue-lighten-1" :content="doneCount" inline class="ml-2"></v-badge>
</v-tab>
</v-tabs>
</v-card>
<v-card class="mb-6 pa-4 elevation-1">
<v-row class="align-center">
<v-col cols="12" sm="3">
<v-text-field label="No. RM" v-model="filters.rm_number" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
<!-- <v-col cols="12" sm="3">
<v-text-field label="No. Kode QR" v-model="filters.qr_code" variant="outlined" density="compact" hide-details></v-text-field>
</v-col> -->
<v-col cols="12" sm="2">
<v-text-field label="No. Antrean" v-model="filters.queue_number" variant="outlined" density="compact" hide-details type="number"></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-select label="Layanan" v-model="filters.service" :items="['Klinik Jiwa', 'Radiologi', 'Fisioterapi', 'Klinik Umum']" variant="outlined" density="compact" hide-details clearable></v-select>
</v-col>
<v-col cols="12" sm="2">
<v-btn color="blue-lighten-1" block height="40" @click="applyFilters">Cari</v-btn>
</v-col>
</v-row>
</v-card>
</v-card-text>
<v-data-table
:headers="headers"
:items="paginatedPatients"
:items-per-page="10"
class="elevation-1"
:search="currentSearchTerm"
>
<template v-slot:item.status="{ item }">
<v-chip :color="getStatusColor(item.status)" size="small" class="font-weight-medium" label>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn size="small" color="orange-darken-1" dark @click="viewPatient(item.id)">
Lihat
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="info" icon="mdi-information-outline">
Tidak ada data pasien untuk ditampilkan.
</v-alert>
</template>
</v-data-table>
</v-container>
<!-- Filter Section -->
<v-card-text class="filter-section">
<div class="filter-card">
<v-row class="align-center">
<v-col cols="12" sm="3">
<v-text-field
label="No. RM"
v-model="filters.rm_number"
variant="outlined"
density="compact"
hide-details
class="input-field"
></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-text-field
label="No. Antrean"
v-model="filters.queue_number"
variant="outlined"
density="compact"
hide-details
type="number"
class="input-field"
></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-select
label="Layanan"
v-model="filters.service"
:items="['Klinik Jiwa', 'Radiologi', 'Fisioterapi', 'Klinik Umum']"
variant="outlined"
density="compact"
hide-details
clearable
class="input-field"
></v-select>
</v-col>
<v-col cols="12" sm="2">
<v-btn
color="success"
block
height="40"
@click="applyFilters"
class="btn-search"
variant="flat"
>
<v-icon left size="18">mdi-magnify</v-icon>
Cari
</v-btn>
</v-col>
</v-row>
</div>
</v-card-text>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="headers"
:items="paginatedPatients"
:items-per-page="10"
class="elevation-0 data-table"
:search="currentSearchTerm"
>
<template v-slot:item.status="{ item }">
<v-chip :color="getStatusColor(item.status)" size="small" class="font-weight-medium status-chip" label>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
@click="viewPatient(item.id)"
class="btn-view"
variant="flat"
>
<v-icon size="16" left>mdi-eye</v-icon>
Lihat
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="info" icon="mdi-information-outline">
Tidak ada data pasien untuk ditampilkan.
</v-alert>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</template>
@@ -95,6 +154,14 @@ definePageMeta({
const router = useRouter();
// Get current date formatted
const currentDate = computed(() => {
const date = new Date();
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
return `${days[date.getDay()]}, ${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
});
// === STATE MANAGEMENT ===
const activeTab = ref('all');
@@ -224,29 +291,180 @@ const viewPatient = (patientId) => {
};
</script>
<style scoped>
.floating-toolbar {
border-radius: 9999px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
height: 64px !important;
<style scoped lang="scss">
// Colors from Design System
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$success-700: #1B6E53;
$success-600: #009262;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// 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;
}
/* Jarak PENTING: Tambahkan padding horizontal ke toolbar */
.floating-toolbar.custom-padding {
padding: 0 16px; /* Memberi jarak 16px di kiri dan kanan */
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
}
/* Penyesuaian agar teks dan ikon kontras dengan soft blue background */
.v-toolbar-title {
margin-left: 0 !important; /* Reset default margin */
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
}
/* Pastikan chip Admin tidak mepet */
.admin-chip {
margin-right: 0 !important; /* Reset default margin */
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 36px;
line-height: 44px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
.header-stats {
display: flex;
align-items: center;
gap: 8px;
}
.stat-chip {
color: $success-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// TABS SECTION
// ============================================
.tabs-section {
padding: 24px 24px 0 24px !important;
background: $neutral-100;
}
.status-tabs {
font-family: $font-family-base;
}
.v-tab.v-tab--selected {
font-weight: 600;
font-weight: $font-weight-semibold;
}
// ============================================
// FILTER SECTION
// ============================================
.filter-section {
padding: 24px !important;
background: $neutral-100;
}
.filter-card {
background: $neutral-300;
padding: 20px;
border-radius: 12px;
border: 1px solid $neutral-400;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-search {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// DATA TABLE
// ============================================
.data-table {
font-family: $font-family-base;
}
.status-chip {
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-view {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.header-stats {
width: 100%;
flex-wrap: wrap;
}
.filter-card {
padding: 16px;
}
}
</style>
+215 -67
View File
@@ -1,30 +1,44 @@
<template>
<v-container fluid class="pa-0">
<v-container>
<v-card>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-account-details</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Informasi Pasien</h2>
<p class="page-subtitle">{{ currentDate }} - Detail Data Pasien</p>
</div>
</div>
<div class="header-stats">
<v-chip
v-if="patient.status"
color="white"
variant="flat"
class="stat-chip mr-2"
>
<v-icon start size="16">mdi-progress-check</v-icon>
{{ patient.status }}
</v-chip>
<v-btn
color="white"
elevation="0"
class="btn-back"
@click="$router.go(-1)"
>
<v-icon left size="20">mdi-arrow-left</v-icon>
Kembali
</v-btn>
</div>
</div>
</div>
<div class="pa-4 pb-0">
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding">
<v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
<v-icon left class="mr-2" color="blue-darken-3">mdi-account-details</v-icon>
Informasi Pasien
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="orange-darken-1"
dark
variant="flat"
size="small"
@click="$router.go(-1)"
prepend-icon="mdi-arrow-left"
class="admin-chip"
>
Kembali
</v-btn>
</v-toolbar>
</div>
<v-container class="mt-6">
<v-card class="mb-6 pa-6 elevation-4 rounded-lg" color="white">
<!-- Content -->
<v-card-text class="content-section">
<v-card class="mb-6 patient-info-card" elevation="0">
<v-row class="align-center mb-4">
<v-col cols="12" md="8">
<div class="d-flex align-center mb-2">
@@ -42,15 +56,9 @@
</div>
</div>
</v-col>
<v-col cols="12" md="4" class="d-flex justify-md-end">
<v-chip :color="patient.status === 'Aktif' ? 'green-darken-1' : 'grey-darken-1'" size="x-large" class="font-weight-bold px-6 py-2">
<v-icon start>mdi-progress-check</v-icon>
Status: {{ patient.status }}
</v-chip>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<v-divider class="my-4" />
<v-row>
<v-col cols="12" sm="6" md="4" class="py-1">
@@ -84,17 +92,17 @@
</v-card>
<h2 class="text-h5 mb-4 font-weight-bold text-blue-darken-3">Tiket Pelayanan Aktif</h2>
<h2 class="section-title">Tiket Pelayanan Aktif</h2>
<v-row class="ticket-row">
<v-col
v-for="ticket in patient.activeTickets"
:key="ticket.title"
cols="12"
sm="6"
md="4"
lg="3"
v-for="ticket in patient.activeTickets"
:key="ticket.title"
lg="3"
>
<MonitorPasienTicketCard
:title="ticket.title"
@@ -105,24 +113,33 @@
</v-col>
<v-col v-if="!patient.activeTickets || patient.activeTickets.length === 0" cols="12">
<v-card class="pa-6 text-center" variant="tonal" color="grey-lighten-2">
<v-icon size="48" color="grey">mdi-ticket-off-outline</v-icon>
<div class="text-subtitle-1 text-grey-darken-1 mt-2">Pasien ini tidak memiliki tiket pelayanan aktif saat ini.</div>
</v-card>
<v-card class="empty-ticket-card" elevation="0">
<v-icon size="48" color="grey">mdi-ticket-off-outline</v-icon>
<div class="empty-text">Pasien ini tidak memiliki tiket pelayanan aktif saat ini.</div>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
definePageMeta({
middleware:['auth']
})
// Get current date formatted
const currentDate = computed(() => {
const date = new Date();
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
return `${days[date.getDay()]}, ${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
});
// ===============================================
// JSON MOCK DATA STRUCTURE (Tetap sama)
// ===============================================
@@ -255,44 +272,175 @@ const fetchPatientData = () => {
onMounted(fetchPatientData);
</script>
<style scoped>
/* =============================================== */
/* FLOATING TOOLBAR STYLES */
/* =============================================== */
.floating-toolbar {
border-radius: 9999px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
height: 64px !important;
<style scoped lang="scss">
// Colors from Design System
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$success-700: #1B6E53;
$success-600: #009262;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// 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;
}
.floating-toolbar.custom-padding {
padding: 0 16px;
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
}
.admin-chip {
margin-right: 0 !important;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
}
/* =============================================== */
/* TICKET CARD ALIGNMENT STYLES (Fix Tinggi Card) */
/* =============================================== */
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 36px;
line-height: 44px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
.header-stats {
display: flex;
align-items: center;
gap: 8px;
}
.stat-chip {
color: $success-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-back {
font-weight: $font-weight-semibold;
text-transform: none;
letter-spacing: 0.5px;
font-size: 16px;
line-height: 24px;
color: $success-600 !important;
}
// ============================================
// CONTENT SECTION
// ============================================
.content-section {
padding: 24px !important;
background: $neutral-100;
}
.patient-info-card {
background: $neutral-300;
padding: 24px;
border-radius: 12px;
border: 1px solid $neutral-400;
margin-bottom: 24px;
}
.section-title {
font-size: 24px;
line-height: 32px;
font-weight: $font-weight-semibold;
color: $success-600;
margin-bottom: 16px;
}
// ============================================
// TICKET ROW
// ============================================
.ticket-row {
/* Mengaktifkan flex container pada row */
display: flex;
/* Agar item (v-col) menyesuaikan tinggi item tertinggi */
align-items: stretch;
}
/* Pastikan v-col children juga menggunakan full height */
.ticket-row > .v-col {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
/* =============================================== */
/* TIMELINE STYLES (Jika ada) */
/* =============================================== */
.v-timeline-item {
padding-bottom: 8px !important;
.empty-ticket-card {
background: $neutral-300;
padding: 48px 24px;
border-radius: 12px;
text-align: center;
border: 1px solid $neutral-400;
}
.empty-text {
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-700;
margin-top: 16px;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.header-stats {
width: 100%;
flex-wrap: wrap;
}
.patient-info-card {
padding: 16px;
}
}
</style>
+400 -335
View File
@@ -45,74 +45,22 @@
</div>
<v-card-text class="dialog-content">
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Pilih User (Optional)</v-label>
<v-select
v-model="selectedUserId"
:items="availableUsers"
item-title="namaLengkap"
item-value="id"
placeholder="Pilih User untuk auto-fill data"
variant="outlined"
<v-col cols="12">
<v-alert
type="info"
variant="tonal"
density="comfortable"
class="mt-1"
@update:model-value="fillUserData"
clearable
class="mb-4"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="`${item.raw.namaLengkap} (${item.raw.namaUser})`" :subtitle="item.raw.tipeUser"></v-list-item>
</template>
</v-select>
<v-alert-title>Group-Based Access</v-alert-title>
Hak akses akan diberikan ke semua user yang memiliki group dan role yang dipilih.
Setiap user dapat memiliki multiple hak akses dari berbagai group.
</v-alert>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">User ID</v-label>
<v-text-field
v-model="editedItem.userId"
placeholder="User ID"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
placeholder="Nama Lengkap"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
placeholder="Nama User"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Tipe User</v-label>
<v-select
v-model="selectedTipeUser"
:items="availableTipeUsers"
placeholder="Pilih Tipe User"
variant="outlined"
density="comfortable"
class="mt-1"
@update:model-value="fillUserDataByTipeUser"
clearable
></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Role</v-label>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Role <span class="text-error">*</span></v-label>
<v-select
v-model="editedItem.role"
:items="availableRoles"
@@ -120,20 +68,54 @@
variant="outlined"
density="comfortable"
class="mt-1"
required
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Group</v-label>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Group <span class="text-error">*</span></v-label>
<v-select
v-model="editedItem.group"
:items="availableGroups"
v-model="selectedGroup"
:items="availableGroupPaths"
item-title="label"
item-value="value"
placeholder="Pilih Group"
variant="outlined"
density="comfortable"
class="mt-1"
></v-select>
required
@update:model-value="onGroupSelected"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.label" :subtitle="item.raw.path"></v-list-item>
</template>
</v-select>
<div v-if="selectedGroupUsers.length > 0" class="mt-2">
<v-chip size="small" color="info" class="mr-1 mb-1">
{{ selectedGroupUsers.length }} user akan mendapatkan hak akses ini
</v-chip>
</div>
</v-col>
<v-col cols="12" md="4">
</v-row>
<v-row v-if="selectedGroupUsers.length > 0">
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis mb-2">User yang akan mendapatkan hak akses:</v-label>
<v-card variant="outlined" class="pa-3">
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="user in selectedGroupUsers"
:key="user.id"
size="small"
color="primary"
variant="tonal"
>
{{ user.namaLengkap || user.namaUser }} ({{ user.namaUser }})
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User (Optional)</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
@@ -423,35 +405,23 @@
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Role</v-label>
<v-text-field
v-model="editedItem.role"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Group</v-label>
<v-text-field
v-model="editedItem.group"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis mb-2">Hak Akses (Multiple Groups)</v-label>
<v-card variant="outlined" class="pa-3">
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="(hakAkses, idx) in viewModeHakAksesList"
:key="idx"
size="small"
color="primary"
variant="tonal"
>
<v-icon start size="14">mdi-account-group</v-icon>
{{ hakAkses.group }} / {{ hakAkses.role }}
</v-chip>
<span v-if="viewModeHakAksesList.length === 0" class="text-grey">Tidak ada hak akses</span>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
@@ -589,20 +559,7 @@
<!-- Filter bar -->
<v-row class="mb-4" dense>
<v-col cols="12" sm="3">
<v-select
v-model="filterUserId"
:items="filterUserIdOptions"
item-title="label"
item-value="value"
label="Filter User ID"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-col cols="12" sm="4">
<v-select
v-model="filterTipeUser"
:items="availableTipeUsers"
@@ -613,7 +570,7 @@
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-col cols="12" sm="4">
<v-select
v-model="filterRole"
:items="availableRoles"
@@ -624,7 +581,7 @@
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-col cols="12" sm="4">
<v-select
v-model="filterGroup"
:items="availableGroups"
@@ -670,10 +627,10 @@
<div class="text-center">{{ item.id }}</div>
</template>
<!-- Tampilkan User ID virtual yang dikelompokkan per tipe user -->
<!-- Tampilkan User ID dengan format generated -->
<template v-slot:item.userId="{ item }">
<div class="text-center">
{{ formatDisplayUserId(item) }}
{{ generateDisplayUserId(item) }}
</div>
</template>
@@ -689,40 +646,30 @@
<span v-else class="text-grey">-</span>
</template>
<!-- Role dengan chip -->
<template v-slot:item.role="{ item }">
<v-chip
v-if="item.role"
color="blue-lighten-1"
size="small"
>
{{ item.role }}
</v-chip>
<span v-else class="text-grey">-</span>
</template>
<!-- Group dengan chip -->
<template v-slot:item.group="{ item }">
<v-chip
v-if="item.group"
color="green-lighten-1"
size="small"
>
{{ item.group }}
</v-chip>
<span v-else class="text-grey">-</span>
</template>
<!-- Nama Tipe User dengan chip -->
<template v-slot:item.namaTipeUser="{ item }">
<v-chip
v-if="item.namaTipeUser"
color="orange-lighten-1"
size="small"
>
{{ item.namaTipeUser }}
</v-chip>
<span v-else class="text-grey">-</span>
<!-- Hak Akses List - menampilkan multiple hak akses per user -->
<template v-slot:item.hakAksesList="{ item }">
<div class="d-flex flex-wrap gap-1">
<v-tooltip
v-for="(hakAkses, idx) in item.hakAksesList"
:key="idx"
location="top"
>
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
:color="hakAkses.isGroupBased ? 'primary' : 'grey'"
size="small"
variant="tonal"
class="mb-1"
>
<v-icon start size="14">{{ hakAkses.isGroupBased ? 'mdi-account-group' : 'mdi-account' }}</v-icon>
{{ hakAkses.group }} / {{ hakAkses.role }}
</v-chip>
</template>
<span>{{ hakAkses.groupPath || hakAkses.group }} - {{ hakAkses.role }}</span>
</v-tooltip>
<span v-if="item.hakAksesList.length === 0" class="text-grey">-</span>
</div>
</template>
<template v-slot:bottom>
@@ -830,7 +777,7 @@ interface NavItem {
interface HakAksesData {
id: number;
userId?: string;
userId?: string; // Optional: untuk backward compatibility dan tracking
namaLengkap?: string;
namaUser?: string;
tipeUser?: string;
@@ -838,6 +785,9 @@ interface HakAksesData {
group: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
// New: untuk group-based access
isGroupBased?: boolean; // Flag untuk membedakan group-based vs individual
groupPath?: string; // Full group path untuk matching
}
interface BackendPermissionItem {
@@ -870,16 +820,14 @@ const navItemsStore = useNavItemsStore();
const draggableMenus = ref<NavItem[]>([]);
// Data table headers
// Kolom "No" dan "User ID" di-center agar sejajar dengan isi sel yang juga center
// Changed to show user with their multiple access rights
const headers = ref([
{ title: 'No', key: 'id' as const, align: 'center' as const },
{ title: 'User ID', key: 'userId' as const, sortable: true, align: 'center' as const },
{ title: 'Nama Lengkap', key: 'namaLengkap' as const, sortable: true },
{ title: 'Nama User', key: 'namaUser' as const, sortable: true },
{ title: 'Tipe User', key: 'tipeUser' as const, sortable: true },
{ title: 'Role', key: 'role' as const, sortable: true },
{ title: 'Group', key: 'group' as const, sortable: true },
{ title: 'Nama Tipe User', key: 'namaTipeUser' as const, sortable: true },
{ title: 'Hak Akses', key: 'hakAksesList' as const, sortable: false },
{ title: 'Aksi', align: 'center' as const, key: 'actions' as const, sortable: false },
]);
@@ -923,9 +871,34 @@ const availableUsers = ref<any[]>([]);
const availableTipeUsers = ref<string[]>([]);
const selectedUserId = ref<string | null>(null);
const selectedTipeUser = ref<string | null>(null);
const selectedGroup = ref<string | null>(null);
const selectedGroupUsers = ref<any[]>([]);
const isFetchingPermissions = ref(false);
const fetchedBackendData = ref<BackendPermissionItem[]>([]);
// Group paths dengan label yang lebih user-friendly
const availableGroupPaths = computed(() => {
const groupMap = new Map<string, { value: string; label: string; path: string }>();
availableUsers.value.forEach((user) => {
if (Array.isArray(user.groups)) {
user.groups.forEach((groupPath: string) => {
if (groupPath && !groupMap.has(groupPath)) {
const parts = groupPath.split('/').filter(Boolean);
const label = parts.length > 1 ? parts[1] : parts[0] || groupPath;
groupMap.set(groupPath, {
value: groupPath,
label: label,
path: groupPath,
});
}
});
}
});
return Array.from(groupMap.values()).sort((a, b) => a.label.localeCompare(b.label));
});
// --- Menu management logic ---
const buildMenuTemplate = (items: NavItem[]): HakAksesMenu[] => {
const result: HakAksesMenu[] = [];
@@ -970,82 +943,98 @@ const showingEntriesText = computed(() => {
});
// Filter state
const filterUserId = ref<number | null>(null);
const filterTipeUser = ref<string | null>(null);
const filterRole = ref<string | null>(null);
const filterGroup = ref<string | null>(null);
// Opsi dropdown User ID (pakai ID baris + label User ID virtual)
const filterUserIdOptions = computed(() =>
Array.from(
new Map(
allHakAksesData.value.map((item) => [
item.id,
{
value: item.id,
label: formatDisplayUserId(item),
},
]),
).values(),
),
);
// Data tabel setelah difilter oleh dropdown
// Group hak akses data by user untuk menampilkan multiple hak akses per user
const groupedHakAksesByUser = computed(() => {
const userMap = new Map<string, {
userId: string;
namaLengkap: string;
namaUser: string;
tipeUser: string;
hakAksesList: HakAksesData[];
}>();
allHakAksesData.value.forEach((item) => {
// Untuk group-based, kita perlu mencari user yang memiliki group tersebut
if (item.isGroupBased && item.groupPath) {
// Find users with this group
const usersWithGroup = availableUsers.value.filter((u: any) =>
Array.isArray(u.groups) && u.groups.includes(item.groupPath)
);
usersWithGroup.forEach((user: any) => {
const key = user.id;
if (!userMap.has(key)) {
userMap.set(key, {
userId: user.id,
namaLengkap: user.namaLengkap || '',
namaUser: user.namaUser || '',
tipeUser: user.tipeUser || '',
hakAksesList: [],
});
}
const userData = userMap.get(key)!;
userData.hakAksesList.push(item);
});
} else if (item.userId) {
// Legacy: individual user access
const key = item.userId;
if (!userMap.has(key)) {
userMap.set(key, {
userId: item.userId,
namaLengkap: item.namaLengkap || '',
namaUser: item.namaUser || '',
tipeUser: item.tipeUser || '',
hakAksesList: [],
});
}
const userData = userMap.get(key)!;
userData.hakAksesList.push(item);
}
});
return Array.from(userMap.values()).map((userData, index) => ({
id: index + 1,
...userData,
}));
});
// Data tabel setelah difilter oleh dropdown (menggunakan grouped data)
const filteredHakAksesData = computed(() =>
allHakAksesData.value.filter((item) => {
const matchUserId = !filterUserId.value || item.id === filterUserId.value;
groupedHakAksesByUser.value.filter((item) => {
const matchTipeUser = !filterTipeUser.value || (item.tipeUser || '') === filterTipeUser.value;
const matchRole = !filterRole.value || item.role === filterRole.value;
const matchGroup = !filterGroup.value || item.group === filterGroup.value;
return matchUserId && matchTipeUser && matchRole && matchGroup;
const matchRole = !filterRole.value ||
item.hakAksesList.some(ha => ha.role === filterRole.value);
const matchGroup = !filterGroup.value ||
item.hakAksesList.some(ha => ha.group === filterGroup.value || ha.groupPath === filterGroup.value);
return matchTipeUser && matchRole && matchGroup;
}),
);
// Generate display User ID grouped by tipeUser
const getTipeUserCode = (tipeUser?: string): string => {
if (!tipeUser) return 'USR';
const normalized = tipeUser.toLowerCase().trim();
if (normalized.includes('super') && normalized.includes('admin')) return 'SA';
if (normalized === 'admin') return 'ADM';
if (normalized.includes('loket')) return 'LOK';
if (normalized.includes('klinik')) return 'KLN';
if (normalized.includes('barcode')) return 'BAR';
if (normalized.includes('inova')) return 'INV';
if (normalized.includes('ranap')) return 'RNP';
if (normalized.includes('report')) return 'RPT';
if (normalized.includes('farmasi')) return 'FRM';
if (normalized.includes('manager')) return 'MGR';
// Default: ambil inisial dari tiap kata, maksimal 3 huruf
const initials = normalized
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0]?.toUpperCase() || '')
.join('')
.slice(0, 3);
return initials || 'USR';
};
const formatDisplayUserId = (item: HakAksesData): string => {
const tipe = item.tipeUser || 'Unknown';
const prefix = getTipeUserCode(tipe);
// Kelompokkan berdasarkan tipeUser, urut berdasarkan userId untuk konsistensi
const sameTypeItems = allHakAksesData.value
.filter((i) => (i.tipeUser || 'Unknown') === tipe)
.slice()
.sort((a, b) => (a.userId || '').localeCompare(b.userId || ''));
const index = sameTypeItems.findIndex(
(i) => i.userId === item.userId && (i.tipeUser || 'Unknown') === tipe
// Generate display User ID dengan format US-001, US-002, dll
// Berdasarkan urutan di filteredHakAksesData (bukan user ID asli)
const generateDisplayUserId = (item: any): string => {
if (!item || !item.userId) {
return 'US-000';
}
// Cari index di filteredHakAksesData
const index = filteredHakAksesData.value.findIndex(
(i) => i.userId === item.userId
);
const number = index >= 0 ? index + 1 : sameTypeItems.length + 1;
return `${prefix}-${String(number).padStart(4, '0')}`;
// Jika tidak ditemukan, cari di groupedHakAksesByUser
const finalIndex = index >= 0
? index
: groupedHakAksesByUser.value.findIndex((i) => i.userId === item.userId);
const number = finalIndex >= 0 ? finalIndex + 1 : 1;
return `US-${String(number).padStart(3, '0')}`;
};
const formTitle = computed(() => {
@@ -1120,33 +1109,136 @@ const showAddForm = () => {
viewMode.value = 'add';
};
const viewItem = (item: HakAksesData) => {
editedItem.value = { ...item };
const viewModeHakAksesList = ref<HakAksesData[]>([]);
const viewItem = (item: any) => {
// Item is from groupedHakAksesByUser
// For view, we'll show all hak akses for this user
// Store the list of hak akses for display
viewModeHakAksesList.value = item.hakAksesList || [];
if (item.hakAksesList && item.hakAksesList.length > 0) {
// Merge all hak akses menus (OR logic - if any access allows, then allow)
const baseMenuItems = buildMenuTemplate(navItemsStore.navItems);
const mergedMenu: HakAksesMenu[] = baseMenuItems.map(menu => {
let canAccess = false;
let canView = false;
let canAdd = false;
let canEdit = false;
let canDelete = false;
item.hakAksesList.forEach((ha: HakAksesData) => {
const haMenu = ha.hakAksesMenu.find(m => m.name === menu.name);
if (haMenu) {
canAccess = canAccess || haMenu.canAccess;
canView = canView || haMenu.canView;
canAdd = canAdd || haMenu.canAdd;
canEdit = canEdit || haMenu.canEdit;
canDelete = canDelete || haMenu.canDelete;
}
});
return {
name: menu.name,
canAccess,
canView,
canAdd,
canEdit,
canDelete,
};
});
editedItem.value = {
id: item.id,
userId: item.userId,
namaLengkap: item.namaLengkap,
namaUser: item.namaUser,
tipeUser: item.tipeUser,
role: '', // Not used in view mode
group: '', // Not used in view mode
namaTipeUser: item.hakAksesList[0]?.namaTipeUser || '',
hakAksesMenu: mergedMenu,
};
} else {
editedItem.value = {
id: item.id,
userId: item.userId,
namaLengkap: item.namaLengkap,
namaUser: item.namaUser,
tipeUser: item.tipeUser,
role: '',
group: '',
namaTipeUser: '',
hakAksesMenu: [],
};
}
viewMode.value = 'view';
};
const editItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
selectedTipeUser.value = item.tipeUser || null;
viewMode.value = 'editName';
const editItem = (item: any) => {
// Item is now from groupedHakAksesByUser, so we need to edit the first hak akses or create new
// For now, we'll edit the group-based access if it exists
if (item.hakAksesList && item.hakAksesList.length > 0) {
// Find the group-based access for this user
const groupBasedAccess = item.hakAksesList.find((ha: HakAksesData) => ha.isGroupBased);
if (groupBasedAccess) {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === groupBasedAccess.id);
editedItem.value = { ...groupBasedAccess };
selectedGroup.value = groupBasedAccess.groupPath || null;
onGroupSelected(selectedGroup.value);
viewMode.value = 'editName';
return;
}
}
// Fallback: create new access
resetForm();
viewMode.value = 'add';
};
const editAccess = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
const baseMenuItems = buildMenuTemplate(navItemsStore.navItems);
const mergedMenuItems = mergePermissions(baseMenuItems, item.hakAksesMenu);
const editAccess = (item: any) => {
// Item is from groupedHakAksesByUser
// For edit access, we'll edit all group-based accesses for this user
// For simplicity, we'll edit the first one or create new
if (item.hakAksesList && item.hakAksesList.length > 0) {
const groupBasedAccess = item.hakAksesList.find((ha: HakAksesData) => ha.isGroupBased);
if (groupBasedAccess) {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === groupBasedAccess.id);
const baseMenuItems = buildMenuTemplate(navItemsStore.navItems);
const mergedMenuItems = mergePermissions(baseMenuItems, groupBasedAccess.hakAksesMenu);
editedItem.value = {
...item,
hakAksesMenu: mergedMenuItems
};
viewMode.value = 'editAccess';
editedItem.value = {
...groupBasedAccess,
hakAksesMenu: mergedMenuItems
};
selectedGroup.value = groupBasedAccess.groupPath || null;
onGroupSelected(selectedGroup.value);
viewMode.value = 'editAccess';
return;
}
}
// Fallback
resetForm();
viewMode.value = 'add';
};
const deleteItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
const deleteItem = (item: any) => {
// Item is from groupedHakAksesByUser
// We need to delete all group-based accesses for this user
// For now, we'll delete the first group-based access
if (item.hakAksesList && item.hakAksesList.length > 0) {
const groupBasedAccess = item.hakAksesList.find((ha: HakAksesData) => ha.isGroupBased);
if (groupBasedAccess) {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === groupBasedAccess.id);
showDeleteDialog.value = true;
return;
}
}
// Fallback
editedIndex.value = -1;
showDeleteDialog.value = false;
};
const closeDeleteDialog = () => {
@@ -1198,18 +1290,19 @@ const updateItemAccess = (updatedItem: HakAksesData & { backendPermissions?: Bac
const resetForm = () => {
editedItem.value = {
id: 0,
userId: '',
namaLengkap: '',
namaUser: '',
tipeUser: '',
role: '',
group: '',
groupPath: '',
namaTipeUser: '',
hakAksesMenu: buildMenuTemplate(navItemsStore.navItems),
isGroupBased: true,
};
selectedUserId.value = null;
selectedTipeUser.value = null;
selectedGroup.value = null;
selectedGroupUsers.value = [];
fetchedBackendData.value = [];
viewModeHakAksesList.value = [];
};
// Check if a permission is mapped to a menu
@@ -1225,72 +1318,28 @@ const getMappingStatus = (pagename: string): boolean => {
);
};
// Fill user data when user is selected by ID
const fillUserData = (userId: string | null) => {
if (!userId) {
editedItem.value.userId = '';
editedItem.value.namaLengkap = '';
editedItem.value.namaUser = '';
editedItem.value.tipeUser = '';
selectedTipeUser.value = null;
// Handle group selection
const onGroupSelected = (groupPath: string | null) => {
if (!groupPath) {
selectedGroupUsers.value = [];
editedItem.value.group = '';
editedItem.value.groupPath = '';
return;
}
const user = availableUsers.value.find(u => u.id === userId);
if (user) {
editedItem.value.userId = user.id;
editedItem.value.namaLengkap = user.namaLengkap || '';
editedItem.value.namaUser = user.namaUser || '';
editedItem.value.tipeUser = user.tipeUser || '';
selectedTipeUser.value = user.tipeUser || null;
// Auto-fill role and group if available
if (user.realmRoles && user.realmRoles.length > 0) {
editedItem.value.role = user.realmRoles[0];
}
if (user.groups && user.groups.length > 0) {
const groupPath = user.groups[0];
const parts = groupPath.split('/').filter(Boolean);
if (parts.length > 1) {
editedItem.value.group = parts[1];
} else if (parts.length === 1) {
editedItem.value.group = parts[0];
}
}
}
// Extract group name from path
const parts = groupPath.split('/').filter(Boolean);
editedItem.value.group = parts.length > 1 ? parts[1] : parts[0] || groupPath;
editedItem.value.groupPath = groupPath;
// Find all users with this group
selectedGroupUsers.value = availableUsers.value.filter((u: any) =>
Array.isArray(u.groups) && u.groups.includes(groupPath)
);
console.log(`📋 Selected group: ${groupPath}, found ${selectedGroupUsers.value.length} users`);
};
// Fill user data when tipe user is selected
const fillUserDataByTipeUser = (tipeUser: string | null) => {
if (!tipeUser) {
editedItem.value.tipeUser = '';
return;
}
editedItem.value.tipeUser = tipeUser;
// Find first user with this tipe user and fill their data
const user = availableUsers.value.find(u => u.tipeUser === tipeUser);
if (user) {
editedItem.value.userId = user.id;
editedItem.value.namaLengkap = user.namaLengkap || '';
editedItem.value.namaUser = user.namaUser || '';
// Auto-fill role and group if available
if (user.realmRoles && user.realmRoles.length > 0) {
editedItem.value.role = user.realmRoles[0];
}
if (user.groups && user.groups.length > 0) {
const groupPath = user.groups[0];
const parts = groupPath.split('/').filter(Boolean);
if (parts.length > 1) {
editedItem.value.group = parts[1];
} else if (parts.length === 1) {
editedItem.value.group = parts[0];
}
}
}
};
// Snackbar for notifications
const snackbar = ref({
@@ -1436,7 +1485,7 @@ const loadAvailableRolesAndGroups = async () => {
// Extract group name from path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groupsSet.add(parts[1]); // Get second part as group name
groupsSet.add(parts[1]); // Get second part as group name for filter
} else if (parts.length === 1) {
groupsSet.add(parts[0]);
}
@@ -1455,6 +1504,8 @@ const loadAvailableRolesAndGroups = async () => {
}
});
availableTipeUsers.value = Array.from(tipeUsersSet).sort();
console.log(`✅ Loaded ${availableUsers.value.length} users, ${availableRoles.value.length} roles, ${availableGroups.value.length} groups`);
} catch (error) {
console.error('Error loading roles and groups:', error);
}
@@ -1462,7 +1513,7 @@ const loadAvailableRolesAndGroups = async () => {
const saveItem = async () => {
// Validate required fields
if (!editedItem.value.role || !editedItem.value.group) {
if (!editedItem.value.role || !selectedGroup.value) {
snackbar.value = {
show: true,
message: 'Role dan Group wajib diisi!',
@@ -1497,51 +1548,65 @@ const saveItem = async () => {
editedItem.value.hakAksesMenu = buildMenuTemplate(navItemsStore.navItems);
}
// Create a clean copy of the item to save
const itemToSave: HakAksesData = {
id: editedItem.value.id || 0,
userId: editedItem.value.userId || '',
namaLengkap: editedItem.value.namaLengkap || '',
namaUser: editedItem.value.namaUser || '',
tipeUser: editedItem.value.tipeUser || '',
role: editedItem.value.role,
group: editedItem.value.group,
namaTipeUser: editedItem.value.namaTipeUser || editedItem.value.tipeUser || '',
hakAksesMenu: editedItem.value.hakAksesMenu || [],
};
// Check if this group+role combination already exists
const existingIndex = allHakAksesData.value.findIndex(
item => item.isGroupBased &&
item.groupPath === editedItem.value.groupPath &&
item.role === editedItem.value.role
);
console.log('💾 Saving item:', itemToSave);
console.log('📊 Current allHakAksesData length:', allHakAksesData.value.length);
if (editedIndex.value > -1) {
// Edit item
allHakAksesData.value[editedIndex.value] = { ...itemToSave };
console.log('✅ Item updated at index:', editedIndex.value);
if (editedIndex.value > -1 || existingIndex > -1) {
// Edit existing group-based access
const indexToUpdate = editedIndex.value > -1 ? editedIndex.value : existingIndex;
const existingItem = allHakAksesData.value[indexToUpdate];
// Update the group-based access entry
allHakAksesData.value[indexToUpdate] = {
...existingItem,
role: editedItem.value.role,
group: editedItem.value.group,
groupPath: editedItem.value.groupPath,
namaTipeUser: editedItem.value.namaTipeUser || '',
hakAksesMenu: editedItem.value.hakAksesMenu || [],
isGroupBased: true,
};
console.log('✅ Group-based access updated at index:', indexToUpdate);
snackbar.value = {
show: true,
message: 'Data hak akses berhasil diperbarui!',
message: `Hak akses untuk group "${editedItem.value.group}" berhasil diperbarui! ${selectedGroupUsers.value.length} user terpengaruh.`,
color: 'success',
timeout: 3000,
timeout: 4000,
};
} else {
// Add item with a new ID
// Create new group-based access entry
const newId = allHakAksesData.value.length > 0
? Math.max(...allHakAksesData.value.map(i => i.id)) + 1
: 1;
itemToSave.id = newId;
allHakAksesData.value.push({ ...itemToSave });
console.log('✅ New item added with ID:', newId);
console.log('📊 New allHakAksesData length:', allHakAksesData.value.length);
const groupBasedAccess: HakAksesData = {
id: newId,
role: editedItem.value.role,
group: editedItem.value.group,
groupPath: editedItem.value.groupPath || selectedGroup.value || '',
namaTipeUser: editedItem.value.namaTipeUser || '',
hakAksesMenu: editedItem.value.hakAksesMenu || [],
isGroupBased: true,
};
allHakAksesData.value.push(groupBasedAccess);
console.log('✅ New group-based access added with ID:', newId);
console.log(`📋 Affected users: ${selectedGroupUsers.value.length}`);
snackbar.value = {
show: true,
message: 'Data hak akses berhasil ditambahkan!',
message: `Hak akses untuk group "${editedItem.value.group}" berhasil ditambahkan! ${selectedGroupUsers.value.length} user mendapatkan hak akses ini.`,
color: 'success',
timeout: 3000,
timeout: 4000,
};
}
// Force update localStorage by triggering reactivity
// useLocalStorage should auto-save, but we ensure it by reassigning
await nextTick();
allHakAksesData.value = [...allHakAksesData.value];
+10 -7
View File
@@ -222,7 +222,8 @@
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-3 px-6"
class="text-capitalize mr-3 px-6"
rounded="0"
prepend-icon="mdi-close"
@click="cancelForm"
:disabled="isSaving"
@@ -234,7 +235,8 @@
v-if="!readOnly"
:color="isEditMode ? 'orange-darken-2' : 'green-darken-2'"
variant="flat"
class="text-capitalize rounded-lg px-6"
class="text-capitalize px-6"
rounded="0"
:prepend-icon="isEditMode ? 'mdi-content-save' : 'mdi-check'"
@click="saveItem"
:loading="isSaving"
@@ -280,6 +282,7 @@
@click="refreshAllUsers"
elevation="0"
class="add-btn"
rounded="0"
:disabled="isPending || isSaving"
:loading="isSaving"
>
@@ -451,13 +454,13 @@
</template>
<template #item.actions="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-btn icon color="blue" size="small" class="mr-2" rounded="0" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<!-- <v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<!-- <v-btn icon color="orange" size="small" class="mr-2" rounded="0" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn> -->
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-btn icon color="red" size="small" rounded="0" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
@@ -494,8 +497,8 @@
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog" :disabled="isSaving">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete" :loading="isSaving">Hapus</v-btn>
<v-btn color="grey-darken-1" variant="text" rounded="0" @click="closeDeleteDialog" :disabled="isSaving">Batal</v-btn>
<v-btn color="red" variant="text" rounded="0" @click="confirmDelete" :loading="isSaving">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
+179 -84
View File
@@ -1,68 +1,78 @@
<template>
<v-container>
<v-app>
<!-- Page Header -->
<PageHeader
icon="mdi-shield-check"
title="Verifikasi Akun"
subtitle="Manajemen Pasien Digital"
:show-add-button="false"
theme="primary"
<v-card>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-shield-check</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Verifikasi Akun</h2>
<p class="page-subtitle">{{ currentDate }} - Manajemen Pasien Digital</p>
</div>
</div>
<div class="header-stats">
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-account-group</v-icon>
{{ patients.length }} User
</v-chip>
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-clock-alert</v-icon>
{{ unverifiedPatients.length }} Pending
</v-chip>
<v-chip color="white" variant="flat" class="stat-chip">
<v-icon start size="16">mdi-check-decagram</v-icon>
{{ verifiedPatients.length }} Terverifikasi
</v-chip>
</div>
</div>
</div>
<!-- Search & Content -->
<v-card-text class="pa-0">
<!-- Search & Tabs -->
<VerificationSearchTabs
v-model:search-query="search"
v-model:selected-tab="filterStatus"
:all-count="patients.length"
:pending-count="unverifiedPatients.length"
:verified-count="verifiedPatients.length"
/>
<!-- Patient List -->
<PatientVerificationList
:patients="filteredPatients"
:empty-message="noDataText"
@verify="openDaftarModal"
/>
</v-card-text>
</v-card>
<!-- QR Verification Dialog -->
<QRVerificationDialog
v-model="isModalOpen"
v-model:phone-number="tempTelepon"
:patient="selectedPatient"
:qr-generated="qrCodeGenerated"
:is-mobile="display.xs.value"
@generate="generateQrCode"
@reload="reloadQr"
@complete="completeVerification"
@close="closeModal"
>
<template #actions>
<v-chip color="secondary-600" class="admin-chip" variant="flat" rounded="xl" size="large">
<v-icon start color="white">mdi-account-star</v-icon>
<span class="chip-text">Admin</span>
</v-chip>
<template #qr-code>
<qrcode-vue
:value="qrCodeData"
:size="qrSize"
level="H"
:foreground="'#FFA532'"
:background="'#FFFFFF'"
class="qr-code"
/>
</template>
</PageHeader>
<v-main class="main-bg">
<v-container fluid class="pa-6 pt-8 pb-12">
<v-card class="content-card">
<!-- Search & Tabs -->
<VerificationSearchTabs
v-model:search-query="search"
v-model:selected-tab="filterStatus"
:all-count="patients.length"
:pending-count="unverifiedPatients.length"
:verified-count="verifiedPatients.length"
/>
<!-- Patient List -->
<PatientVerificationList
:patients="filteredPatients"
:empty-message="noDataText"
@verify="openDaftarModal"
/>
</v-card>
<!-- QR Verification Dialog -->
<QrVerificationDialog
v-model="isModalOpen"
:patient="selectedPatient"
v-model:phone-number="tempTelepon"
:qr-generated="qrCodeGenerated"
:is-mobile="display.xs.value"
@generate="generateQrCode"
@reload="reloadQr"
@complete="completeVerification"
@close="closeModal"
>
<template #qr-code>
<qrcode-vue
:value="qrCodeData"
:size="qrSize"
level="H"
:foreground="'#0663C7'"
:background="'#FFFFFF'"
class="qr-code"
/>
</template>
</QrVerificationDialog>
</v-container>
</v-main>
</v-app>
</QRVerificationDialog>
</v-container>
</template>
@@ -70,10 +80,9 @@
import { ref, computed } from 'vue';
import { useDisplay } from 'vuetify';
import QrcodeVue from 'qrcode.vue';
import PageHeader from '@/components/common/PageHeader.vue';
import VerificationSearchTabs from '@/components/verification/VerificationSearchTabs.vue';
import PatientVerificationList from '@/components/verification/PatientVerificationList.vue';
import QrVerificationDialog from '@/components/verification/QrVerificationDialog.vue';
import QRVerificationDialog from '@/components/verification/QRVerificationDialog.vue';
definePageMeta({
middleware: ['auth']
@@ -98,6 +107,13 @@ const tempTelepon = ref('');
const qrCodeGenerated = ref(false);
// Computed
const currentDate = computed(() => {
const now = new Date();
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
});
const noDataText = computed(() =>
search.value
? 'Tidak ada data pasien yang cocok dengan pencarian.'
@@ -196,42 +212,121 @@ const completeVerification = () => {
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
// Colors from Design System
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$secondary-600: #0671E0;
$neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
// Orange/Primary Colors
$primary-700: #FF9B1B;
$primary-600: #FFA532;
$primary-500: #FFB95F;
$primary-400: #FFCD8D;
$primary-300: #FFDCAF;
$primary-200: #FFE6C6;
$danger-600: #E02B1D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-bold: 700;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.main-bg {
background: $neutral-300;
min-height: 100vh;
// Apply font family
* {
font-family: $font-family-base;
}
.admin-chip {
font-weight: $font-weight-bold;
padding: 12px 20px;
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
}
.chip-text {
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
font-weight: $font-weight-bold;
font-family: $font-family-base;
}
.content-card {
background: $neutral-100;
border-radius: 12px;
padding: 0;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
border: 1px solid $neutral-500;
.header-left {
display: flex;
align-items: center;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 36px;
line-height: 44px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
.header-stats {
display: flex;
align-items: center;
gap: 8px;
}
.stat-chip {
color: $primary-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.qr-code {
border-radius: 8px;
display: block;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.header-stats {
width: 100%;
flex-wrap: wrap;
}
.page-title {
font-size: 28px;
line-height: 36px;
}
}
</style>
+8 -6
View File
@@ -1,14 +1,16 @@
// Quick database access script
// Usage: node scripts/db-access.js
const Database = require('better-sqlite3');
const path = require('path');
const readline = require('readline');
import Database from 'better-sqlite3';
import path from 'path';
import readline from 'readline';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = path.join(process.cwd(), 'data', 'users.db');
// Check if database exists
const fs = require('fs');
if (!fs.existsSync(dbPath)) {
console.error('❌ Database not found at:', dbPath);
process.exit(1);
+73
View File
@@ -0,0 +1,73 @@
// Quick database viewer - shows all content and exits
// Usage: node scripts/view-db.js
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const dbPath = path.join(process.cwd(), 'data', 'users.db');
if (!fs.existsSync(dbPath)) {
console.error('❌ Database not found at:', dbPath);
process.exit(1);
}
const db = new Database(dbPath);
console.log('📊 Database Content Viewer\n');
console.log('Database path:', dbPath);
console.log('='.repeat(80));
// Get all tables
const tables = db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
`).all();
console.log('\n📋 Available Tables:');
tables.forEach((table, index) => {
console.log(` ${index + 1}. ${table.name}`);
});
// Show content for each table
tables.forEach((table) => {
const tableName = table.name;
// Get row count
const count = db.prepare(`SELECT COUNT(*) as total FROM ${tableName}`).get();
console.log('\n' + '='.repeat(80));
console.log(`\n📊 Table: ${tableName} (${count.total} rows)`);
console.log('-'.repeat(80));
// Get table schema
const schema = db.prepare(`PRAGMA table_info(${tableName})`).all();
console.log('\n📐 Schema:');
console.table(schema.map(col => ({
column: col.name,
type: col.type,
nullable: col.notnull === 0 ? 'YES' : 'NO',
default: col.dflt_value || '-'
})));
// Get all data
const data = db.prepare(`SELECT * FROM ${tableName}`).all();
if (data.length === 0) {
console.log('\n📭 No data in this table.');
} else {
console.log(`\n📄 Data (showing all ${data.length} rows):`);
console.table(data);
// If there are many rows, also show a summary
if (data.length > 10) {
console.log(`\n💡 Tip: Showing all ${data.length} rows. Use the interactive script for filtering.`);
}
}
});
console.log('\n' + '='.repeat(80));
console.log('\n✅ Done! Use `node scripts/db-access.js` for interactive queries.\n');
db.close();