update card, update design system, update layout anjungan

This commit is contained in:
bagus-arie05
2025-12-05 10:21:16 +07:00
parent 817bf8f548
commit 9b3fd7b314
23 changed files with 5064 additions and 2991 deletions
-1
View File
@@ -1,6 +1,5 @@
@use 'sass:map';
// Color Palette Variables
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
@@ -11,19 +11,19 @@
</div>
<div class="action-grid">
<v-btn color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')">
<v-btn class="text-white" color="success-600" variant="flat" block size="large" @click="$emit('action', 'check-in')">
<v-icon start size="20">mdi-check</v-icon>
Selesai
</v-btn>
<v-btn color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')">
<v-btn class="text-white" color="primary-600" variant="flat" block size="large" @click="$emit('action', 'terlambat')">
<v-icon start size="20">mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')">
<v-btn class="text-white" color="danger-600" variant="flat" block size="large" @click="$emit('action', 'pending')">
<v-icon start size="20">mdi-pause</v-icon>
Pending
</v-btn>
<v-btn color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')">
<v-btn class="text-white" color="secondary-600" variant="flat" block size="large" @click="$emit('change-klinik')">
<v-icon start size="20">mdi-swap-horizontal</v-icon>
{{ changeButtonText }}
</v-btn>
+214
View File
@@ -0,0 +1,214 @@
<template>
<v-card class="patient-card" elevation="0">
<v-card-text class="pa-4">
<!-- Header: Queue Number & Status -->
<div class="card-header">
<div class="queue-number">{{ patient.noAntrian.split(" |")[0] }}</div>
<v-chip
:color="getStatusColor(patient.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(patient.status) }}
</v-chip>
</div>
<!-- Patient Info Grid -->
<div class="patient-info mt-3">
<div class="info-row">
<span class="info-label">Barcode:</span>
<span class="info-value">{{ patient.barcode }}</span>
</div>
<div class="info-row">
<span class="info-label">Jam Panggil:</span>
<span class="info-value">{{ patient.jamPanggil }}</span>
</div>
<div class="info-row">
<span class="info-label">Klinik:</span>
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ patient.klinik }}
</v-chip>
</div>
<div class="info-row">
<span class="info-label">Fast Track:</span>
<span class="info-value">{{ patient.fastTrack }}</span>
</div>
<div class="info-row">
<span class="info-label">Pembayaran:</span>
<span class="info-value">{{ patient.pembayaran }}</span>
</div>
</div>
<!-- Action Button -->
<div class="card-actions mt-4">
<v-btn
v-if="patient.status === 'diloket'"
block
color="primary-600"
variant="flat"
@click="$emit('action', patient, 'proses')"
>
<v-icon start size="18">mdi-account-check</v-icon>
Proses
</v-btn>
<v-btn
v-else-if="patient.status === 'terlambat'"
block
color="success-600"
variant="flat"
class="text-white"
@click="$emit('action', patient, 'aktifkan')"
>
<v-icon start size="18">mdi-check-circle</v-icon>
Aktifkan
</v-btn>
<v-btn
v-else-if="patient.status === 'pending'"
block
color="success-600"
class="text-white"
variant="flat"
@click="$emit('action', patient, 'proses')"
>
<v-icon start size="18">mdi-play-circle</v-icon>
Proses
</v-btn>
</div>
</v-card-text>
<!-- Card Menu (Optional) -->
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
size="small"
class="card-menu-btn"
v-bind="props"
/>
</template>
<v-list density="compact">
<v-list-item @click="$emit('view-details', patient)">
<v-list-item-title>Lihat Detail</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card>
</template>
<script setup>
const props = defineProps({
patient: {
type: Object,
required: true
}
});
defineEmits(['action', 'view-details']);
const getStatusColor = (status) => {
const colors = {
diloket: "var(--color-secondary-600)",
terlambat: "var(--color-primary-600)",
pending: "var(--color-danger-600)"
};
return colors[status] || "var(--color-neutral-600)";
};
const getStatusLabel = (status) => {
const labels = {
diloket: "Di Loket",
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%;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-neutral-400);
}
.queue-number {
font-size: 20px;
font-weight: 700;
color: var(--color-neutral-900);
}
.status-chip {
font-weight: 600;
font-size: 11px;
}
.patient-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.info-label {
color: var(--color-neutral-600);
font-weight: 500;
min-width: 100px;
}
.info-value {
color: var(--color-neutral-900);
font-weight: 600;
text-align: right;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
}
.card-actions .v-btn {
text-transform: none;
font-weight: 600;
font-size: 13px;
height: 40px;
}
.card-menu-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.patient-card:hover .card-menu-btn {
opacity: 1;
}
</style>
+112 -132
View File
@@ -1,8 +1,8 @@
<template>
<v-card class="patient-table-card" elevation="0">
<v-card class="patient-data-container" elevation="0">
<v-card-text class="pa-4">
<!-- Table Header with Filters -->
<div class="table-header mb-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
@@ -28,73 +28,39 @@
</div>
</div>
<!-- Data Table -->
<v-data-table
:headers="headers"
:items="filteredItems"
:search="searchModel"
class="patient-table"
:items-per-page="itemsPerPage"
density="comfortable"
>
<template #item.noAntrian="{ item }">
<div class="queue-number">{{ item.noAntrian.split(" |")[0] }}</div>
</template>
<!-- Patient Cards Grid -->
<div v-if="filteredAndSearchedItems.length > 0" class="patient-grid">
<PatientCard
v-for="(patient, index) in paginatedItems"
:key="`${patient.barcode}-${index}`"
:patient="patient"
@action="handleAction"
/>
</div>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(item.status) }}
</v-chip>
</template>
<!-- Empty State -->
<div v-else class="empty-state">
<v-icon size="64" color="neutral-500">mdi-account-search</v-icon>
<div class="empty-text mt-3">Tidak ada data pasien</div>
<div class="empty-subtext">Data akan muncul ketika ada pasien yang terdaftar</div>
</div>
<template #item.klinik="{ item }">
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ item.klinik }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<div class="action-buttons-table">
<v-btn
v-if="item.status === 'diloket'"
size="small"
color="primary-600"
variant="flat"
@click="$emit('action', item, 'proses')"
>
Proses
</v-btn>
<v-btn
v-else-if="item.status === 'terlambat'"
size="small"
color="success-600"
variant="flat"
@click="$emit('action', item, 'aktifkan')"
>
Aktifkan
</v-btn>
<v-btn
v-else-if="item.status === 'pending'"
size="small"
color="success-600"
variant="flat"
@click="$emit('action', item, 'proses')"
>
Proses
</v-btn>
</div>
</template>
</v-data-table>
<!-- 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 } from 'vue';
import { computed, ref } from 'vue';
import PatientCard from './PatientCard.vue';
const props = defineProps({
items: {
@@ -123,7 +89,7 @@ const props = defineProps({
},
itemsPerPage: {
type: Number,
default: 10
default: 9
},
statusLabels: {
type: Object,
@@ -138,14 +104,22 @@ const props = defineProps({
const emit = defineEmits(['update:selectedStatus', 'update:searchQuery', 'action']);
const currentPage = ref(1);
const selectedStatusModel = computed({
get: () => props.selectedStatus,
set: (value) => emit('update:selectedStatus', value)
set: (value) => {
currentPage.value = 1; // Reset to first page on filter change
emit('update:selectedStatus', value);
}
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
set: (value) => {
currentPage.value = 1; // Reset to first page on search
emit('update:searchQuery', value);
}
});
const statusOptions = computed(() => [
@@ -160,39 +134,34 @@ const filteredItems = computed(() => {
return props.items.filter(p => p.status === selectedStatusModel.value);
});
const headers = [
{ title: "No", value: "no", width: "60px", sortable: false },
{ title: "Jam Panggil", value: "jamPanggil", width: "100px" },
{ title: "Barcode", value: "barcode", width: "130px" },
{ title: "No Antrian", value: "noAntrian", width: "140px" },
{ title: "Klinik", value: "klinik", width: "100px" },
{ title: "Fast Track", value: "fastTrack", width: "100px" },
{ title: "Pembayaran", value: "pembayaran", width: "100px" },
{ title: "Status", value: "status", width: "100px" },
{ title: "Aksi", value: "aksi", width: "100px", sortable: false },
];
const filteredAndSearchedItems = computed(() => {
if (!searchModel.value) return filteredItems.value;
const searchLower = searchModel.value.toLowerCase();
return filteredItems.value.filter(patient =>
patient.barcode?.toLowerCase().includes(searchLower) ||
patient.noAntrian?.toLowerCase().includes(searchLower) ||
patient.klinik?.toLowerCase().includes(searchLower)
);
});
const getStatusColor = (status) => {
const colors = {
diloket: "var(--color-secondary-600)",
terlambat: "var(--color-primary-600)",
pending: "var(--color-danger-600)"
};
return colors[status] || "var(--color-neutral-600)";
};
const totalPages = computed(() =>
Math.ceil(filteredAndSearchedItems.value.length / props.itemsPerPage)
);
const getStatusLabel = (status) => {
const labels = {
diloket: "Di Loket",
terlambat: "Terlambat",
pending: "Pending"
};
return labels[status] || status;
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-table-card {
.patient-data-container {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
@@ -206,7 +175,7 @@ const getStatusLabel = (status) => {
text-transform: uppercase;
}
.table-header {
.data-header {
display: flex;
flex-direction: column;
gap: 12px;
@@ -230,6 +199,11 @@ const getStatusLabel = (status) => {
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 {
@@ -239,56 +213,45 @@ const getStatusLabel = (status) => {
}
.search-field {
max-width: 250px;
max-width: 300px;
}
:deep(.patient-table) {
border-radius: 8px;
.patient-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
:deep(.patient-table .v-data-table__thead) {
background: var(--color-neutral-300);
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
:deep(.patient-table th) {
font-size: 11px !important;
font-weight: 700 !important;
color: var(--color-neutral-600) !important;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 8px !important;
}
:deep(.patient-table td) {
font-size: 13px !important;
padding: 10px 8px !important;
border-bottom: 1px solid var(--color-neutral-400) !important;
}
:deep(.patient-table tbody tr:hover) {
background: var(--color-neutral-300) !important;
}
.queue-number {
font-weight: 700;
color: var(--color-neutral-900);
}
.status-chip {
.empty-text {
font-size: 16px;
font-weight: 600;
font-size: 11px;
color: var(--color-neutral-700);
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
.empty-subtext {
font-size: 13px;
color: var(--color-neutral-600);
margin-top: 4px;
}
.action-buttons-table .v-btn {
text-transform: none;
.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;
font-size: 12px;
height: 32px;
}
@media (max-width: 960px) {
@@ -300,5 +263,22 @@ const getStatusLabel = (status) => {
.search-field {
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(300px, 1fr));
}
}
</style>
@@ -0,0 +1,245 @@
<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-list-item-title class="patient-name">
{{ patient.nama }}
</v-list-item-title>
<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>
<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>
<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>
</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-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-100: #FFE8CC;
$primary-200: #FFDCAF;
$primary-600: #FFA532;
$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-item {
border-bottom: 1px solid $neutral-400;
padding: 24px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
font-family: $font-family-base;
}
.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;
}
.patient-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid $neutral-100;
}
.patient-name {
font-size: 24px;
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;
}
.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-btn {
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-family: $font-family-base;
}
.verified-chip {
font-weight: $font-weight-extra-bold;
font-size: 16px;
color: $neutral-100;
padding: 12px 24px;
font-family: $font-family-base;
}
.empty-state {
text-align: center;
padding: 32px 0;
font-size: 18px;
color: $neutral-600;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
@media (max-width: 960px) {
.patient-item {
padding: 20px;
}
.action-btn {
font-size: 14px;
padding: 10px 20px;
}
.patient-avatar {
width: 56px !important;
height: 56px !important;
}
.patient-name {
font-size: 20px;
}
}
</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>
+20 -12
View File
@@ -19,15 +19,23 @@ export default defineNuxtConfig({
"@nuxt/test-utils",
"@nuxt/ui",
"@pinia/nuxt",
"@vesp/nuxt-fontawesome",
"@nuxtjs/google-fonts",
(_options, nuxt) => {
nuxt.hooks.hook("vite:extendConfig", (config) => {
// @ts-expect-error
config.plugins.push(vuetify({ autoImport: true }));
});
});
},
],
fontawesome: {
icons: {
solid: ["dna", "user", "home", "gear"],
regular: ["heart"],
brands: ["github"],
},
},
googleFonts: {
families: {
Inter: [400, 500, 600, 700],
@@ -41,8 +49,8 @@ export default defineNuxtConfig({
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001'
}
authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
},
},
build: {
@@ -54,10 +62,10 @@ export default defineNuxtConfig({
"@mdi/font/css/materialdesignicons.min.css",
"~/assets/scss/main.scss",
],
// devServer: {
// host: "10.10.150.114",
// port: 3001,
// },
devServer: {
host: "10.10.150.114",
port: 3001,
},
// 'http://10.10.150.114:3001/'
@@ -65,18 +73,18 @@ export default defineNuxtConfig({
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
api: "modern-compiler",
additionalData: `
@use "sass:math";
@use "sass:map";
@use "~/assets/scss/_variables.scss" as *;
@use "~/assets/scss/_colors.scss" as *;
`
}
}
`,
},
},
},
ssr: {
noExternal: ["vuetify"],
},
},
});
});
+91 -21
View File
@@ -7,6 +7,9 @@
"name": "nuxt-app",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@mdi/font": "^7.4.47",
"@nuxt/content": "^2.7.2",
"@nuxt/eslint": "^1.7.1",
@@ -16,6 +19,7 @@
"@nuxt/ui": "^3.3.0",
"@pinia/nuxt": "^0.11.2",
"@unhead/vue": "^2.0.13",
"@vesp/nuxt-fontawesome": "^2.0.0",
"better-sqlite3": "^12.2.0",
"chart.js": "^4.5.0",
"dayjs": "^1.11.18",
@@ -1460,6 +1464,63 @@
}
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
"integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1916,9 +1977,9 @@
}
},
"node_modules/@nuxt/devtools-kit": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nuxt/devtools-kit/-/devtools-kit-3.0.0.tgz",
"integrity": "sha512-/X8GLPYydj/Lbmti7M+HGPeeBCfzzZnAXtERjnbUqQsPNUmRMDt1nOSPHYN4NL6TaH3ECHt3HYqJt/O+Hw2kDg==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nuxt/devtools-kit/-/devtools-kit-3.1.0.tgz",
"integrity": "sha512-1AEZS6ge8G9X3sJauw6hTWqTpUIVqs5Uq9d7Z9cjUAinXjE+pGliVQ+i8xWCNnGLaZCCSqX/I/M/EByD3v2JIA==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^4.2.1",
@@ -6014,6 +6075,15 @@
"node": ">=8"
}
},
"node_modules/@vesp/nuxt-fontawesome": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vesp/nuxt-fontawesome/-/nuxt-fontawesome-2.0.0.tgz",
"integrity": "sha512-20of3+NQHy5naZIk/DsH4LKjfmjEyiA1ajvrrCZexqEkVuf9e8IBVvtl228zAdzSgRxjHDcWGLKW8j0sirCDzA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.0.0"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
@@ -7191,9 +7261,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==",
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -7223,9 +7293,9 @@
}
},
"node_modules/birpc": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz",
"integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz",
"integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -7537,9 +7607,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"funding": [
{
"type": "opencollective",
@@ -8635,9 +8705,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.241",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
"integrity": "sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w==",
"version": "1.5.262",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
"license": "ISC"
},
"node_modules/embla-carousel": {
@@ -16314,9 +16384,9 @@
}
},
"node_modules/srvx": {
"version": "0.8.16",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.16.tgz",
"integrity": "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==",
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.9.6.tgz",
"integrity": "sha512-5L4rT6qQqqb+xcoDoklUgCNdmzqJ6vbcDRwPVGRXewF55IJH0pqh0lQlrJ266ZWTKJ4mfeioqHQJeAYesS+RrQ==",
"license": "MIT",
"bin": {
"srvx": "bin/srvx.mjs"
@@ -18179,9 +18249,9 @@
}
},
"node_modules/vite-plugin-vue-tracer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-tracer/-/vite-plugin-vue-tracer-1.0.1.tgz",
"integrity": "sha512-L5/vAhT6oYbH4RSQYGLN9VfHexWe7SGzca1pJ7oPkL6KtxWA1jbGeb3Ri1JptKzqtd42HinOq4uEYqzhVWrzig==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-tracer/-/vite-plugin-vue-tracer-1.1.3.tgz",
"integrity": "sha512-fM7hfHELZvbPnSn8EKZwHfzxm5EfYFQIclz8rwcNXfodNbRkwNvh0AGMtaBfMxQ9HC5KVa3KitwHnmE4ezDemw==",
"license": "MIT",
"dependencies": {
"estree-walker": "^3.0.3",
+4
View File
@@ -12,6 +12,9 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@mdi/font": "^7.4.47",
"@nuxt/content": "^2.7.2",
"@nuxt/eslint": "^1.7.1",
@@ -21,6 +24,7 @@
"@nuxt/ui": "^3.3.0",
"@pinia/nuxt": "^0.11.2",
"@unhead/vue": "^2.0.13",
"@vesp/nuxt-fontawesome": "^2.0.0",
"better-sqlite3": "^12.2.0",
"chart.js": "^4.5.0",
"dayjs": "^1.11.18",
+111 -601
View File
@@ -1,97 +1,56 @@
<template>
<div class="loket-container">
<!-- Compact Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<v-icon size="28" color="white">mdi-hospital-building</v-icon>
<div class="header-text">
<h1 class="page-title">Admin Klinik</h1>
<p class="page-subtitle">{{ currentDate }}</p>
</div>
</div>
</div>
</div>
<PageHeader
icon="mdi-hospital-building"
title="Admin Klinik"
:subtitle="currentDate"
:show-add-button="false"
theme="secondary"
/>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient -->
<!-- Left Column: Current Patient & Queue Actions -->
<v-col cols="12" md="5">
<v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">SEDANG DIPROSES</div>
<div v-if="currentProcessingPatient" class="patient-details">
<div class="patient-number mb-2">{{ currentProcessingPatient.noAntrian.split(" |")[0] }}</div>
<div class="patient-info-text mb-3">
<div>{{ currentProcessingPatient.barcode }}</div>
<div>{{ currentProcessingPatient.klinik }} | {{ currentProcessingPatient.pembayaran }}</div>
</div>
<div class="action-grid">
<v-btn color="#10b981" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'check-in')">
<v-icon start size="20">mdi-check</v-icon>
Selesai
</v-btn>
<v-btn color="#f59e0b" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'terlambat')">
<v-icon start size="20">mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn color="#ef4444" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'pending')">
<v-icon start size="20">mdi-pause</v-icon>
Pending
</v-btn>
<v-btn color="#3b82f6" variant="flat" block size="large" @click="showChangeKlinikDialog = true">
<v-icon start size="20">mdi-swap-horizontal</v-icon>
Ubah Klinik
</v-btn>
</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">Tidak ada pasien yang diproses</div>
</div>
</v-card-text>
</v-card>
<!-- Current Patient Card -->
<CurrentPatientCard
:patient="currentProcessingPatient"
theme="secondary"
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
/>
<!-- Queue Actions -->
<v-card class="queue-actions-card mt-3" 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">150</span>
</div>
<div class="quota-item">
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ 150 - quotaUsed }}</span>
</div>
<div class="quota-item full-width">
<v-progress-linear :model-value="(quotaUsed / 150) * 100" color="#10b981" height="6" rounded class="mt-1"></v-progress-linear>
<span class="quota-used">Terpakai: {{ quotaUsed }}</span>
</div>
</div>
<div class="call-buttons">
<v-btn color="#10b981" variant="outlined" @click="callNext" :disabled="!nextPatient">1</v-btn>
<v-btn color="#3b82f6" variant="outlined" @click="callMultiplePatients(5)">5</v-btn>
<v-btn color="#f59e0b" variant="outlined" @click="callMultiplePatients(10)">10</v-btn>
<v-btn color="#ef4444" variant="outlined" @click="callMultiplePatients(20)">20</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Queue Actions Card -->
<QueueActionsCard
class="mt-3"
:total-quota="150"
:used-quota="quotaUsed"
:has-next="!!nextPatient"
@call="handleCall"
/>
<!-- Create Queue Buttons -->
<div class="create-buttons mt-3">
<v-btn color="#f59e0b" variant="flat" block size="large" class="mb-2" @click="showKlinikDialog = true">
<v-btn
color="primary-600"
variant="flat"
block
size="large"
class="mb-2 create-btn"
@click="showKlinikDialog = true"
>
<v-icon start size="20">mdi-hospital-building</v-icon>
Buat Antrean Klinik
</v-btn>
<v-btn color="#3b82f6" variant="flat" block size="large" @click="openPenunjangDialog()">
<v-btn
color="secondary-600"
variant="flat"
block
size="large"
class="create-btn text-white"
@click="openPenunjangDialog()"
>
<v-icon start size="20">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
@@ -100,219 +59,67 @@
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<v-card class="patient-table-card" elevation="0">
<v-card-text class="pa-4">
<!-- Table Header with Filters -->
<div class="table-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
Semua ({{ allPatients.length }})
</v-chip>
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
Di Klinik ({{ (diLoketPatients || []).length }})
</v-chip>
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
Terlambat ({{ (terlambatPatients || []).length }})
</v-chip>
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
Pending ({{ (pendingPatients || []).length }})
</v-chip>
</v-chip-group>
<v-text-field
v-model="searchQuery"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
></v-text-field>
</div>
</div>
<!-- Data Table -->
<v-data-table
:headers="tableHeaders"
:items="filteredPatients"
:search="searchQuery"
class="patient-table"
:items-per-page="10"
density="comfortable"
>
<template #item.noAntrian="{ item }">
<div class="queue-number">{{ item.noAntrian.split(" |")[0] }}</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(item.status) }}
</v-chip>
</template>
<template #item.klinik="{ item }">
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ item.klinik }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<div class="action-buttons-table">
<v-btn
v-if="item.status === 'diloket'"
size="small"
color="#10b981"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
<v-btn
v-else-if="item.status === 'terlambat'"
size="small"
color="#10b981"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
<v-btn
v-else-if="item.status === 'pending'"
size="small"
color="#10b981"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
<PatientDataTable
:items="allPatients"
v-model:selected-status="selectedStatus"
v-model:search-query="searchQuery"
:di-loket-count="(diLoketPatients || []).length"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
:status-labels="statusLabels"
@action="handleTableAction"
/>
</v-col>
</v-row>
<!-- Klinik Dialog -->
<v-dialog v-model="showKlinikDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showKlinikDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Pilih Klinik</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="klinikSearch"
placeholder="Cari klinik..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="klinik in filteredKliniks" :key="klinik.id" cols="6" sm="4">
<v-card class="klinik-card" @click="selectKlinik(klinik)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="klinik-name">{{ klinik.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<SelectionDialog
v-model="showKlinikDialog"
title="Pilih Klinik"
:items="filteredKliniks"
v-model:search-query="klinikSearch"
search-placeholder="Cari klinik..."
@select="selectKlinik"
/>
<!-- Penunjang Dialog -->
<v-dialog v-model="showPenunjangDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showPenunjangDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Pilih Penunjang</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="penunjangSearch"
placeholder="Cari penunjang..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="penunjang in filteredPenunjangs" :key="penunjang.id" cols="6" sm="4">
<v-card class="penunjang-card" @click="selectPenunjang(penunjang)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="penunjang-name">{{ penunjang.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<SelectionDialog
v-model="showPenunjangDialog"
title="Pilih Penunjang"
:items="filteredPenunjangs"
v-model:search-query="penunjangSearch"
search-placeholder="Cari penunjang..."
@select="selectPenunjang"
/>
<!-- Change Klinik Dialog -->
<v-dialog v-model="showChangeKlinikDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showChangeKlinikDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Ubah Klinik</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="changeKlinikSearch"
placeholder="Cari klinik..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="klinik in filteredChangeKliniks" :key="klinik.id" cols="6" sm="4">
<v-card class="klinik-card" @click="changeKlinik(klinik)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="klinik-name">{{ klinik.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<SelectionDialog
v-model="showChangeKlinikDialog"
title="Ubah Klinik"
:items="filteredChangeKliniks"
v-model:search-query="changeKlinikSearch"
search-placeholder="Cari klinik..."
@select="changeKlinik"
/>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
{{ snackbarText }}
<template v-slot:actions>
<v-btn icon size="small" @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "../composables/useQueue";
import { useQueue } from "@/composables/useQueue";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
const {
snackbar,
@@ -354,15 +161,21 @@ const currentDate = ref(
const selectedStatus = ref("all");
const searchQuery = ref("");
// Combine all patients with status - Fixed to prevent infinite loop
// Custom status labels for Klinik
const statusLabels = {
all: 'Semua',
diloket: 'Di Klinik',
terlambat: 'Terlambat',
pending: 'Pending'
};
// Combine all patients with status
const allPatients = computed(() => {
try {
// Safely access reactive values
const diLoketList = diLoketPatients.value || [];
const terlambatList = terlambatPatients.value || [];
const pendingList = pendingPatients.value || [];
// Map with status
const diLoket = diLoketList.map(p => ({ ...p, status: 'diloket' }));
const terlambat = terlambatList.map(p => ({ ...p, status: 'terlambat' }));
const pending = pendingList.map(p => ({ ...p, status: 'pending' }));
@@ -374,90 +187,32 @@ const allPatients = computed(() => {
}
});
// Filter patients based on selected status
const filteredPatients = computed(() => {
try {
const all = allPatients.value;
if (selectedStatus.value === "all") return all;
return all.filter(p => p.status === selectedStatus.value);
} catch (error) {
console.error('Error in filteredPatients computed:', error);
return [];
const handlePatientAction = (action) => {
if (currentProcessingPatient.value) {
processPatient(currentProcessingPatient.value, action);
}
});
const tableHeaders = ref([
{ title: "No", value: "no", width: "60px", sortable: false },
{ title: "Jam Panggil", value: "jamPanggil", width: "100px" },
{ title: "Barcode", value: "barcode", width: "130px" },
{ title: "No Antrian", value: "noAntrian", width: "140px" },
{ title: "Klinik", value: "klinik", width: "100px" },
{ title: "Fast Track", value: "fastTrack", width: "100px" },
{ title: "Pembayaran", value: "pembayaran", width: "100px" },
{ title: "Status", value: "status", width: "100px" },
{ title: "Aksi", value: "aksi", width: "100px", sortable: false },
]);
const getStatusColor = (status) => {
const colors = {
diloket: "#3b82f6",
terlambat: "#f59e0b",
pending: "#ef4444"
};
return colors[status] || "#6b7280";
};
const getStatusLabel = (status) => {
const labels = {
diloket: "Di Klinik",
terlambat: "Terlambat",
pending: "Pending"
};
return labels[status] || status;
const handleCall = (count) => {
if (count === 1) {
callNext();
} else {
callMultiplePatients(count);
}
};
const handleTableAction = (item, action) => {
processPatient(item, action);
};
</script>
<style scoped>
<style scoped lang="scss">
.loket-container {
background: #f8fafc;
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
}
.page-header {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.2);
}
.header-content {
padding: 20px 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-text {
color: white;
}
.page-title {
font-size: 24px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.page-subtitle {
font-size: 14px;
margin: 4px 0 0 0;
opacity: 0.9;
}
.content-grid {
margin: 0 -8px;
}
@@ -466,260 +221,15 @@ const getStatusLabel = (status) => {
padding: 8px;
}
.current-patient-card,
.queue-actions-card,
.patient-table-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
background: white;
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: #64748b;
text-transform: uppercase;
}
.patient-details {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 8px;
padding: 16px;
}
.patient-number {
font-size: 28px;
font-weight: 800;
color: #1e293b;
line-height: 1;
}
.patient-info-text {
font-size: 13px;
color: #475569;
line-height: 1.5;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.empty-state {
text-align: center;
padding: 32px 16px;
}
.empty-text {
font-size: 13px;
color: #94a3b8;
margin-top: 8px;
}
.quota-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.quota-item {
background: #f1f5f9;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.quota-item.full-width {
grid-column: 1 / -1;
}
.quota-label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
font-weight: 600;
}
.quota-value {
display: block;
font-size: 24px;
font-weight: 800;
color: #1e293b;
}
.quota-available {
color: #10b981;
}
.quota-used {
display: block;
font-size: 11px;
color: #64748b;
margin-top: 4px;
}
.call-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.call-buttons .v-btn {
font-size: 16px;
font-weight: 700;
height: 44px;
}
.create-buttons .v-btn {
.create-buttons .create-btn {
text-transform: none;
font-weight: 600;
}
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid #e2e8f0;
background: white;
color: #64748b;
}
.status-filter .v-chip.active-chip {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.search-field {
max-width: 250px;
}
:deep(.patient-table) {
border-radius: 8px;
}
:deep(.patient-table .v-data-table__thead) {
background: #f8fafc;
}
:deep(.patient-table th) {
font-size: 11px !important;
font-weight: 700 !important;
color: #64748b !important;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 8px !important;
}
:deep(.patient-table td) {
font-size: 13px !important;
padding: 10px 8px !important;
border-bottom: 1px solid #f1f5f9 !important;
}
:deep(.patient-table tbody tr:hover) {
background: #f8fafc !important;
}
.queue-number {
font-weight: 700;
color: #1e293b;
}
.status-chip {
font-weight: 600;
font-size: 11px;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
}
.action-buttons-table .v-btn {
text-transform: none;
font-weight: 600;
font-size: 12px;
height: 32px;
}
.dialog-header {
background: #f8fafc;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
}
.klinik-card,
.penunjang-card {
cursor: pointer;
transition: all 0.2s;
border: 2px solid #e2e8f0;
background: white;
}
.klinik-card:hover,
.penunjang-card:hover {
border-color: #3b82f6;
background: #eff6ff;
}
.klinik-name,
.penunjang-name {
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.v-btn {
text-transform: none !important;
letter-spacing: 0 !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.search-field {
max-width: 100%;
}
.action-grid {
grid-template-columns: 1fr;
}
.call-buttons {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+97 -596
View File
@@ -1,96 +1,55 @@
<template>
<div class="loket-container">
<!-- Compact Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<v-icon size="28" color="white">mdi-view-dashboard</v-icon>
<div class="header-text">
<h1 class="page-title">Admin Loket</h1>
<p class="page-subtitle">{{ currentDate }}</p>
</div>
</div>
</div>
</div>
<PageHeader
icon="mdi-view-dashboard"
title="Admin Loket"
:subtitle="currentDate"
:show-add-button="false"
theme="primary"
/>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient -->
<!-- Left Column: Current Patient & Queue Actions -->
<v-col cols="12" md="5">
<v-card class="current-patient-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">SEDANG DIPROSES</div>
<div v-if="currentProcessingPatient" class="patient-details">
<div class="patient-number mb-2">{{ currentProcessingPatient.noAntrian.split(" |")[0] }}</div>
<div class="patient-info-text mb-3">
<div>{{ currentProcessingPatient.barcode }}</div>
<div>{{ currentProcessingPatient.klinik }} | {{ currentProcessingPatient.pembayaran }}</div>
</div>
<div class="action-grid">
<v-btn color="success-600" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'check-in')">
<v-icon start size="20">mdi-check</v-icon>
Selesai
</v-btn>
<v-btn color="primary-600" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'terlambat')">
<v-icon start size="20">mdi-clock-alert</v-icon>
Terlambat
</v-btn>
<v-btn color="danger-600" variant="flat" block size="large" @click="processPatient(currentProcessingPatient, 'pending')">
<v-icon start size="20">mdi-pause</v-icon>
Pending
</v-btn>
<v-btn color="secondary-600" variant="flat" block size="large" @click="showChangeKlinikDialog = true">
<v-icon start size="20">mdi-swap-horizontal</v-icon>
Ubah Klinik
</v-btn>
</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">Tidak ada pasien yang diproses</div>
</div>
</v-card-text>
</v-card>
<!-- Current Patient Card -->
<CurrentPatientCard
:patient="currentProcessingPatient"
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
/>
<!-- Queue Actions -->
<v-card class="queue-actions-card mt-3" 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">150</span>
</div>
<div class="quota-item">
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ 150 - quotaUsed }}</span>
</div>
<div class="quota-item full-width">
<v-progress-linear :model-value="(quotaUsed / 150) * 100" color="success-600" height="6" rounded class="mt-1"></v-progress-linear>
<span class="quota-used">Terpakai: {{ quotaUsed }}</span>
</div>
</div>
<div class="call-buttons">
<v-btn color="success-600" variant="outlined" @click="callNext" :disabled="!nextPatient">1</v-btn>
<v-btn color="secondary-600" variant="outlined" @click="callMultiplePatients(5)">5</v-btn>
<v-btn color="primary-600" variant="outlined" @click="callMultiplePatients(10)">10</v-btn>
<v-btn color="danger-600" variant="outlined" @click="callMultiplePatients(20)">20</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Queue Actions Card -->
<QueueActionsCard
class="mt-3"
:total-quota="150"
:used-quota="quotaUsed"
:has-next="!!nextPatient"
@call="handleCall"
/>
<!-- Create Queue Buttons -->
<div class="create-buttons mt-3">
<v-btn color="primary-600" variant="flat" block size="large" class="mb-2" @click="showKlinikDialog = true">
<v-btn
color="primary-600"
variant="flat"
block
size="large"
class="mb-2"
@click="showKlinikDialog = true"
>
<v-icon start size="20">mdi-hospital-building</v-icon>
Buat Antrean Klinik
</v-btn>
<v-btn color="secondary-600" variant="flat" block size="large" @click="openPenunjangDialog()">
<v-btn
color="secondary-600"
variant="flat"
block
size="large"
class="text-white"
@click="openPenunjangDialog()"
>
<v-icon start size="20">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
@@ -99,219 +58,66 @@
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<v-card class="patient-table-card" elevation="0">
<v-card-text class="pa-4">
<!-- Table Header with Filters -->
<div class="table-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<v-chip-group v-model="selectedStatus" mandatory class="status-filter">
<v-chip value="all" :class="{ 'active-chip': selectedStatus === 'all' }">
Semua ({{ allPatients.length }})
</v-chip>
<v-chip value="diloket" :class="{ 'active-chip': selectedStatus === 'diloket' }">
Di Loket ({{ (diLoketPatients || []).length }})
</v-chip>
<v-chip value="terlambat" :class="{ 'active-chip': selectedStatus === 'terlambat' }">
Terlambat ({{ (terlambatPatients || []).length }})
</v-chip>
<v-chip value="pending" :class="{ 'active-chip': selectedStatus === 'pending' }">
Pending ({{ (pendingPatients || []).length }})
</v-chip>
</v-chip-group>
<v-text-field
v-model="searchQuery"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
></v-text-field>
</div>
</div>
<!-- Data Table -->
<v-data-table
:headers="tableHeaders"
:items="filteredPatients"
:search="searchQuery"
class="patient-table"
:items-per-page="10"
density="comfortable"
>
<template #item.noAntrian="{ item }">
<div class="queue-number">{{ item.noAntrian.split(" |")[0] }}</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="status-chip"
>
{{ getStatusLabel(item.status) }}
</v-chip>
</template>
<template #item.klinik="{ item }">
<v-chip size="small" variant="outlined" class="klinik-chip">
{{ item.klinik }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<div class="action-buttons-table">
<v-btn
v-if="item.status === 'diloket'"
size="small"
color="primary-600"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
<v-btn
v-else-if="item.status === 'terlambat'"
size="small"
color="success-600"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
<v-btn
v-else-if="item.status === 'pending'"
size="small"
color="success-600"
variant="flat"
@click="processPatient(item, 'proses')"
>
Proses
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
<PatientDataTable
:items="allPatients"
v-model:selected-status="selectedStatus"
v-model:search-query="searchQuery"
:di-loket-count="(diLoketPatients || []).length"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
@action="handleTableAction"
/>
</v-col>
</v-row>
<!-- Klinik Dialog -->
<v-dialog v-model="showKlinikDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showKlinikDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Pilih Klinik</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="klinikSearch"
placeholder="Cari klinik..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="klinik in filteredKliniks" :key="klinik.id" cols="6" sm="4">
<v-card class="klinik-card" @click="selectKlinik(klinik)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="klinik-name">{{ klinik.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<SelectionDialog
v-model="showKlinikDialog"
title="Pilih Klinik"
:items="filteredKliniks"
v-model:search-query="klinikSearch"
search-placeholder="Cari klinik..."
@select="selectKlinik"
/>
<!-- Penunjang Dialog -->
<v-dialog v-model="showPenunjangDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showPenunjangDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Pilih Penunjang</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="penunjangSearch"
placeholder="Cari penunjang..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="penunjang in filteredPenunjangs" :key="penunjang.id" cols="6" sm="4">
<v-card class="penunjang-card" @click="selectPenunjang(penunjang)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="penunjang-name">{{ penunjang.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<SelectionDialog
v-model="showPenunjangDialog"
title="Pilih Penunjang"
:items="filteredPenunjangs"
v-model:search-query="penunjangSearch"
search-placeholder="Cari penunjang..."
@select="selectPenunjang"
/>
<!-- Change Klinik Dialog -->
<v-dialog v-model="showChangeKlinikDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="showChangeKlinikDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>Ubah Klinik</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="changeKlinikSearch"
placeholder="Cari klinik..."
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-row dense>
<v-col v-for="klinik in filteredChangeKliniks" :key="klinik.id" cols="6" sm="4">
<v-card class="klinik-card" @click="changeKlinik(klinik)" elevation="0">
<v-card-text class="text-center pa-3">
<div class="klinik-name">{{ klinik.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<SelectionDialog
v-model="showChangeKlinikDialog"
title="Ubah Klinik"
:items="filteredChangeKliniks"
v-model:search-query="changeKlinikSearch"
search-placeholder="Cari klinik..."
@select="changeKlinik"
/>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
{{ snackbarText }}
<template v-slot:actions>
<v-btn icon size="small" @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "../composables/useQueue";
import { useQueue } from "@/composables/useQueue";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
const {
snackbar,
@@ -359,47 +165,25 @@ const allPatients = computed(() => {
const terlambat = (terlambatPatients.value || []).map(p => ({ ...p, status: 'terlambat' }));
const pending = (pendingPatients.value || []).map(p => ({ ...p, status: 'pending' }));
console.log('Di Loket:', diLoket.length);
console.log('Terlambat:', terlambat.length);
console.log('Pending:', pending.length);
return [...diLoket, ...terlambat, ...pending];
});
// Filter patients based on selected status
const filteredPatients = computed(() => {
if (selectedStatus.value === "all") return allPatients.value;
return allPatients.value.filter(p => p.status === selectedStatus.value);
});
const tableHeaders = ref([
{ title: "No", value: "no", width: "60px", sortable: false },
{ title: "Jam Panggil", value: "jamPanggil", width: "100px" },
{ title: "Barcode", value: "barcode", width: "130px" },
{ title: "No Antrian", value: "noAntrian", width: "140px" },
{ title: "Klinik", value: "klinik", width: "100px" },
{ title: "Fast Track", value: "fastTrack", width: "100px" },
{ title: "Pembayaran", value: "pembayaran", width: "100px" },
{ title: "Status", value: "status", width: "100px" },
{ title: "Aksi", value: "aksi", width: "100px", sortable: false },
]);
const getStatusColor = (status) => {
const colors = {
diloket: "var(--color-secondary-600)",
terlambat: "var(--color-primary-600)",
pending: "var(--color-danger-600)"
};
return colors[status] || "var(--color-neutral-600)";
const handlePatientAction = (action) => {
if (currentProcessingPatient.value) {
processPatient(currentProcessingPatient.value, action);
}
};
const getStatusLabel = (status) => {
const labels = {
diloket: "Di Loket",
terlambat: "Terlambat",
pending: "Pending"
};
return labels[status] || status;
const handleCall = (count) => {
if (count === 1) {
callNext();
} else {
callMultiplePatients(count);
}
};
const handleTableAction = (item, action) => {
processPatient(item, action);
};
</script>
@@ -410,42 +194,6 @@ const getStatusLabel = (status) => {
padding: 16px;
}
.page-header {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.2);
}
.header-content {
padding: 20px 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-text {
color: var(--color-neutral-100);
}
.page-title {
font-size: 24px;
font-weight: 700;
margin: 0;
line-height: 1.2;
color: var(--color-neutral-100);
}
.page-subtitle {
font-size: 14px;
margin: 4px 0 0 0;
opacity: 0.9;
color: var(--color-neutral-100);
}
.content-grid {
margin: 0 -8px;
}
@@ -454,261 +202,14 @@ const getStatusLabel = (status) => {
padding: 8px;
}
.current-patient-card,
.queue-actions-card,
.patient-table-card {
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;
}
.patient-details {
background: linear-gradient(135deg, var(--color-primary-200) 0%, var(--color-primary-300) 100%);
border-radius: 8px;
padding: 16px;
}
.patient-number {
font-size: 28px;
font-weight: 800;
color: var(--color-neutral-900);
line-height: 1;
}
.patient-info-text {
font-size: 13px;
color: var(--color-neutral-700);
line-height: 1.5;
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.empty-state {
text-align: center;
padding: 32px 16px;
}
.empty-text {
font-size: 13px;
color: var(--color-neutral-600);
margin-top: 8px;
}
.quota-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.quota-item {
background: var(--color-neutral-300);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.quota-item.full-width {
grid-column: 1 / -1;
}
.quota-label {
display: block;
font-size: 11px;
color: var(--color-neutral-600);
margin-bottom: 4px;
font-weight: 600;
}
.quota-value {
display: block;
font-size: 24px;
font-weight: 800;
color: var(--color-neutral-900);
}
.quota-available {
color: var(--color-success-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;
font-weight: 700;
height: 44px;
}
.create-buttons .v-btn {
text-transform: none;
font-weight: 600;
}
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
}
.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);
}
.status-filter .v-chip.active-chip {
background: var(--color-secondary-600);
color: var(--color-neutral-100);
border-color: var(--color-secondary-600);
}
.search-field {
max-width: 250px;
}
:deep(.patient-table) {
border-radius: 8px;
}
:deep(.patient-table .v-data-table__thead) {
background: var(--color-neutral-300);
}
:deep(.patient-table th) {
font-size: 11px !important;
font-weight: 700 !important;
color: var(--color-neutral-600) !important;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 8px !important;
}
:deep(.patient-table td) {
font-size: 13px !important;
padding: 10px 8px !important;
border-bottom: 1px solid var(--color-neutral-400) !important;
}
:deep(.patient-table tbody tr:hover) {
background: var(--color-neutral-300) !important;
}
.queue-number {
font-weight: 700;
color: var(--color-neutral-900);
}
.status-chip {
font-weight: 600;
font-size: 11px;
}
.klinik-chip {
font-size: 11px;
font-weight: 600;
}
.action-buttons-table .v-btn {
text-transform: none;
font-weight: 600;
font-size: 12px;
height: 32px;
}
.dialog-header {
background: var(--color-neutral-300);
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
color: var(--color-neutral-900);
}
.klinik-card,
.penunjang-card {
cursor: pointer;
transition: all 0.2s;
border: 2px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.klinik-card:hover,
.penunjang-card:hover {
border-color: var(--color-secondary-600);
background: var(--color-secondary-200);
}
.klinik-name,
.penunjang-name {
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-900);
}
.v-btn {
text-transform: none !important;
letter-spacing: 0 !important;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.search-field {
max-width: 100%;
}
.action-grid {
grid-template-columns: 1fr;
}
.call-buttons {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+329 -175
View File
@@ -22,7 +22,7 @@
<!-- Hero Call Section -->
<div v-if="currentCalledQueue" class="hero-call-section">
<div class="call-label">
<v-icon size="32" class="mr-2">mdi-bullhorn</v-icon>
<v-icon size="32" color="white" class="mr-2">mdi-bullhorn</v-icon>
NOMOR ANTRIAN YANG DIPANGGIL
</div>
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
@@ -39,13 +39,9 @@
<!-- Clinic Header -->
<div class="clinic-header-bar">
<span class="clinic-title">{{ ruang.namaKlinik }} - {{ ruang.namaRuang }}</span>
<v-chip
:color="ruang.totalQueues > 0 ? '#FF9800' : '#89939E'"
size="small"
class="clinic-count"
>
<v-icon size="16" class="mr-1">mdi-account-multiple</v-icon>
{{ ruang.totalQueues }}
<v-chip size="small" class="clinic-count">
<v-icon size="16" color="white" class="mr-1">mdi-account-multiple</v-icon>
<span class="count-text">{{ ruang.totalQueues }}</span>
</v-chip>
</div>
@@ -75,7 +71,7 @@
<!-- Empty State -->
<div v-if="!ruang.currentQueue && ruang.nextQueues.length === 0" class="empty-state">
<v-icon size="56" color="#F5F7FA">mdi-clock-outline</v-icon>
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
@@ -85,8 +81,8 @@
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon" style="background: #FFF3E0;">
<v-icon size="32" color="#FF9800">mdi-format-list-numbered</v-icon>
<div class="stat-icon stat-icon-total">
<v-icon size="32" color="primary-600">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.total }}</div>
@@ -97,8 +93,8 @@
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon" style="background: #FFF3E0;">
<v-icon size="32" color="#FF9800">mdi-clock-alert-outline</v-icon>
<div class="stat-icon stat-icon-waiting">
<v-icon size="32" color="primary-600">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.waiting }}</div>
@@ -109,8 +105,8 @@
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon" style="background: #E8F5E9;">
<v-icon size="32" color="#18B653">mdi-account-check</v-icon>
<div class="stat-icon stat-icon-active">
<v-icon size="32" color="success-600">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.active }}</div>
@@ -119,7 +115,7 @@
</div>
<div class="footer-message">
<v-icon size="24" color="#FF9800" class="mr-2">mdi-information</v-icon>
<v-icon size="24" color="primary-600" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor antrian Anda di layar</span>
</div>
</div>
@@ -142,24 +138,18 @@ const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Get patients from klinik stage
const klinikPatients = computed(() => {
return queueStore.getPatientsByStage('klinik').value.all
})
// Get all ruang from master store
const allRuangList = computed(() => {
return masterStore.getAllRuangList || []
})
// Display ruang with queue data
const displayedRuang = computed(() => {
// Ambil max 9 ruang pertama untuk display
const ruangToDisplay = (allRuangList.value || []).slice(0, 9)
return ruangToDisplay.map(ruang => {
// Filter pasien berdasarkan klinik dan ruang (jika ada field ruang di patient data)
// Untuk sementara, kita filter berdasarkan klinik saja
const queues = klinikPatients.value
.filter(p => p.klinik === ruang.namaKlinik)
.sort((a, b) => {
@@ -205,7 +195,6 @@ const currentCalledQueue = computed(() => {
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
if (calledQueues.length > 0) {
// Find ruang for this patient
const patient = calledQueues[0]
const ruang = (allRuangList.value || []).find(r => r.namaKlinik === patient.klinik)
return {
@@ -252,61 +241,41 @@ onUnmounted(() => {
</script>
<style scoped lang="scss">
// Colors from Design System
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$primary-600: #FFA532;
$success-600: #009262;
$danger-700: #E01507;
$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;
$font-weight-bold: 700;
// Apply font family
* {
font-family: $font-family-base;
}
.antrian-display-container {
background: $neutral-100;
background: var(--color-neutral-300);
min-height: 100vh;
max-width: 1920px;
max-height: 1080px;
width: 100vw;
height: 100vh;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 auto;
}
// ============================================
// HEADER
// ============================================
/* ========== HEADER ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
border-radius: 20px;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(255, 152, 0, 0.2);
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(255, 155, 27, 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.15);
width: 80px;
height: 80px;
border-radius: 50%;
@@ -317,122 +286,105 @@ $font-weight-bold: 700;
}
.hospital-name {
font-size: 42px;
line-height: 1.2;
font-weight: $font-weight-bold;
color: $neutral-100;
font-size: 48px;
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 1px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.display-subtitle {
font-size: 22px;
line-height: 1.3;
font-weight: $font-weight-medium;
color: $neutral-100;
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
line-height: 1;
font-weight: $font-weight-bold;
color: $neutral-100;
font-weight: 900;
color: var(--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;
line-height: 1.3;
color: $neutral-100;
color: var(--color-neutral-100);
margin-top: 8px;
font-weight: $font-weight-regular;
font-weight: 500;
opacity: 0.95;
}
// ============================================
// HERO CALL SECTION
// ============================================
/* ========== HERO CALL SECTION ========== */
.hero-call-section {
background: linear-gradient(135deg, $danger-700 0%, $danger-600 100%);
background: linear-gradient(135deg, var(--color-danger-600) 0%, var(--color-danger-700) 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 24px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
animation: pulse-shadow 2s infinite;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
}
.call-label {
font-size: 28px;
line-height: 1.3;
font-weight: $font-weight-bold;
color: $neutral-100;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 2px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.call-number {
font-size: 110px;
line-height: 1;
font-weight: $font-weight-bold;
color: $neutral-100;
font-size: 140px;
font-weight: 900;
color: var(--color-neutral-100);
margin: 16px 0;
letter-spacing: 4px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2);
letter-spacing: 8px;
line-height: 1;
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.call-clinic {
font-size: 38px;
line-height: 1.2;
font-weight: $font-weight-bold;
color: $neutral-100;
font-size: 36px;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 1px;
}
@keyframes pulse-shadow {
0%, 100% {
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
}
50% {
box-shadow: 0 16px 48px rgba(224, 21, 7, 0.4);
}
}
// ============================================
// CLINICS GRID
// ============================================
/* ========== CLINICS GRID ========== */
.clinics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 24px;
gap: 16px;
margin-bottom: 20px;
flex: 1;
}
.clinic-box {
background: $neutral-100;
border: 2px solid #FFF3E0;
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.08);
transition: all 0.3s ease;
min-height: 260px;
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.12);
min-height: 240px;
display: flex;
flex-direction: column;
}
.clinic-box:hover {
box-shadow: 0 8px 24px rgba(255, 152, 0, 0.15);
transform: translateY(-2px);
}
.clinic-header-bar {
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 16px 20px;
display: flex;
justify-content: space-between;
@@ -441,20 +393,27 @@ $font-weight-bold: 700;
.clinic-title {
font-size: 22px;
line-height: 1.3;
font-weight: $font-weight-bold;
color: $neutral-100;
font-weight: 800;
color: var(--color-neutral-100);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
flex: 1;
}
.clinic-count {
background: rgba(255, 255, 255, 0.2) !important;
color: $neutral-100 !important;
font-weight: $font-weight-bold;
font-size: 16px;
line-height: 1.2;
color: var(--color-neutral-100) !important;
font-weight: 700;
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: 4px;
}
.count-text {
font-size: 18px;
font-weight: 800;
}
.clinic-content {
@@ -462,43 +421,44 @@ $font-weight-bold: 700;
display: flex;
flex-direction: column;
justify-content: center;
padding: 24px;
background: #FAFBFC;
padding: 20px;
background: var(--color-primary-100);
}
.current-section {
text-align: center;
margin-bottom: 16px;
margin-bottom: 12px;
}
.current-label {
font-size: 16px;
line-height: 1.2;
font-weight: $font-weight-bold;
color: $success-600;
font-weight: 700;
color: var(--color-success-600);
margin-bottom: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.current-number {
font-size: 68px;
font-size: 80px;
font-weight: 900;
color: var(--color-neutral-900);
letter-spacing: 4px;
line-height: 1;
font-weight: $font-weight-bold;
color: $neutral-900;
letter-spacing: 2px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.next-section {
text-align: center;
margin-top: 8px;
}
.next-label {
font-size: 13px;
line-height: 1.2;
font-weight: $font-weight-bold;
color: $neutral-700;
margin-bottom: 10px;
letter-spacing: 1px;
font-weight: 700;
color: var(--color-neutral-700);
margin-bottom: 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
@@ -510,15 +470,16 @@ $font-weight-bold: 700;
}
.next-item {
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
color: $neutral-100;
padding: 8px 18px;
border-radius: 10px;
background: var(--color-secondary-600);
color: var(--color-neutral-100);
padding: 8px 16px;
border-radius: 12px;
font-size: 22px;
line-height: 1.3;
font-weight: $font-weight-bold;
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.2);
font-weight: 700;
box-shadow: 0 2px 8px rgba(6, 99, 199, 0.25);
letter-spacing: 1px;
min-width: 60px;
text-align: center;
}
.empty-state {
@@ -527,25 +488,22 @@ $font-weight-bold: 700;
}
.empty-text {
font-size: 18px;
line-height: 1.3;
color: $neutral-600;
font-size: 16px;
color: var(--color-neutral-600);
margin-top: 12px;
font-weight: $font-weight-semibold;
font-weight: 600;
}
// ============================================
// FOOTER STATS
// ============================================
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: $neutral-100;
border: 2px solid #FFF3E0;
border-radius: 20px;
padding: 24px 40px;
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
padding: 20px 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.08);
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.12);
}
.stat-item {
@@ -563,36 +521,232 @@ $font-weight-bold: 700;
justify-content: center;
}
.stat-icon-total,
.stat-icon-waiting {
background: var(--color-primary-200);
}
.stat-icon-active {
background: var(--color-success-200);
}
.stat-info {
text-align: left;
}
.stat-value {
font-size: 42px;
font-size: 40px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
font-weight: $font-weight-bold;
color: $neutral-900;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 15px;
line-height: 1.3;
color: $neutral-700;
font-weight: $font-weight-semibold;
font-size: 14px;
color: var(--color-neutral-700);
font-weight: 600;
margin-top: 4px;
opacity: 0.95;
}
.stat-divider {
width: 2px;
height: 64px;
background: #FFF3E0;
height: 60px;
background: var(--color-primary-200);
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
line-height: 1.3;
font-weight: $font-weight-semibold;
color: $neutral-800;
font-weight: 600;
color: var(--color-neutral-800);
display: flex;
align-items: center;
justify-content: flex-end;
}
/* ========== RESPONSIVE ========== */
@media (min-width: 1920px) and (min-height: 1080px) {
.antrian-display-container {
width: 1920px;
height: 1080px;
padding: 24px;
}
}
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
}
.display-header {
padding: 16px 24px;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-clinic {
font-size: 28px;
}
.clinics-grid {
gap: 12px;
}
.clinic-box {
min-height: 220px;
padding: 0;
}
.clinic-header-bar {
padding: 14px 16px;
}
.clinic-title {
font-size: 20px;
}
.clinic-content {
padding: 16px;
}
.current-number {
font-size: 72px;
}
.next-item {
font-size: 20px;
padding: 6px 14px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
}
}
@media (max-width: 1024px) {
.display-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.clinics-grid {
grid-template-columns: repeat(2, 1fr);
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 768px) {
.clinics-grid {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.call-number {
font-size: 80px;
}
.current-number {
font-size: 60px;
}
}
/* Prevent text selection */
* {
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+557 -397
View File
File diff suppressed because it is too large Load Diff
+208 -140
View File
@@ -3,12 +3,12 @@
<div class="d-flex justify-space-between align-center mb-10">
<div>
<h1 class="text-h3 font-weight-black primary-text">
<h1 class="dashboard-title">
Antrean Dashboard
</h1>
<p v-if="user" class="text-subtitle-1 secondary-text mt-2">
<p v-if="user" class="dashboard-subtitle">
Selamat Datang,
<span class="font-weight-bold primary-accent">
<span class="user-name">
{{ user.name || user.preferred_username }}
</span>! 🍊
</p>
@@ -19,9 +19,9 @@
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="#FF9B1B"
color="primary-600"
variant="flat"
class="text-caption font-weight-bold rounded-lg elevation-2 mr-4 export-btn"
class="export-btn mr-4"
>
<v-icon start icon="mdi-download-box-outline"></v-icon>
Export Data ({{ exportType }})
@@ -42,8 +42,8 @@
</v-list>
</v-menu>
<v-chip color="white" variant="flat" class="pa-4 font-weight-bold text-caption secondary-text rounded-lg date-chip">
<v-icon start icon="mdi-calendar-check-outline" class="primary-accent"></v-icon>
<v-chip color="white" variant="flat" class="date-chip">
<v-icon start icon="mdi-calendar-check-outline" color="primary-600"></v-icon>
{{ currentDate }}
</v-chip>
</div>
@@ -51,48 +51,48 @@
<v-row class="mb-10">
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #D17A4A;">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 primary-accent">mdi-tablet-dashboard</v-icon>
<v-icon size="48" color="primary-600" class="mr-4">mdi-tablet-dashboard</v-icon>
<div>
<div class="text-h4 font-weight-black primary-accent">150</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Antrean Online</div>
<div class="stat-value">150</div>
<div class="stat-label">Total Antrean Online</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #4A5F7A;">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 secondary-accent">mdi-account-hard-hat</v-icon>
<v-icon size="48" color="secondary-600" class="mr-4">mdi-account-hard-hat</v-icon>
<div>
<div class="text-h4 font-weight-black secondary-accent">100</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Antrean MJKN</div>
<div class="stat-value">100</div>
<div class="stat-label">Total Antrean MJKN</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #D17A4A;">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 primary-accent">mdi-account-group</v-icon>
<v-icon size="48" color="primary-600" class="mr-4">mdi-account-group</v-icon>
<div>
<div class="text-h4 font-weight-black primary-accent">500</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Kunjungan</div>
<div class="stat-value">500</div>
<div class="stat-label">Total Kunjungan</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #4A5F7A;">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 secondary-accent">mdi-timer-sand</v-icon>
<v-icon size="48" color="secondary-600" class="mr-4">mdi-timer-sand</v-icon>
<div>
<div class="text-h4 font-weight-black secondary-accent">7.5s</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Avg. Check-in Time</div>
<div class="stat-value">7.5s</div>
<div class="stat-label">Avg. Check-in Time</div>
</div>
</div>
</v-card>
@@ -101,10 +101,10 @@
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="#D17A4A" class="text-caption rounded-lg">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="primary-600" class="period-toggle">
<v-btn size="small" value="day">Day</v-btn>
</v-btn-toggle>
</v-card-title>
@@ -119,24 +119,24 @@
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Total Registrasi Perbulan</span>
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Total Registrasi Perbulan</span>
<div>
<v-chip
:color="activeYear === '2024' ? '#D17A4A' : '#F5F5F5'"
:text-color="activeYear === '2024' ? 'white' : '#FF9B1B'"
:color="activeYear === '2024' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2024' ? 'white' : 'neutral-700'"
variant="flat"
class="mr-2 text-caption font-weight-bold cursor-pointer transition-chip rounded-pill"
class="year-chip mr-2"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? '#D17A4A' : '#F5F5F5'"
:text-color="activeYear === '2025' ? 'white' : '#FF9B1B'"
:color="activeYear === '2025' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2025' ? 'white' : 'neutral-700'"
variant="flat"
class="text-caption font-weight-bold cursor-pointer transition-chip rounded-pill"
class="year-chip"
@click="changeYear('2025')"
>
2025
@@ -156,10 +156,10 @@
<v-row>
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">
Realtime Check-in Velocity
<v-chip size="small" color="success" class="ml-2">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Realtime Check-in Velocity</span>
<v-chip size="small" color="success-600" class="live-chip">
<v-icon start size="small">mdi-circle</v-icon>
Live
</v-chip>
@@ -175,8 +175,10 @@
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">Registrant Type Breakdown</v-card-title>
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Registrant Type Breakdown</span>
</v-card-title>
<v-card-text class="d-flex justify-center align-center pa-0" style="height: 350px;">
<Pie
:data="pieData"
@@ -188,9 +190,9 @@
</v-col>
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center dashboard-bg">
<v-progress-circular indeterminate size="64" color="#D17A4A"></v-progress-circular>
<p class="mt-4 primary-text">Loading dashboard...</p>
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="primary-600"></v-progress-circular>
<p class="loading-text">Loading dashboard...</p>
</v-overlay>
</v-container>
</template>
@@ -226,17 +228,11 @@ definePageMeta({
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// --- KEYCLOAK/AUTH INTEGRATION ---
// const { user, isLoading, checkAuth, logout } = useAuth()
// const navigateTo = (path) => { console.log('Navigating to', path); };
// Mock user for demo purposes - replace with your actual auth
const user = ref({ name: 'Admin User', preferred_username: 'admin' });
const isLoading = ref(false);
const checkAuth = async () => user.value;
const navigateTo = (path) => console.log('Navigate to:', path);
// ---------------------------------
// --- EXPORT STATE ---
const exportType = ref('JSON');
const exportOptions = ref([
{ title: 'JSON (.json)', type: 'json', icon: 'mdi-code-json' },
@@ -244,14 +240,11 @@ const exportOptions = ref([
{ title: 'Excel (.xlsx) - Server Only', type: 'excel', icon: 'mdi-file-excel' },
{ title: 'PDF (.pdf) - Placeholder', type: 'pdf', icon: 'mdi-file-pdf' },
]);
// --------------------
// Dashboard state
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('day');
// --- MOCK DATA FOR CHARTS ---
const mockQueueData = ref([
{ date: '2025-10-13', registrants: 300, attendees: 250 },
{ date: '2025-10-14', registrants: 450, attendees: 400 },
@@ -261,7 +254,6 @@ const mockQueueData = ref([
{ date: '2025-10-18', registrants: 435, attendees: 380 },
]);
// --- REALTIME CHART LOGIC - FIXED ---
const realtimeChart = ref(null);
const maxDataPoints = 10;
let realtimeInterval = null;
@@ -273,15 +265,15 @@ const realtimeLineData = ref({
datasets: [
{
label: 'Check-ins/min',
backgroundColor: 'rgba(74, 95, 122, 0.15)',
borderColor: '#5FB7FF',
backgroundColor: 'rgba(6, 99, 199, 0.1)',
borderColor: '#0671E0',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
fill: true,
tension: 0.3,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#4A5F7A',
pointBackgroundColor: '#0663C7',
},
],
});
@@ -289,7 +281,6 @@ const realtimeLineData = ref({
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
plugins: {
legend: { display: true },
title: { display: false }
@@ -312,32 +303,22 @@ const updateRealtimeData = () => {
const data = realtimeLineData.value.datasets[0].data;
const labels = realtimeLineData.value.labels;
// Remove first data point
data.shift();
labels.shift();
// Add new data point
const newDataPoint = Math.floor(Math.random() * 20) + 40;
const newTimeLabel = dayjs().format('HH:mm:ss');
data.push(newDataPoint);
labels.push(newTimeLabel);
console.log('Realtime chart updated:', newTimeLabel, newDataPoint);
};
// --- END REALTIME CHART LOGIC ---
onMounted(async () => {
try {
const sessionUser = await checkAuth()
if (sessionUser) {
// Set date with Indonesian locale
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
console.log('Current date set:', currentDate.value);
// Start realtime updates every 5 seconds
realtimeInterval = setInterval(updateRealtimeData, 5000);
console.log('Realtime interval started');
} else {
await navigateTo('/LoginPage');
}
@@ -350,7 +331,6 @@ onMounted(async () => {
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
console.log('Realtime interval cleared');
}
});
@@ -358,7 +338,6 @@ const changeYear = (year) => {
activeYear.value = year;
};
// --- DATA UTILITY FUNCTIONS ---
const getKpiData = () => {
return [
{ name: 'Antrean Online', value: 150 },
@@ -385,9 +364,7 @@ const convertJsonToCsv = (jsonData, fields) => {
return header + '\n' + csv;
};
// --- END DATA UTILITY FUNCTIONS ---
// --- EXPORT FUNCTION HANDLER ---
const downloadFile = (data, filename, mimeType) => {
const dataStr = `data:${mimeType};charset=utf-8,` + encodeURIComponent(data);
const downloadAnchorNode = document.createElement('a');
@@ -425,9 +402,7 @@ const handleExport = (type) => {
alert("PDF (.pdf) export requires client-side libraries (like jsPDF) or a server service. Simulating download... 📝");
}
};
// --- END EXPORT FUNCTION HANDLER ---
// --- CHART DATA LOGIC ---
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
@@ -473,12 +448,12 @@ const processQueueData = (data, period) => {
{
label: `Attendees`,
backgroundColor: 'transparent',
borderColor: '#5FB7FF',
borderColor: '#0671E0',
data: finalAttendees,
yAxisID: 'y1',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#4A5F7A',
pointBackgroundColor: '#0663C7',
tension: 0.4,
borderWidth: 3,
},
@@ -530,13 +505,13 @@ const barData = computed(() => {
},
{
label: `Conv. Rate (%)`,
borderColor: '#5FB7FF',
borderColor: '#0671E0',
backgroundColor: 'transparent',
data: conversionData,
yAxisID: 'y2',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#4A5F7A',
pointBackgroundColor: '#0663C7',
tension: 0.4,
borderWidth: 3,
}
@@ -574,8 +549,8 @@ const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#5FB7FF', '#FFB95F'],
hoverBackgroundColor: ['#1B98FF ', '#FF9B1B'],
backgroundColor: ['#0671E0', '#FFB95F'],
hoverBackgroundColor: ['#0053AD', '#FF9B1B'],
data: [759, 1876],
borderWidth: 3,
borderColor: '#ffffff',
@@ -602,90 +577,183 @@ const pieOptions = ref({
});
</script>
<style scoped>
/* Color Palette */
<style scoped lang="scss">
/* Background */
.dashboard-bg {
background-color: #FAFAFA;
background: var(--color-neutral-300);
}
.card-style {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.04);
/* Typography */
.dashboard-title {
font-size: 32px;
font-weight: 800;
color: var(--color-neutral-900);
margin: 0;
line-height: 1.2;
}
/* Text Colors */
.primary-text {
color: #2C3E50 !important;
.dashboard-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-700);
margin: 8px 0 0 0;
}
.secondary-text {
color: #7B8794 !important;
.user-name {
font-weight: 700;
color: var(--color-primary-600);
}
.secondary-light {
color: #7B8794 !important;
}
.primary-accent {
color: #FF9B1B !important;
}
.secondary-accent {
color: #1B98FF !important;
}
/* Buttons & Interactive Elements */
/* Export Button */
.export-btn {
transition: all 0.3s ease;
text-transform: none;
font-weight: 600;
font-size: 14px;
letter-spacing: 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.2);
}
.export-btn:hover {
background-color: #E0916E !important;
transform: translateY(-1px);
}
.cursor-pointer {
cursor: pointer;
}
/* Card Hover Effect - Subtle and Clean */
.hover-effect {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.hover-effect:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08) !important;
}
/* Chip Transition */
.transition-chip {
transition: all 0.25s ease;
}
.transition-chip:hover {
transform: scale(1.05);
}
/* Date Chip Styling */
/* Date Chip */
.date-chip {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.04);
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid var(--color-neutral-500);
}
/* Chart Card Fixed Height */
/* Stat Cards */
.stat-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 24px;
border: 2px solid transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
}
.stat-card-primary {
border-left-color: var(--color-primary-600);
}
.stat-card-secondary {
border-left-color: var(--color-secondary-600);
}
.stat-value {
font-size: 36px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1.4;
}
/* Chart Cards */
.chart-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--color-neutral-500);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
min-height: 480px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 16px 0;
margin-bottom: 16px;
}
.chart-title {
font-size: 18px;
font-weight: 700;
color: var(--color-neutral-900);
}
.period-toggle {
font-size: 12px;
font-weight: 600;
border-radius: 8px;
}
.year-chip {
cursor: pointer;
font-size: 13px;
font-weight: 700;
border-radius: 20px;
transition: all 0.2s ease;
}
.year-chip:hover {
transform: scale(1.05);
}
.live-chip {
font-weight: 600;
color: var(--color-neutral-100);
}
.chart-card .v-card-text {
flex: 1;
display: flex;
flex-direction: column;
}
/* Loading */
.loading-text {
margin-top: 16px;
color: var(--color-neutral-900);
font-weight: 600;
font-size: 16px;
}
/* Responsive */
@media (max-width: 960px) {
.dashboard-title {
font-size: 24px;
}
.dashboard-subtitle {
font-size: 14px;
}
.d-flex.justify-space-between {
flex-direction: column;
gap: 16px;
align-items: flex-start !important;
}
.d-flex.align-center .mr-4 {
margin-right: 0 !important;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
}
.stat-label {
font-size: 11px;
}
}
</style>
+817
View File
@@ -0,0 +1,817 @@
<!-- pages/MasterPenunjang.vue -->
<template>
<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-clipboard-pulse</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Master Penunjang</h2>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Manajemen Layanan Penunjang</p>
</div>
</div>
<v-btn
color="white"
elevation="0"
class="add-btn"
@click="openTambahDialog"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Penunjang
</v-btn>
</div>
</div>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="headers"
:items="masterStore.penunjangData"
:items-per-page="10"
class="elevation-0 data-table"
>
<template #item.jenis="{ item }">
<v-chip
size="small"
:class="item.jenis === 'Medis' ? 'chip-medis' : 'chip-non-medis'"
>
{{ item.jenis }}
</v-chip>
</template>
<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.status="{ item }">
<v-chip
size="small"
:class="item.status === 'Aktif' ? 'chip-active' : 'chip-inactive'"
>
{{ item.status }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
class="btn-edit mr-2"
variant="flat"
@click="openEditDialog(item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
class="btn-delete"
variant="outlined"
@click="handleDelete(item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- Dialog Tambah/Edit - COMPACT VERSION -->
<v-dialog v-model="dialog" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Penunjang' : 'Tambah Penunjang' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content-compact">
<v-form ref="formRef">
<!-- Informasi Dasar -->
<div class="form-section">
<div class="section-title">
<v-icon size="18" class="mr-2">mdi-information-outline</v-icon>
<span class="text-semibold">Informasi Dasar</span>
</div>
<v-text-field
v-model="formData.kode"
label="Kode Penunjang"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode harus diisi']"
hide-details="auto"
placeholder="LAB"
class="form-field"
/>
<v-text-field
v-model="formData.nama"
label="Nama Penunjang"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama harus diisi']"
hide-details="auto"
placeholder="Laboratorium"
class="form-field"
/>
<v-row dense>
<v-col cols="6">
<v-select
v-model="formData.jenis"
label="Jenis Penunjang"
:items="jenisPenunjangList"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Jenis harus dipilih']"
hide-details="auto"
class="form-field"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="formData.status"
label="Status"
:items="statusList"
variant="outlined"
density="compact"
hide-details="auto"
class="form-field"
/>
</v-col>
</v-row>
</div>
<v-divider class="section-divider"/>
<!-- Konfigurasi Shift -->
<div class="form-section">
<div class="section-title">
<v-icon size="18" class="mr-2">mdi-clock-outline</v-icon>
<span class="text-semibold">Konfigurasi Shift & Kuota</span>
</div>
<v-row dense>
<v-col cols="6">
<v-text-field
v-model.number="formData.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="form-field"
@update:model-value="updateShiftCount"
/>
</v-col>
</v-row>
<!--
<div class="total-display">
<span class="caption-text">Total Kuota</span>
<v-chip size="small" color="success" variant="flat">
<v-icon size="14" start>mdi-sigma</v-icon>
{{ calculateTotalQuota() }}
</v-chip>
</div> -->
<v-checkbox
v-model="formData.autoShift"
label="Auto Shift"
hide-details
color="primary"
density="compact"
class="form-field-checkbox"
/>
<!-- Shift List -->
<div class="shift-list">
<div v-for="(shift, index) in formData.jamShiftList" :key="index" class="shift-row">
<div class="shift-label">Shift {{ index + 1 }}</div>
<v-row dense>
<v-col cols="4">
<v-text-field
v-model="shift.dari"
label="Mulai"
type="time"
variant="outlined"
density="compact"
hide-details
class="form-field-inline"
/>
</v-col>
<v-col cols="4">
<v-text-field
v-model="shift.sampai"
label="Selesai"
type="time"
variant="outlined"
density="compact"
hide-details
class="form-field-inline"
/>
</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="form-field-inline"
/>
</v-col>
<v-col cols="1" class="d-flex align-center">
<v-btn
v-if="formData.jamShiftList.length > 1"
icon
size="x-small"
variant="text"
color="error"
@click="removeShift(index)"
>
<v-icon size="18">mdi-close</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
</div>
</div>
<v-divider class="section-divider"/>
<!-- Jadwal Operasional -->
<div class="form-section">
<div class="section-title">
<v-icon size="18" class="mr-2">mdi-calendar-check</v-icon>
<span class="text-semibold">Jadwal Operasional</span>
</div>
<v-select
v-model="formData.jadwalOperasional"
:items="hariList.map(h => h.hari)"
label="Pilih Hari Operasional"
variant="outlined"
density="compact"
multiple
chips
hide-details
class="form-field"
>
<template #chip="{ item, props }">
<v-chip
v-bind="props"
size="small"
closable
>
{{ item.title }}
</v-chip>
</template>
</v-select>
</div>
<v-divider class="section-divider"/>
<!-- Keterangan -->
<div class="form-section">
<div class="section-title">
<v-icon size="18" class="mr-2">mdi-note-text-outline</v-icon>
<span class="text-semibold">Keterangan</span>
</div>
<v-textarea
v-model="formData.keterangan"
label="Keterangan Layanan"
variant="outlined"
density="compact"
hide-details
rows="3"
placeholder="Informasi tambahan tentang layanan penunjang..."
class="form-field"
/>
</div>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="closeDialog"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="submitForm"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
<span class="body-3">{{ snackbar.message }}</span>
<template #actions>
<v-btn variant="text" size="small" @click="snackbar.show = false">Tutup</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
const masterStore = useMasterStore();
const dialog = ref(false);
const isEdit = ref(false);
const formRef = ref(null);
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
const headers = ref([
{ title: 'No', value: 'no', sortable: true },
{ title: 'Kode', value: 'kode', sortable: true },
{ title: 'Nama Penunjang', value: 'nama', sortable: true },
{ title: 'Jenis', value: 'jenis', sortable: true },
{ title: 'Shift', value: 'shift', sortable: true },
{ title: 'Total Kuota', value: 'totalQuota', sortable: true },
{ title: 'Status', value: 'status', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
]);
const jenisPenunjangList = ref([
'Medis',
'Non-Medis'
]);
const statusList = ref([
'Aktif',
'Tidak Aktif'
]);
const hariList = ref([
{ no: 1, hari: 'Senin' },
{ no: 2, hari: 'Selasa' },
{ no: 3, hari: 'Rabu' },
{ no: 4, hari: 'Kamis' },
{ no: 5, hari: 'Jum\'at' },
{ no: 6, hari: 'Sabtu' },
{ no: 7, hari: 'Minggu' },
]);
const formData = ref({
id: null,
kode: '',
nama: '',
jenis: 'Medis',
shift: 1,
jamShiftList: [{ dari: '07:00', sampai: '15:00', kuota: 0 }],
autoShift: false,
jadwalOperasional: [],
status: 'Aktif',
keterangan: ''
});
const calculateTotalQuota = () => {
return formData.value.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
};
const updateShiftCount = (newShiftCount) => {
const currentCount = formData.value.jamShiftList.length;
if (newShiftCount > currentCount) {
for (let i = currentCount; i < newShiftCount; i++) {
let defaultDari = '07:00', defaultSampai = '15:00';
if (i === 1) { defaultDari = '13:00'; defaultSampai = '21:00'; }
else if (i === 2) { defaultDari = '21:00'; defaultSampai = '07:00'; }
else if (i > 2) {
const prevShift = formData.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 + 8) % 24).padStart(2, '0')}:00`;
}
formData.value.jamShiftList.push({ dari: defaultDari, sampai: defaultSampai, kuota: 0 });
}
} else if (newShiftCount < currentCount && newShiftCount > 0) {
formData.value.jamShiftList = formData.value.jamShiftList.slice(0, newShiftCount);
}
};
const removeShift = (index) => {
if (formData.value.jamShiftList.length > 1) {
formData.value.jamShiftList.splice(index, 1);
formData.value.shift = formData.value.jamShiftList.length;
}
};
const openTambahDialog = () => {
isEdit.value = false;
resetForm();
dialog.value = true;
};
const openEditDialog = (item) => {
isEdit.value = true;
formData.value = {
id: item.id,
kode: item.kode,
nama: item.nama,
jenis: item.jenis,
shift: item.shift,
jamShiftList: JSON.parse(JSON.stringify(item.jamShiftList)),
autoShift: item.autoShift || false,
jadwalOperasional: [...(item.jadwalOperasional || [])],
status: item.status,
keterangan: item.keterangan || ''
};
dialog.value = true;
};
const closeDialog = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
formData.value = {
id: null,
kode: '',
nama: '',
jenis: 'Medis',
shift: 1,
jamShiftList: [{ dari: '07:00', sampai: '15:00', kuota: 0 }],
autoShift: false,
jadwalOperasional: [],
status: 'Aktif',
keterangan: ''
};
if (formRef.value) {
formRef.value.reset();
}
};
const submitForm = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
let result;
if (isEdit.value) {
result = masterStore.updatePenunjang(formData.value);
} else {
result = masterStore.addPenunjang(formData.value);
}
if (result.success) {
snackbar.value = { show: true, message: result.message, color: 'success' };
closeDialog();
} else {
snackbar.value = { show: true, message: result.message, color: 'error' };
}
};
const handleDelete = (item) => {
if (confirm(`Hapus penunjang ${item.nama}?`)) {
const result = masterStore.deletePenunjang(item.id);
snackbar.value = {
show: true,
message: result.message,
color: result.success ? 'success' : 'error'
};
}
};
</script>
<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;
$primary-700: #7c3aed;
$primary-600: #8b5cf6;
$success-600: #009262;
$success-300: #84DFC1;
$danger-600: #E02B1D;
$medis-600: #10b981;
$medis-300: #d1fae5;
$non-medis-600: #f59e0b;
$non-medis-300: #fef3c7;
// Font
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
* {
font-family: $font-family-base;
}
// ============================================
// 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(139, 92, 246, 0.2);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
}
.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;
}
.add-btn {
font-weight: $font-weight-semibold;
text-transform: none;
letter-spacing: 0.5px;
font-size: 16px;
line-height: 24px;
color: $primary-600 !important;
}
// ============================================
// DATA TABLE
// ============================================
.data-table {
font-family: $font-family-base;
}
.chip-medis {
background-color: $medis-300 !important;
color: $medis-600 !important;
font-weight: $font-weight-semibold;
}
.chip-non-medis {
background-color: $non-medis-300 !important;
color: $non-medis-600 !important;
font-weight: $font-weight-semibold;
}
.chip-secondary {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-active {
background-color: $success-300 !important;
color: $success-600 !important;
font-weight: $font-weight-semibold;
}
.chip-inactive {
background-color: $neutral-400 !important;
color: $neutral-600 !important;
font-weight: $font-weight-semibold;
}
.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;
}
.body-2 {
font-size: 16px;
line-height: 24px;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
// ============================================
// DIALOG - COMPACT VERSION
// ============================================
.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-compact {
padding: 20px 24px !important;
background: $neutral-100;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-100;
}
// ============================================
// FORM SECTIONS - COMPACT
// ============================================
.form-section {
margin-bottom: 16px;
}
.section-title {
display: flex;
align-items: center;
font-size: 13px;
color: $primary-600;
font-weight: $font-weight-semibold;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.section-divider {
margin: 16px 0;
border-color: $neutral-400 !important;
}
.form-field {
margin-bottom: 12px;
}
.form-field-inline {
margin-bottom: 0;
}
.form-field-checkbox {
margin-bottom: 12px;
margin-top: -4px;
}
// Total Display
.total-display {
display: flex;
flex-direction: column;
gap: 4px;
height: 40px;
justify-content: center;
}
.caption-text {
font-size: 11px;
color: $neutral-700;
font-weight: $font-weight-medium;
}
// Shift List
.shift-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.shift-row {
background: $neutral-300;
border-radius: 8px;
padding: 10px 12px;
border: 1px solid $neutral-400;
}
.shift-label {
font-size: 11px;
font-weight: $font-weight-semibold;
color: $primary-600;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
// ============================================
// BUTTONS
// ============================================
.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;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
</style>
+1 -1
View File
@@ -244,7 +244,7 @@ const headers = ref([
{ title: "Nama Screen", value: "namaScreen", sortable: true },
{ title: "Nomor Screen", value: "nomorScreen", sortable: true },
{ title: "Klinik Ditampilkan", value: "klinik", sortable: false },
{ title: "Actions", value: "actions", sortable: false, width: "250px" },
{ title: "Actions", value: "actions", sortable: false, width: "auto" },
]);
const screenItems = ref([
+115 -450
View File
@@ -1,256 +1,87 @@
<template>
<v-app>
<v-card
class="elevation-3 rounded-xl header-banner mx-6 mt-6 mb-n6"
style="position: relative; z-index: 10;"
<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-container fluid class="py-5 px-6 d-flex align-center justify-space-between">
<div class="d-flex align-center">
<div class="icon-container">
<v-icon size="44" class="primary-icon mr-4">mdi-shield-check</v-icon>
</div>
<div>
<h1 class="text-h3 font-weight-black primary-text mb-1">Verifikasi Akun</h1>
<p class="text-subtitle-1 secondary-text mb-0">Manajemen Pasien Digital</p>
</div>
</div>
<v-chip color="#5FB7FF" class="text-white font-weight-black px-5" variant="flat" rounded="xl" size="large">
<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>
Admin
<span class="chip-text">Admin</span>
</v-chip>
</v-container>
</v-card>
</template>
</PageHeader>
<v-main class="bg-main">
<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"
/>
<v-card class="pa-0 mb-6 elevation-3 rounded-xl overflow-hidden card-style">
<v-row class="ma-0 pa-6 align-center search-section">
<v-col cols="12" sm="6" class="py-2">
<v-text-field
v-model="search"
density="comfortable"
label="Cari pasien berdasarkan nama atau RM..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
hide-details
single-line
color="#D17A4A"
bg-color="white"
class="search-field text-body-1"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" class="py-2">
<v-tabs
v-model="filterStatus"
color="#D17A4A"
align-tabs="end"
class="pt-0 filter-tabs"
slider-color="#D17A4A"
>
<v-tab :value="0" class="font-weight-bold text-button">
SEMUA <v-chip size="small" color="#7B8794" class="ml-2 text-white">{{ patients.length }}</v-chip>
</v-tab>
<v-tab :value="1" class="font-weight-bold text-button">
PENDING <v-chip size="small" color="#D17A4A" class="ml-2 text-white">{{ unverifiedPatients.length }}</v-chip>
</v-tab>
<v-tab :value="2" class="font-weight-bold text-button">
VERIFIED <v-chip size="small" color="#4A5F7A" class="ml-2 text-white">{{ verifiedPatients.length }}</v-chip>
</v-tab>
</v-tabs>
</v-col>
</v-row>
<v-card-text class="pa-0 bg-white">
<v-list lines="two" class="pa-0">
<v-list-item
v-for="(patient, index) in filteredPatients"
:key="patient.rm"
class="patient-item py-5 px-6"
:class="{ 'verified-item': patient.status === 'Terverifikasi' }"
>
<template v-slot:prepend>
<v-avatar :color="patient.status === 'Terverifikasi' ? '#32A3FF' : '#FFA532'" 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-list-item-title class="text-h5 font-weight-black mb-2 primary-text">
{{ patient.nama }}
</v-list-item-title>
<v-list-item-subtitle class="mt-2">
<v-row dense class="text-body-1">
<v-col cols="12" sm="3" md="2" class="py-1">
<v-chip size="default" color="#E3F2FD" class="font-weight-bold chip-rm" variant="flat">
<v-icon start size="18" color="#4A5F7A">mdi-file-document</v-icon>
<span style="color: #4A5F7A;">{{ patient.rm }}</span>
</v-chip>
</v-col>
<v-col cols="12" sm="5" md="6" class="py-1 secondary-text d-flex align-center">
<v-icon size="18" class="mr-2" color="#7B8794">mdi-map-marker</v-icon>
<span class="text-body-1">{{ patient.alamat }}</span>
</v-col>
<v-col cols="12" sm="4" md="4" class="py-1 secondary-text d-flex align-center">
<v-icon size="18" class="mr-2" color="#7B8794">mdi-phone</v-icon>
<span class="text-body-1">{{ patient.telepon || 'Belum diisi' }}</span>
</v-col>
</v-row>
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
v-if="patient.status === 'Belum Terverifikasi'"
color="#FFA532"
size="x-large"
@click="openDaftarModal(patient)"
prepend-icon="mdi-qrcode-scan"
variant="flat"
class="font-weight-black elevation-2 action-btn px-8 text-h6 text-white"
rounded="xl"
>
VERIFIKASI
</v-btn>
<v-chip
v-else
color="#32A3FF"
size="x-large"
variant="flat"
class="font-weight-black px-6 text-h6 text-white"
rounded="xl"
>
<v-icon start size="24">mdi-shield-check</v-icon>
VERIFIED
</v-chip>
</template>
</v-list-item>
<v-list-item v-if="filteredPatients.length === 0">
<v-list-item-title class="text-center py-8 text-h5 secondary-text">
{{ noDataText }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<!-- Patient List -->
<PatientVerificationList
:patients="filteredPatients"
:empty-message="noDataText"
@verify="openDaftarModal"
/>
</v-card>
<v-dialog v-model="isModalOpen" max-width="600" transition="dialog-bottom-transition" scrollable>
<v-card class="pa-0 rounded-xl overflow-hidden card-style">
<div class="modal-header pa-7">
<v-icon size="72" color="white" class="mb-3">mdi-qrcode-scan</v-icon>
<h2 class="text-h3 font-weight-black text-white mb-2">Aktivasi Akun</h2>
<p class="text-h6 text-white opacity-90">{{ selectedPatient.nama }}</p>
<v-chip color="white" class="font-weight-bold mt-2" variant="flat" size="default">
<span style="color: #D17A4A;">RM: {{ selectedPatient.rm }}</span>
</v-chip>
</div>
<v-card-text class="pa-7">
<v-text-field
v-model="tempTelepon"
:disabled="qrCodeGenerated"
label="Nomor Telepon Pasien"
placeholder="08xxxxxxxxxx"
variant="outlined"
density="comfortable"
:color="qrCodeGenerated ? 'grey' : '#D17A4A'"
:rules="[v => v.length >= 8 || 'Min. 8 digit']"
prepend-inner-icon="mdi-phone"
class="mb-3 text-body-1"
base-color="grey-darken-1"
></v-text-field>
<v-btn
v-if="!qrCodeGenerated"
:disabled="!isTeleponValid"
color="#5FB7FF"
block
size="x-large"
class="mt-3 font-weight-black elevation-2 text-h5 text-white"
@click="generateQrCode"
rounded="xl"
>
<v-icon left size="32">mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
<div v-else class="text-center mt-6 pa-4 pa-sm-7 qr-container rounded-xl">
<div class="pulse-icon mb-3 mb-sm-4">
<v-icon color="#4A5F7A" :size="display.xs.value ? 48 : 64">mdi-cellphone-check</v-icon>
</div>
<h3 class="text-h5 text-sm-h4 font-weight-black mb-2 mb-sm-3" style="color: #4A5F7A;">Pindai QR Code</h3>
<p class="text-body-2 text-sm-body-1 secondary-text mb-4 mb-sm-6">Arahkan kamera smartphone ke QR code</p>
<div class="d-flex justify-center mb-3 mb-sm-4">
<div class="qr-frame">
<qrcode-vue
:value="qrCodeData"
:size="qrSize"
level="H"
:foreground="'#4A5F7A'"
:background="'#FFFFFF'"
class="qr-code"
/>
</div>
</div>
</div>
</v-card-text>
<v-card-actions v-if="qrCodeGenerated" class="pa-7 pt-0">
<v-btn
color="#4A5F7A"
variant="outlined"
@click="reloadQr"
prepend-icon="mdi-reload"
size="x-large"
class="font-weight-bold flex-grow-1 text-body-1"
rounded="xl"
>
Reload QR
</v-btn>
<v-btn
color="#D17A4A"
variant="flat"
@click="completeVerification"
prepend-icon="mdi-check-circle"
size="x-large"
class="font-weight-black flex-grow-1 elevation-2 text-body-1"
rounded="xl"
>
Selesai
</v-btn>
</v-card-actions>
<v-card-actions v-else class="pa-7 pt-0">
<v-btn color="#7B8794" variant="text" @click="closeModal" size="x-large" block rounded="xl" class="font-weight-bold text-body-1">
Batal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 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>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useDisplay } from 'vuetify';
import QrcodeVue from 'qrcode.vue';
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';
definePageMeta({
middleware:['auth']
})
middleware: ['auth']
});
const display = useDisplay();
// --- MOCK DATA SIMRS ---
// Data
const patients = ref([
{ nama: 'Budi Santoso', rm: '00123456', alamat: 'Jl. Melati No. 10', telepon: '', status: 'Belum Terverifikasi' },
{ nama: 'Siti Aminah', rm: '00123457', alamat: 'Perum. Indah Blok A', telepon: '081234567890', status: 'Terverifikasi' },
@@ -259,14 +90,24 @@ const patients = ref([
{ nama: 'Rina Wijaya', rm: '00123460', alamat: 'Jl. Sudirman 21', telepon: '', status: 'Belum Terverifikasi' },
]);
// --- STATE DAN FILTERING ---
const filterStatus = ref(0);
const filterStatus = ref(0);
const search = ref('');
const noDataText = computed(() => search.value ? 'Tidak ada data pasien yang cocok dengan pencarian.' : 'Tidak ada pasien dengan status ini.');
const isModalOpen = ref(false);
const selectedPatient = ref({});
const tempTelepon = ref('');
const qrCodeGenerated = ref(false);
// Computed
const noDataText = computed(() =>
search.value
? 'Tidak ada data pasien yang cocok dengan pencarian.'
: 'Tidak ada pasien dengan status ini.'
);
const unverifiedPatients = computed(() =>
patients.value.filter((p) => p.status === 'Belum Terverifikasi')
);
const verifiedPatients = computed(() =>
patients.value.filter((p) => p.status === 'Terverifikasi')
);
@@ -289,33 +130,24 @@ const filteredPatients = computed(() => {
);
});
// --- STATE MODAL QR CODE ---
const isModalOpen = ref(false);
const selectedPatient = ref({});
const tempTelepon = ref('');
const qrCodeGenerated = ref(false);
const isTeleponValid = computed(() => tempTelepon.value.length >= 8);
const qrCodeData = computed(() => {
return qrCodeGenerated.value
? JSON.stringify({
rm: selectedPatient.value.rm,
phone: tempTelepon.value,
action: 'verify-account',
timestamp: Date.now()
})
: '';
return qrCodeGenerated.value
? JSON.stringify({
rm: selectedPatient.value.rm,
phone: tempTelepon.value,
action: 'verify-account',
timestamp: Date.now()
})
: '';
});
// Adaptive QR code size based on screen size
const qrSize = computed(() => {
if (display.xs.value) return 200; // Extra small screens (mobile)
if (display.sm.value) return 240; // Small screens (large phones)
return 280; // Medium and larger screens
if (display.xs.value) return 200;
if (display.sm.value) return 240;
return 280;
});
// --- LOGIC MODAL ---
// Methods
const openDaftarModal = (patient) => {
selectedPatient.value = patient;
tempTelepon.value = patient.telepon || '';
@@ -331,7 +163,7 @@ const closeModal = () => {
};
const generateQrCode = () => {
if (isTeleponValid.value) {
if (tempTelepon.value.length >= 8) {
console.log(`[API CALL] Generating QR for ${selectedPatient.value.nama}. QR Data: ${qrCodeData.value}`);
setTimeout(() => {
qrCodeGenerated.value = true;
@@ -341,7 +173,7 @@ const generateQrCode = () => {
const reloadQr = () => {
console.log('[API CALL] Reloading QR Code...');
qrCodeGenerated.value = false;
qrCodeGenerated.value = false;
setTimeout(() => {
qrCodeGenerated.value = true;
console.log('QR Code reloaded.');
@@ -358,215 +190,48 @@ const completeVerification = () => {
patients.value[patientIndex].telepon = tempTelepon.value;
}
filterStatus.value = 2;
filterStatus.value = 2;
closeModal();
};
</script>
<style scoped>
/* Background */
.bg-main {
background-color: #FAFAFA;
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$secondary-600: #0671E0;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-bold: 700;
.main-bg {
background: $neutral-300;
min-height: 100vh;
font-family: $font-family-base;
}
.card-style {
background-color: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.04);
.admin-chip {
font-weight: $font-weight-bold;
padding: 12px 20px;
}
/* Text Colors */
.primary-text {
color: #2C3E50 !important;
.chip-text {
color: $neutral-100;
font-weight: $font-weight-bold;
font-family: $font-family-base;
}
.secondary-text {
color: #7B8794 !important;
}
.primary-icon {
color: #FF9B1B !important;
}
/* Header Banner */
.header-banner {
background-color: #E8F5FF;
.content-card {
background: $neutral-100;
border-radius: 12px;
padding: 0;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.header-banner:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.icon-container {
background-color: rgba(209, 122, 74, 0.1);
border-radius: 12px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Search Section */
.search-section {
background-color: #FAFAFA;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.search-field {
border-radius: 12px !important;
}
.filter-tabs {
background: transparent;
}
/* Patient List Items */
.patient-item {
border-bottom: 1px solid #F0F0F0;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.patient-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background: #D17A4A;
transform: scaleY(0);
transition: transform 0.3s ease;
}
.patient-item:hover::before {
transform: scaleY(1);
}
.patient-item:hover {
background: linear-gradient(90deg, #FFF5EE 0%, #ffffff 100%) !important;
transform: translateX(4px);
}
.verified-item {
background: linear-gradient(90deg, #F0F4F8 0%, #ffffff 100%) !important;
}
.verified-item::before {
background: #4A5F7A !important;
}
.verified-item:hover {
background: linear-gradient(90deg, #E8EFF5 0%, #ffffff 100%) !important;
}
.patient-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 3px solid white;
}
.chip-rm {
border: 1px solid rgba(74, 95, 122, 0.2);
}
.action-btn {
text-transform: none !important;
letter-spacing: 0.5px;
transition: all 0.3s ease;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(209, 122, 74, 0.3) !important;
background-color: #E0916E !important;
}
/* Modal Styling */
.modal-header {
background: linear-gradient(135deg, #4A5F7A 0%, #5D7290 100%);
text-align: center;
box-shadow: 0 4px 12px rgba(74, 95, 122, 0.2);
}
.qr-container {
background: linear-gradient(135deg, #F0F4F8 0%, #E8EFF5 100%);
border: 3px solid #4A5F7A;
box-shadow: 0 6px 20px rgba(74, 95, 122, 0.15);
}
.qr-frame {
padding: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: inline-block;
border: 3px solid #4A5F7A;
}
@media (min-width: 600px) {
.qr-frame {
padding: 24px;
border-radius: 16px;
border: 4px solid #4A5F7A;
}
border: 1px solid $neutral-500;
}
.qr-code {
border-radius: 8px;
display: block;
}
.pulse-icon {
animation: pulse-scale 2s ease-in-out infinite;
}
@keyframes pulse-scale {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.08);
opacity: 0.85;
}
}
/* Responsive */
@media (max-width: 960px) {
.patient-item {
padding: 20px !important;
}
.header-banner h1 {
font-size: 1.75rem;
}
.header-banner p {
font-size: 0.9rem;
}
.action-btn {
font-size: 1rem;
padding: 10px 20px;
}
.patient-avatar {
width: 56px !important;
height: 56px !important;
}
.text-h5 {
font-size: 1.25rem !important;
}
}
.header-banner .v-container {
padding-left: 1.5rem !important;
padding-right: 1.5rem !important;
}
</style>
+296 -8
View File
@@ -1,4 +1,4 @@
// stores/masterStore.js
// stores/masterStore.js - Integrated dengan Penunjang
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
@@ -355,6 +355,258 @@ export const useMasterStore = defineStore('master', () => {
return allRuang;
});
// ============================================
// MASTER PENUNJANG
// ============================================
const penunjangData = ref([
{
id: 1,
no: 1,
kode: 'LAB',
nama: 'LABORATORIUM',
jenis: 'Medis',
shift: 3,
totalQuota: 150,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 60 },
{ dari: '13:00', sampai: '21:00', kuota: 50 },
{ dari: '21:00', sampai: '07:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 30,
keterangan: 'Pemeriksaan darah lengkap, urine, dan pemeriksaan laboratorium lainnya'
},
{
id: 2,
no: 2,
kode: 'RAD',
nama: 'RADIOLOGI',
jenis: 'Medis',
shift: 2,
totalQuota: 100,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 60 },
{ dari: '13:00', sampai: '21:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu'],
status: 'Aktif',
estimasiLayanan: 45,
keterangan: 'Rontgen, CT Scan, MRI, USG, dan pemeriksaan radiologi lainnya'
},
{
id: 3,
no: 3,
kode: 'PAT',
nama: 'PATOLOGI ANATOMI',
jenis: 'Medis',
shift: 1,
totalQuota: 50,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 50 }
],
autoShift: false,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 60,
keterangan: 'Pemeriksaan jaringan dan sitologi'
},
{
id: 4,
no: 4,
kode: 'RHB',
nama: 'REHABILITASI MEDIK',
jenis: 'Medis',
shift: 2,
totalQuota: 80,
jamShiftList: [
{ dari: '07:00', sampai: '13:00', kuota: 40 },
{ dari: '13:00', sampai: '19:00', kuota: 40 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 30,
keterangan: 'Fisioterapi, terapi okupasi, dan terapi wicara'
},
{
id: 5,
no: 5,
kode: 'RME',
nama: 'REKAM MEDIS',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 120,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 70 },
{ dari: '13:00', sampai: '21:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu'],
status: 'Aktif',
estimasiLayanan: 15,
keterangan: 'Pengurusan dokumen rekam medis dan surat keterangan'
},
{
id: 6,
no: 6,
kode: 'GIZ',
nama: 'GIZI',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 60,
jamShiftList: [
{ dari: '07:00', sampai: '13:00', kuota: 30 },
{ dari: '13:00', sampai: '19:00', kuota: 30 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
status: 'Aktif',
estimasiLayanan: 20,
keterangan: 'Konsultasi gizi dan diet'
},
{
id: 7,
no: 7,
kode: 'LND',
nama: 'LAUNDRY',
jenis: 'Non-Medis',
shift: 3,
totalQuota: 200,
jamShiftList: [
{ dari: '06:00', sampai: '14:00', kuota: 80 },
{ dari: '14:00', sampai: '22:00', kuota: 70 },
{ dari: '22:00', sampai: '06:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 10,
keterangan: 'Layanan laundry linen rumah sakit'
},
{
id: 8,
no: 8,
kode: 'INF',
nama: 'PELAYANAN INFORMASI',
jenis: 'Non-Medis',
shift: 2,
totalQuota: 150,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 80 },
{ dari: '13:00', sampai: '21:00', kuota: 70 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 10,
keterangan: 'Informasi umum rumah sakit dan bantuan navigasi'
},
{
id: 9,
no: 9,
kode: 'FRM',
nama: 'FARMASI',
jenis: 'Medis',
shift: 3,
totalQuota: 200,
jamShiftList: [
{ dari: '07:00', sampai: '15:00', kuota: 80 },
{ dari: '13:00', sampai: '21:00', kuota: 70 },
{ dari: '21:00', sampai: '07:00', kuota: 50 }
],
autoShift: true,
jadwalOperasional: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at', 'Sabtu', 'Minggu'],
status: 'Aktif',
estimasiLayanan: 20,
keterangan: 'Pelayanan obat dan konsultasi farmasi'
}
]);
// Computed - Penunjang
const totalPenunjang = computed(() => penunjangData.value.length);
const activePenunjang = computed(() => penunjangData.value.filter(p => p.status === 'Aktif'));
const penunjangMedis = computed(() => penunjangData.value.filter(p => p.jenis === 'Medis'));
const penunjangNonMedis = computed(() => penunjangData.value.filter(p => p.jenis === 'Non-Medis'));
// Get penunjang list for dropdowns
const penunjangList = computed(() =>
penunjangData.value
.filter(p => p.status === 'Aktif')
.map(p => ({
id: p.id,
kode: p.kode,
nama: p.nama
}))
);
// Actions - Penunjang
const addPenunjang = (penunjangPayload) => {
const newId = Math.max(...penunjangData.value.map(p => p.id), 0) + 1;
const newNo = penunjangData.value.length + 1;
const totalQuota = penunjangPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
const newPenunjang = {
id: newId,
no: newNo,
...penunjangPayload,
totalQuota: totalQuota,
};
penunjangData.value.push(newPenunjang);
return { success: true, message: `Penunjang ${newPenunjang.nama} berhasil ditambahkan`, data: newPenunjang };
};
const updatePenunjang = (penunjangPayload) => {
const index = penunjangData.value.findIndex(p => p.id === penunjangPayload.id);
if (index !== -1) {
const totalQuota = penunjangPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
penunjangData.value[index] = {
...penunjangData.value[index],
...penunjangPayload,
totalQuota: totalQuota,
};
return { success: true, message: `Penunjang ${penunjangPayload.nama} berhasil diupdate` };
}
return { success: false, message: 'Penunjang tidak ditemukan' };
};
const deletePenunjang = (penunjangId) => {
const index = penunjangData.value.findIndex(p => p.id === penunjangId);
if (index !== -1) {
const penunjangName = penunjangData.value[index].nama;
penunjangData.value.splice(index, 1);
penunjangData.value.forEach((p, idx) => {
p.no = idx + 1;
});
return { success: true, message: `Penunjang ${penunjangName} berhasil dihapus` };
}
return { success: false, message: 'Penunjang tidak ditemukan' };
};
const getPenunjangById = (id) => {
return penunjangData.value.find(p => p.id === id);
};
const getPenunjangByKode = (kode) => {
return penunjangData.value.find(p => p.kode === kode);
};
// Get active penunjang list (untuk Admin Penunjang)
const getActivePenunjangList = () => {
return penunjangData.value
.filter(p => p.status === 'Aktif')
.map(p => ({ id: p.id, name: p.nama, kode: p.kode }));
};
// ============================================
// UTILITY FUNCTIONS
// ============================================
@@ -363,41 +615,77 @@ export const useMasterStore = defineStore('master', () => {
return klinik ? klinik.nama : kode;
};
const getPenunjangNameByKode = (kode) => {
const penunjang = penunjangData.value.find(p => p.kode === kode);
return penunjang ? penunjang.nama : kode;
};
return {
// State - Klinik
// ============================================
// KLINIK
// ============================================
// State
klinikData,
klinikList,
// Actions - Klinik
// Actions
addKlinik,
updateKlinik,
deleteKlinik,
getKlinikById,
getKlinikByKode,
// State - Loket
// ============================================
// LOKET
// ============================================
// State
loketData,
availableServices,
// Actions - Loket
// Actions
addLoket,
updateLoket,
deleteLoket,
getLoketById,
// State - Ruang
// ============================================
// RUANG
// ============================================
// State
ruangData,
totalKlinikRuang,
totalRuangan,
getAllRuangList,
// Actions - Ruang
// Actions
addRuang,
updateRuang,
deleteRuang,
getRuangByKlinik,
// Utilities
// ============================================
// PENUNJANG
// ============================================
// State
penunjangData,
totalPenunjang,
activePenunjang,
penunjangMedis,
penunjangNonMedis,
penunjangList,
// Actions
addPenunjang,
updatePenunjang,
deletePenunjang,
getPenunjangById,
getPenunjangByKode,
getActivePenunjangList,
// ============================================
// UTILITIES
// ============================================
getKlinikNameByKode,
getPenunjangNameByKode,
};
});
+2 -1
View File
@@ -52,7 +52,8 @@ const defaultNavItems: NavItem[] = [
{ id: 17, name: "Master Loket", path: "/setting/masterloket", icon: "mdi-circle-small" },
{ id: 18, name: "Master Klinik", path: "/setting/masterklinik", icon: "mdi-circle-small" },
{ id: 19, name: "Master Klinik Ruang", path: "/setting/masterklinikruang", icon: "mdi-circle-small" },
{ id: 20, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
{ id: 20, name: "Master Penunjang", path: "/setting/masterpenunjang", icon: "mdi-circle-small" },
{ id: 21, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
],
},
];
+108 -4
View File
@@ -12,7 +12,7 @@ export const useQueueStore = defineStore('queue', () => {
noAntrian: "UM1001 | Online - 250811100163",
shift: "Shift 1",
klinik: "KANDUNGAN",
fastTrack: "UMUM",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting", // waiting, di-loket, terlambat, pending, processed
processStage: "loket", // loket, klinik, penunjang
@@ -25,7 +25,7 @@ export const useQueueStore = defineStore('queue', () => {
noAntrian: "UM1002 | Online - 250811100155",
shift: "Shift 1",
klinik: "IPD",
fastTrack: "UMUM",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
@@ -38,7 +38,7 @@ export const useQueueStore = defineStore('queue', () => {
noAntrian: "UM1003 | Online - 250811100200",
shift: "Shift 1",
klinik: "SARAF",
fastTrack: "UMUM",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
@@ -51,7 +51,111 @@ export const useQueueStore = defineStore('queue', () => {
noAntrian: "UM1004 | Online - 250811100210",
shift: "Shift 1",
klinik: "THT",
fastTrack: "UMUM",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
createdAt: new Date().toISOString(),
},
{
no: 5,
jamPanggil: "12:49",
barcode: "250811100163",
noAntrian: "UM1001 | Online - 250811100163",
shift: "Shift 1",
klinik: "KANDUNGAN",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting", // waiting, di-loket, terlambat, pending, processed
processStage: "loket", // loket, klinik, penunjang
createdAt: new Date().toISOString(),
},
{
no: 6,
jamPanggil: "10:52",
barcode: "250811100155",
noAntrian: "UM1002 | Online - 250811100155",
shift: "Shift 1",
klinik: "IPD",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
createdAt: new Date().toISOString(),
},
{
no: 7,
jamPanggil: "09:30",
barcode: "250811100200",
noAntrian: "UM1003 | Online - 250811100200",
shift: "Shift 1",
klinik: "SARAF",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
createdAt: new Date().toISOString(),
},
{
no: 8,
jamPanggil: "14:15",
barcode: "250811100210",
noAntrian: "UM1004 | Online - 250811100210",
shift: "Shift 1",
klinik: "THT",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
createdAt: new Date().toISOString(),
},
{
no: 9,
jamPanggil: "12:49",
barcode: "250811100163",
noAntrian: "UM1001 | Online - 250811100163",
shift: "Shift 1",
klinik: "KANDUNGAN",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting", // waiting, di-loket, terlambat, pending, processed
processStage: "loket", // loket, klinik, penunjang
createdAt: new Date().toISOString(),
},
{
no: 10,
jamPanggil: "10:52",
barcode: "250811100155",
noAntrian: "UM1002 | Online - 250811100155",
shift: "Shift 1",
klinik: "IPD",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
createdAt: new Date().toISOString(),
},
{
no: 11,
jamPanggil: "09:30",
barcode: "250811100200",
noAntrian: "UM1003 | Online - 250811100200",
shift: "Shift 1",
klinik: "SARAF",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",
createdAt: new Date().toISOString(),
},
{
no: 12,
jamPanggil: "14:15",
barcode: "250811100210",
noAntrian: "UM1004 | Online - 250811100210",
shift: "Shift 1",
klinik: "THT",
fastTrack: "TIDAK",
pembayaran: "UMUM",
status: "waiting",
processStage: "loket",