33 Commits
Author SHA1 Message Date
Fanrouver 4c2c68d993 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-12-05 10:35:30 +07:00
Fanrouver 10ef054de6 add new data 2025 05 12 2025-12-05 10:33:40 +07:00
bagus-arie05 9b3fd7b314 update card, update design system, update layout anjungan 2025-12-05 10:21:16 +07:00
bagus-arie05 817bf8f548 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-11-24 13:43:46 +07:00
bagus-arie05 82b98fdfe6 update structure component, layout page admin, konfigurasi dan layout master 2025-11-24 13:42:11 +07:00
Fanrouver 642be0f9df pull new folder 2025-11-10 09:16:04 +07:00
Fanrouver e2cf3d309a Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-11-05 13:01:37 +07:00
Fanrouver 89688e02f3 change on login sidebar and dashboard 2025-11-05 13:00:06 +07:00
bagus-arie05 56fb6b319b update dev server 2025-11-05 11:10:36 +07:00
bagus-arie05 39d064c30c update font dan color 2025-11-05 10:51:35 +07:00
Fanrouver dbb8050dc5 Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-30 09:19:02 +07:00
Fanrouver 4960d4659f push update login, dashboard, check in, monitoring, profilpop, verifikasi, dll 2025-10-30 09:08:40 +07:00
Fanrouver 11d2a4671e Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-28 10:22:29 +07:00
Fanrouver 35024b4797 update akbar tampilan login dashboard dan verifikasi 2025-10-28 10:14:00 +07:00
bagus-arie05 d7f3240a82 Update flow anjungan 2025-10-21 08:31:00 +07:00
bagus-arie05 71890ffb4c Merge branch 'Antrean-Code' of https://git.rssa.top/arie.bagus.2905/Web-Antrean into Antrean-Code 2025-10-14 09:13:20 +07:00
bagus-arie05 31e18c5965 update sidebar dan menu layanan penunjang 2025-10-14 08:49:54 +07:00
Fanrouver 0351f9116d change branch to Akbar-antren 2025-10-13 09:25:44 +07:00
arie.bagus.2905 51809468a9 Merge pull request 'Pull request to merge from Akbar's Antrean' from Akbar-antren into Antrean-Code
Reviewed-on: https://git.rssa.top/arie.bagus.2905/Web-Antrean/pulls/2
2025-10-02 01:51:47 +00:00
bagus-arie05 a6b16e1cd1 version 0 2025-10-01 15:17:37 +07:00
bagus-arie05 3ad64b2223 update sitemap 2025-10-01 11:08:51 +07:00
bagus-arie05 1b1d7e331a Update Theme 2025-09-24 11:18:02 +07:00
bagus-arie05 a66f883372 update komponen tabel 2025-09-19 14:55:32 +07:00
bagus-arie05 28822718de update list pasien dan antrian klinik 2025-09-15 15:32:34 +07:00
bagus-arie05 bc5419ca4d update theme blue 2025-09-12 15:34:02 +07:00
bagus-arie05 d6ffc5ee9e update anjungan dan styling color 2025-09-02 14:44:56 +07:00
bagus-arie05 3abb8de83a new page anjungan 2025-09-01 14:39:30 +07:00
bagus-arie05 fc16b2bded implementasi komponen ke data pasien 2025-08-28 15:08:52 +07:00
bagus-arie05 89b36b7d1f update komponen, data pasien, list pasien, screen 2025-08-27 15:52:38 +07:00
bagus-arie05 15c49262f1 component, master loket, edit loket 2025-08-26 14:57:13 +07:00
bagus-arie05 1f5b25a873 new components 2025-08-25 14:40:28 +07:00
bagus-arie05 b1c1ff86f0 update padding dan paginasi 2025-08-20 15:04:15 +07:00
bagus-arie05 6c27987f53 update loket admin & layout 2025-08-13 14:14:48 +07:00
98 changed files with 29145 additions and 8051 deletions

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
:root {
/* Neutral Colors */
--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 Colors (Orange) */
--primary-700: #0053AD;
--primary-600: #0663C7;
--primary-500: #0671E0;
--primary-400: #4196F0;
--primary-300: #DBEDFF;
--primary-200: #EEF5FC;
--primary-100: #EEF5FC;
/* Secondary Colors (Blue) */
--secondary-700: #0053AD;
--secondary-600: #0671E0;
--secondary-500: #0663C7;
--secondary-400: #4196F0;
--secondary-300: #DBEDFF;
--secondary-200: #EEF5FC;
--secondary-100: #0053AD;
/* Success Colors (Green) */
--success-700: #1B6E53;
--success-600: #009262;
--success-500: #115B43;
--success-400: #32C997;
--success-300: #84DFC1;
--success-200: #F1FBF8;
/* Danger Colors (Red) */
--danger-700: #E01507;
--danger-600: #E02B1D;
--danger-500: #C33025;
--danger-400: #FF5A4F;
--danger-300: #F0857D;
--danger-200: #FFF1F0;
}
/* Utility Classes untuk Background */
.bg-neutral-900 { background-color: var(--neutral-900); }
.bg-neutral-800 { background-color: var(--neutral-800); }
.bg-neutral-700 { background-color: var(--neutral-700); }
.bg-neutral-600 { background-color: var(--neutral-600); }
.bg-neutral-500 { background-color: var(--neutral-500); }
.bg-neutral-400 { background-color: var(--neutral-400); }
.bg-neutral-300 { background-color: var(--neutral-300); }
.bg-neutral-100 { background-color: var(--neutral-100); }
.bg-primary-700 { background-color: var(--primary-700); }
.bg-primary-600 { background-color: var(--primary-600); }
.bg-primary-500 { background-color: var(--primary-500); }
.bg-primary-400 { background-color: var(--primary-400); }
.bg-primary-300 { background-color: var(--primary-300); }
.bg-primary-200 { background-color: var(--primary-200); }
.bg-primary-100 { background-color: var(--primary-100); }
.bg-secondary-700 { background-color: var(--secondary-700); }
.bg-secondary-600 { background-color: var(--secondary-600); }
.bg-secondary-500 { background-color: var(--secondary-500); }
.bg-secondary-400 { background-color: var(--secondary-400); }
.bg-secondary-300 { background-color: var(--secondary-300); }
.bg-secondary-200 { background-color: var(--secondary-200); }
.bg-success-700 { background-color: var(--success-700); }
.bg-success-600 { background-color: var(--success-600); }
.bg-success-500 { background-color: var(--success-500); }
.bg-success-400 { background-color: var(--success-400); }
.bg-success-300 { background-color: var(--success-300); }
.bg-success-200 { background-color: var(--success-200); }
.bg-danger-700 { background-color: var(--danger-700); }
.bg-danger-600 { background-color: var(--danger-600); }
.bg-danger-500 { background-color: var(--danger-500); }
.bg-danger-400 { background-color: var(--danger-400); }
.bg-danger-300 { background-color: var(--danger-300); }
.bg-danger-200 { background-color: var(--danger-200); }
/* Utility Classes untuk Text Color */
.text-neutral-900 { color: var(--neutral-900); }
.text-neutral-800 { color: var(--neutral-800); }
.text-neutral-700 { color: var(--neutral-700); }
.text-neutral-600 { color: var(--neutral-600); }
.text-neutral-500 { color: var(--neutral-500); }
.text-neutral-100 { color: var(--neutral-100); }
.text-primary-700 { color: var(--primary-700); }
.text-primary-600 { color: var(--primary-600); }
.text-primary-500 { color: var(--primary-500); }
.text-primary-400 { color: var(--primary-400); }
.text-secondary-700 { color: var(--secondary-700); }
.text-secondary-600 { color: var(--secondary-600); }
.text-secondary-500 { color: var(--secondary-500); }
.text-success-700 { color: var(--success-700); }
.text-success-600 { color: var(--success-600); }
.text-success-500 { color: var(--success-500); }
.text-danger-700 { color: var(--danger-700); }
.text-danger-600 { color: var(--danger-600); }
.text-danger-500 { color: var(--danger-500); }
/* Utility Classes untuk Border */
.border-primary-500 { border-color: var(--primary-500); }
.border-secondary-500 { border-color: var(--secondary-500); }
.border-success-500 { border-color: var(--success-500); }
.border-danger-500 { border-color: var(--danger-500); }
.border-neutral-500 { border-color: var(--neutral-500); }
+109
View File
@@ -0,0 +1,109 @@
/* Base Font */
* {
font-family: 'Inter', sans-serif;
}
/* Headlines - Desktop */
.headline-1 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 64px;
line-height: 76px;
}
.headline-2 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 36px;
line-height: 44px;
}
.headline-3 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 28px;
line-height: 36px;
}
.headline-4 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 20px;
line-height: 28px;
}
/* Body - Desktop */
.body-1 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 18px;
line-height: 28px;
}
.body-2 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 16px;
line-height: 24px;
}
.body-3 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 14px;
line-height: 20px;
}
/* Caption - Desktop */
.text-1 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 16px;
line-height: 24px;
}
.text-2 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 12px;
line-height: 16px;
}
/* Mobile Typography */
@media (max-width: 768px) {
.headline-1 {
font-weight: 700; /* Bold */
font-size: 28px;
line-height: 36px;
}
.headline-2 {
font-weight: 600; /* Semi Bold */
font-size: 20px;
line-height: 28px;
}
.headline-3 {
font-weight: 600; /* Semi Bold */
font-size: 18px;
line-height: 24px;
}
.headline-4 {
font-weight: 600; /* Semi Bold */
font-size: 18px;
line-height: 24px;
}
.body {
font-weight: 500; /* Medium */
font-size: 16px;
line-height: 24px;
}
.caption {
font-weight: 400; /* Regular */
font-size: 14px;
line-height: 20px;
}
}
+165
View File
@@ -0,0 +1,165 @@
@use 'sass:map';
$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: #FF9B1B;
$primary-600: #FFA532;
$primary-500: #FFB95F;
$primary-400: #FFCD8D;
$primary-300: #FFDCAF;
$primary-200: #FFE6C6;
$primary-100: #EEF5FC;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$secondary-500: #0663C7;
$secondary-400: #4196F0;
$secondary-300: #DBEDFF;
$secondary-200: #EEF5FC;
$success-700: #1B6E53;
$success-600: #009262;
$success-500: #115B43;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-700: #E01507;
$danger-600: #E02B1D;
$danger-500: #C33025;
$danger-400: #FF5A4F;
$danger-300: #F0857D;
$danger-200: #FFF1F0;
// CSS Variables untuk dynamic theming
:root {
// Neutral
--color-neutral-900: #{$neutral-900};
--color-neutral-800: #{$neutral-800};
--color-neutral-700: #{$neutral-700};
--color-neutral-600: #{$neutral-600};
--color-neutral-500: #{$neutral-500};
--color-neutral-400: #{$neutral-400};
--color-neutral-300: #{$neutral-300};
--color-neutral-100: #{$neutral-100};
// Primary
--color-primary-700: #{$primary-700};
--color-primary-600: #{$primary-600};
--color-primary-500: #{$primary-500};
--color-primary-400: #{$primary-400};
--color-primary-300: #{$primary-300};
--color-primary-200: #{$primary-200};
--color-primary-100: #{$primary-100};
// Secondary
--color-secondary-700: #{$secondary-700};
--color-secondary-600: #{$secondary-600};
--color-secondary-500: #{$secondary-500};
--color-secondary-400: #{$secondary-400};
--color-secondary-300: #{$secondary-300};
--color-secondary-200: #{$secondary-200};
// Success
--color-success-700: #{$success-700};
--color-success-600: #{$success-600};
--color-success-500: #{$success-500};
--color-success-400: #{$success-400};
--color-success-300: #{$success-300};
--color-success-200: #{$success-200};
// Danger
--color-danger-700: #{$danger-700};
--color-danger-600: #{$danger-600};
--color-danger-500: #{$danger-500};
--color-danger-400: #{$danger-400};
--color-danger-300: #{$danger-300};
--color-danger-200: #{$danger-200};
// Theme colors (akan di-override oleh theme system)
--color-primary: #{$primary-500};
--color-secondary: #{$secondary-500};
--color-success: #{$success-600};
--color-danger: #{$danger-600};
--color-background: #{$neutral-300};
--color-surface: #{$neutral-100};
--color-textPrimary: #{$neutral-900};
--color-textSecondary: #{$neutral-700};
--color-borderColor: #{$neutral-500};
}
// Dark mode base
.dark {
--color-background: #1a1a1a;
--color-surface: #{$neutral-900};
--color-textPrimary: #{$neutral-100};
--color-textSecondary: #{$neutral-500};
--color-borderColor: #{$neutral-800};
}
// Mixin untuk generate color utilities
@mixin color-utilities($prefix, $color) {
.text-#{$prefix} {
color: $color;
}
.bg-#{$prefix} {
background-color: $color;
}
.border-#{$prefix} {
border-color: $color;
}
}
// Generate color utilities
$colors: (
'neutral-900': $neutral-900,
'neutral-800': $neutral-800,
'neutral-700': $neutral-700,
'neutral-600': $neutral-600,
'neutral-500': $neutral-500,
'neutral-400': $neutral-400,
'neutral-300': $neutral-300,
'neutral-100': $neutral-100,
'primary-700': $primary-700,
'primary-600': $primary-600,
'primary-500': $primary-500,
'primary-400': $primary-400,
'primary-300': $primary-300,
'primary-200': $primary-200,
'secondary-700': $secondary-700,
'secondary-600': $secondary-600,
'secondary-500': $secondary-500,
'success-700': $success-700,
'success-600': $success-600,
'success-500': $success-500,
'danger-700': $danger-700,
'danger-600': $danger-600,
'danger-500': $danger-500
);
@each $name, $color in $colors {
@include color-utilities($name, $color);
}
// Shorthand semantic colors
.text-primary { color: $primary-500; }
.text-secondary { color: $secondary-500; }
.text-success { color: $success-600; }
.text-danger { color: $danger-600; }
.text-muted { color: $neutral-600; }
.bg-primary { background-color: $primary-500; }
.bg-secondary { background-color: $secondary-500; }
.bg-success { background-color: $success-600; }
.bg-danger { background-color: $danger-600; }
.bg-light { background-color: $neutral-300; }
.bg-white { background-color: $neutral-100; }
+121
View File
@@ -0,0 +1,121 @@
@use 'sass:map';
@use './variables' as *;
// Mixin untuk generate typography classes
@mixin typography($name, $size, $line-height, $weight) {
.#{$name} {
font-family: $font-family-base;
font-size: $size;
line-height: $line-height;
font-weight: $weight;
margin: 0;
}
}
// Base typography
* {
font-family: $font-family-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: $font-family-base;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
margin: 0;
padding: 0;
}
// Generate desktop typography classes - Gunakan map.get
@each $name, $props in $font-sizes {
@include typography(
$name,
map.get($props, 'size'),
map.get($props, 'line-height'),
map.get($props, 'weight')
);
}
// Generate mobile typography classes - Gunakan map.get
@media (max-width: $breakpoint-mobile) {
@each $name, $props in $font-sizes-mobile {
@include typography(
$name,
map.get($props, 'size'),
map.get($props, 'line-height'),
map.get($props, 'weight')
);
}
}
// HTML semantic tags
h1 {
@extend .headline-1;
}
h2 {
@extend .headline-2;
}
h3 {
@extend .headline-3;
}
h4 {
@extend .headline-4;
}
p {
@extend .body-1;
}
small {
@extend .caption-2;
}
// Utility classes
.text-regular {
font-weight: $font-weight-regular !important;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
.text-bold {
font-weight: $font-weight-bold !important;
}
.text-uppercase {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
// Line clamp utilities
@for $i from 1 through 5 {
.line-clamp-#{$i} {
display: -webkit-box;
-webkit-line-clamp: $i;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
+117
View File
@@ -0,0 +1,117 @@
@use 'sass:map';
@use 'sass:math';
// Font Family
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
// Font Weights
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// Font Sizes & Line Heights - Desktop
$font-sizes: (
'headline-1': (
'size': 64px,
'line-height': 76px,
'weight': $font-weight-semibold
),
'headline-2': (
'size': 36px,
'line-height': 44px,
'weight': $font-weight-semibold
),
'headline-3': (
'size': 28px,
'line-height': 36px,
'weight': $font-weight-semibold
),
'headline-4': (
'size': 20px,
'line-height': 28px,
'weight': $font-weight-semibold
),
'body-1': (
'size': 18px,
'line-height': 28px,
'weight': $font-weight-regular
),
'body-2': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-regular
),
'body-3': (
'size': 14px,
'line-height': 20px,
'weight': $font-weight-regular
),
'caption-1': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-regular
),
'caption-2': (
'size': 12px,
'line-height': 16px,
'weight': $font-weight-regular
)
);
// Font Sizes - Mobile
$font-sizes-mobile: (
'headline-1': (
'size': 28px,
'line-height': 36px,
'weight': $font-weight-bold
),
'headline-2': (
'size': 20px,
'line-height': 28px,
'weight': $font-weight-semibold
),
'headline-3': (
'size': 18px,
'line-height': 24px,
'weight': $font-weight-semibold
),
'headline-4': (
'size': 18px,
'line-height': 24px,
'weight': $font-weight-semibold
),
'body': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-medium
),
'caption': (
'size': 14px,
'line-height': 20px,
'weight': $font-weight-regular
)
);
// Breakpoints
$breakpoint-mobile: 768px;
$breakpoint-tablet: 1024px;
$breakpoint-desktop: 1280px;
// Mixins
@mixin heading($level) {
@if map.has-key($font-sizes, $level) {
$props: map.get($font-sizes, $level);
font-size: map.get($props, 'size');
line-height: map.get($props, 'line-height');
font-weight: map.get($props, 'weight');
font-family: $font-family-base;
}
}
@mixin responsive-text {
@media (max-width: $breakpoint-mobile) {
font-size: 14px;
line-height: 20px;
}
}
+71
View File
@@ -0,0 +1,71 @@
// Gunakan @use daripada @import
@use './variables' as *;
@use './colors' as *;
@use './typography' as *;
// Global styles
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
background-color: var(--color-background);
color: var(--color-textPrimary);
transition: background-color 0.3s ease, color 0.3s ease;
}
// Selection color
::selection {
background-color: var(--color-primary-300);
color: var(--color-primary-700);
}
// Focus styles
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
// Responsive images
img {
max-width: 100%;
height: auto;
display: block;
}
// Links
a {
color: var(--color-primary-500);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--color-primary-600);
}
&:active {
color: var(--color-primary-700);
}
}
// Buttons base
button {
font-family: $font-family-base;
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
+235
View File
@@ -0,0 +1,235 @@
/* assets/styles/loginpage/login.css */
/* --- COLOR DEFINITIONS --- */
/* Base Orange: #FF9B1B (New) */
/* Hover/Darker Orange: #E68A00 (Calculated darker shade for hover effect) */
/* --- END COLOR DEFINITIONS --- */
/* Main Background - Now Orange */
.login-background {
background: #FF9B1B; /* Solid Orange Background: #FF9B1B */
min-height: 100vh;
position: relative;
overflow: hidden;
}
.login-background::before {
/* No radial gradients */
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: none;
}
/* Floating Medical Icons - Subtle on Orange Background */
.floating-medical-icon {
position: absolute;
opacity: 0.8;
transition: opacity 0.3s ease;
z-index: 1;
width: fit-content;
height: fit-content;
top: auto;
left: auto;
bottom: auto;
right: auto;
}
.floating-medical-icon:hover {
opacity: 1;
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important; /* Light white for icons on orange background */
}
/* Specific icon positioning - now static */
.icon-1 { top: 15%; left: 5%; }
.icon-2 { top: 20%; right: 10%; }
.icon-3 { bottom: 10%; left: 15%; }
.icon-4 { top: 60%; left: 10%; }
.icon-5 { bottom: 20%; right: 5%; }
.icon-6 { top: 35%; left: 20%; }
/* Main Card - White */
.main-card {
z-index: 3;
/* max-width is now set in template to 450px */
margin: 2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); /* Adding a subtle shadow back */
}
.white-card {
background: white !important; /* Main card background is white */
border: 1px solid rgba(0, 0, 0, 0.1);
/* box-shadow is now managed by .main-card */
}
/* Right Login Section (White) */
.login-section-box {
background: white; /* Changed to white */
min-height: 500px; /* Reduced min-height since there is no side-by-side comparison */
}
.login-content {
max-width: 400px;
margin: 0 auto;
}
/* White Dialog Styles */
.white-dialog {
background: white !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Dark Text for white background */
.welcome-title-dark {
color: #333; /* Dark text on white background */
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: #777; /* Grey text for instruction on white background */
font-size: 0.8rem;
letter-spacing: 0.3px;
}
/* App Title - Dark for white background */
.app-title-dark {
color: #FF9B1B; /* Orange app title: #FF9B1B */
text-shadow: none;
}
.hospital-logo {
height: 332px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: #555; /* Dark grey for SSO text */
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons (Orange theme) */
.login-btn {
background: #FF9B1B !important; /* Solid Orange button: #FF9B1B */
color: white !important; /* White text on orange button */
border: none;
box-shadow:
0 8px 25px rgba(255, 155, 27, 0.3), /* Orange shadow: #FF9B1B */
0 4px 12px rgba(255, 155, 27, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
height: 56px;
}
.login-btn:hover {
transform: translateY(-2px);
background: #E68A00 !important; /* Slightly darker orange on hover */
box-shadow:
0 12px 30px rgba(255, 155, 27, 0.4),
0 6px 15px rgba(255, 155, 27, 0.3);
}
.register-btn-dark {
color: #FF9B1B !important; /* Orange text for register button: #FF9B1B */
border: 2px solid #FF9B1B !important; /* Orange border: #FF9B1B */
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
height: 48px;
}
.register-btn-dark:hover {
background: rgba(255, 155, 27, 0.05) !important; /* Light orange hover background */
border-color: #E68A00 !important; /* Darker orange border on hover */
transform: translateY(-1px);
}
/* Custom Divider - Dark for white background */
.custom-divider-dark {
border-color: rgba(0, 0, 0, 0.1) !important; /* Light grey divider */
opacity: 1 !important;
}
/* Help Text and Links - Dark for white background */
.help-text-dark {
color: #555; /* Dark grey help text */
font-size: 0.9rem;
}
.contact-link-dark {
color: #FF9B1B !important; /* Orange link: #FF9B1B */
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #777; /* Grey link */
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #FF9B1B; /* Orange on hover: #FF9B1B */
}
/* Transparent Background */
.transparent {
background: transparent !important;
}
/* Dialog Accents */
.v-card-title .v-icon,
.v-list-item .v-icon {
color: #FF9B1B !important; /* Orange accents: #FF9B1B */
}
.v-btn[color="#FF9B1B"] {
background-color: #FF9B1B !important;
color: white !important;
}
.v-alert[color="orange"] {
border-left: 8px solid #FF9B1B !important;
color: #333 !important;
}
/* Responsive Design Adjustments for single column */
@media (max-width: 960px) {
.main-card {
margin: 1rem;
max-width: 450px !important; /* Enforce max-width on smaller screens */
}
.login-section-box {
min-height: auto;
padding: 2rem !important;
}
}
@media (max-width: 600px) {
.main-card {
margin: 0.5rem;
}
.login-section-box {
padding: 1.5rem !important;
}
}
+130
View File
@@ -0,0 +1,130 @@
<template>
<v-card
class="pa-4 rounded-lg elevation-2 d-flex flex-column"
color="white"
style="border-top: 8px solid #FFB95F;"
height="100%"
>
<div class="d-flex align-center justify-center mb-4">
<v-icon size="36" color="orange-lighten-2" class="mr-2">mdi-hospital-box-outline</v-icon>
<div class="text-h6 font-weight-bold text-orange-lighten-2">{{ title }}</div>
</div>
<v-divider class="mb-4"></v-divider>
<div class="timeline-scroll-container">
<v-timeline density="compact" align="start" line-inset="12" line-color="#FFB95F">
<v-timeline-item
v-for="(step, index) in steps"
:key="index"
:dot-color="getStepColor(index)"
:icon="getStepIcon(index)"
size="small"
icon-color="white"
>
<div class="d-flex flex-column align-start">
<span
class="font-weight-bold"
:class="index === currentStepIndex ? 'text-orange-lighten-1' : index < currentStepIndex ? 'text-success' : 'text-grey-darken-1'"
>
{{ step.label }}
</span>
<span class="text-caption text-grey-darken-1">
{{ step.date !== '-' ? `${step.date}, ${step.time}` : 'Menunggu' }}
</span>
</div>
</v-timeline-item>
</v-timeline>
</div>
<v-divider class="mt-4 mb-4"></v-divider>
<v-btn
size="large"
variant="tonal"
color="#FFA532"
class="mt-auto font-weight-bold"
prepend-icon="mdi-printer"
block
>
Cetak Ulang Tiket
</v-btn>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
title: String,
steps: Array,
color: String,
currentStepLabel: String,
});
const currentStepIndex = computed(() => {
return props.steps.findIndex(step => step.label === props.currentStepLabel);
});
const getStepIcon = (index) => {
if (index < currentStepIndex.value) {
return 'mdi-check';
} else if (index === currentStepIndex.value) {
return 'mdi-progress-check';
} else {
return 'mdi-circle-outline';
}
};
const getStepColor = (index) => {
if (index < currentStepIndex.value) {
return 'success';
} else if (index === currentStepIndex.value) {
return 'orange-lighten-1';
}
return 'grey-lighten-1';
};
</script>
<style scoped>
/* =============================================== */
/* SCROLLABLE TIMELINE STYLES (Menggunakan Tinggi Tetap) */
/* =============================================== */
.timeline-scroll-container {
/* Tinggi Tetap: Memastikan tinggi card konsisten di semua skenario responsive */
height: 320px;
/* Membuat konten scrollable di sumbu Y jika melebihi height */
overflow-y: auto;
/* Memberi jarak internal di bagian scrollable */
padding-right: 8px;
}
/* PENTING: Menghilangkan padding bawah dari V-Timeline bawaan */
.v-timeline {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
/* Kustomisasi scrollbar untuk tampilan yang lebih bersih (Opsional) */
.timeline-scroll-container::-webkit-scrollbar {
width: 6px;
}
.timeline-scroll-container::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
/* =============================================== */
/* V-TIMELINE KUSTOMISASI */
/* =============================================== */
.v-timeline-item :deep(.v-timeline-item__body) {
padding-inline-start: 16px !important;
}
.v-timeline-item :deep(.v-timeline-item__opposite) {
display: none;
}
</style>
-155
View File
@@ -1,155 +0,0 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="bottom right"
origin="top right"
transition="slide-y-transition"
>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<v-avatar size="40">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
</v-btn>
</template>
<v-card class="rounded-lg elevation-4 pa-4" width="300">
<div class="d-flex align-center pb-2">
<v-avatar size="48">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="ml-4">
<div class="text-subtitle-1 font-weight-bold">
{{ user?.name || user?.preferred_username || 'User' }}
</div>
<div class="text-caption text-grey-darken-1">
{{ user?.email || 'No email' }}
</div>
<div class="text-caption text-grey-darken-2">
ID: {{ user?.id?.substring(0, 8) }}...
</div>
</div>
</div>
<v-divider class="my-2"></v-divider>
<v-list dense>
<v-list-item link class="rounded-lg" @click="handleAction('account')">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>Pengaturan Akun</v-list-item-title>
</v-list-item>
<v-list-item link class="rounded-lg" @click="handleAction('darkMode')">
<template v-slot:prepend>
<v-icon>mdi-weather-night</v-icon>
</template>
<v-list-item-title>Mode Gelap</v-list-item-title>
<template v-slot:append>
<v-switch
v-model="darkMode"
hide-details
density="compact"
color="primary"
></v-switch>
</template>
</v-list-item>
<v-list-item link class="rounded-lg" @click="handleAction('profile')">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>Profil Saya</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
link
class="rounded-lg text-red"
@click="signOut"
:disabled="isLoggingOut"
>
<template v-slot:prepend>
<v-icon color="red">mdi-logout</v-icon>
</template>
<v-list-item-title>
{{ isLoggingOut ? 'Logging out...' : 'Keluar' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
// Props
const props = defineProps({
user: {
type: Object,
required: true
}
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
/**
* Handles the logout action - delegates to parent
*/
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
console.log('🚪 ProfilePopup signOut called...')
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
console.log('Action triggered:', action);
switch(action) {
case 'account':
// Navigate to account settings
navigateTo('/settings/account')
break;
case 'profile':
// Navigate to profile page
navigateTo('/profile')
break;
case 'darkMode':
// Dark mode toggle is handled by v-model
break;
default:
console.log('Unknown action:', action);
}
// Close menu for navigation actions
if (action !== 'darkMode') {
menu.value = false;
}
};
</script>
<style scoped>
.text-red {
color: rgb(244, 67, 54) !important;
}
</style>
+416
View File
@@ -0,0 +1,416 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="scale-transition"
open-on-hover
>
<template v-slot:activator="{ props: menuProps }">
<v-list-item
v-if="!rail"
v-bind="menuProps"
:title="user?.name || user?.preferred_username || 'User'"
:subtitle="user?.email || 'No email'"
class="pa-3 ma-1 rounded-xl profile-activator"
link
>
<template v-slot:prepend>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</template>
<template v-slot:append>
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
</template>
</v-list-item>
<v-btn
v-else
icon
v-bind="menuProps"
size="large"
variant="flat"
class="avatar-btn"
:title="user?.name || 'Profile'"
>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</v-btn>
</template>
<v-card class="profile-card rounded-xl elevation-12" width="340">
<!-- Decorative Header -->
<div class="card-header">
<div class="header-pattern"></div>
<div class="header-content pa-6 text-center">
<div class="avatar-container mb-4">
<v-avatar size="90" class="profile-avatar">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-badge">
<v-icon size="12" color="white">mdi-check</v-icon>
</div>
</div>
<h3 class="text-h6 font-weight-bold text-white mb-1">
{{ user?.name || user?.preferred_username || 'User' }}
</h3>
<p class="text-body-2 text-white opacity-90 mb-2">
{{ user?.email || 'No email' }}
</p>
<v-chip
size="small"
class="glass-chip"
prepend-icon="mdi-identifier"
>
{{ user?.id?.substring(0, 10) }}...
</v-chip>
</div>
</div>
<!-- Menu Items -->
<v-card-text class="pa-4">
<div class="menu-grid">
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('profile')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon>
</div>
<div class="text-caption font-weight-medium">Profil</div>
</div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('account')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="orange-darken-2">mdi-cog-outline</v-icon>
</div>
<div class="text-caption font-weight-medium">Pengaturan</div>
</div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('darkMode')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" :color="darkMode ? 'deep-purple-darken-2' : 'amber-darken-2'">
{{ darkMode ? 'mdi-moon-waning-crescent' : 'mdi-white-balance-sunny' }}
</v-icon>
</div>
<div class="text-caption font-weight-medium">
{{ darkMode ? 'Gelap' : 'Terang' }}
</div>
</div>
</div>
<v-divider class="my-4"></v-divider>
<!-- Quick Actions -->
<div class="quick-actions">
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('notifications')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-bell-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Notifikasi
</v-list-item-title>
<template v-slot:append>
<v-badge color="orange-darken-2" content="3" inline></v-badge>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('help')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-help-circle-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Bantuan & Dukungan
</v-list-item-title>
</v-list-item>
</div>
<v-divider class="my-4"></v-divider>
<!-- Logout Button -->
<v-btn
block
class="rounded-lg logout-btn"
variant="flat"
color="error"
prepend-icon="mdi-logout"
@click="signOut"
:disabled="isLoggingOut"
:loading="isLoggingOut"
>
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
}
menu.value = false;
};
</script>
<style scoped>
.profile-activator {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border: 1px solid rgba(25, 118, 210, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-activator:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-2px);
}
.avatar-wrapper {
position: relative;
}
.avatar-glow {
border: 3px solid white;
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4);
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 2px solid white;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
.chevron-icon {
transition: transform 0.3s ease;
}
.profile-activator:hover .chevron-icon {
transform: translateY(2px);
}
.avatar-btn {
background: transparent !important;
}
.profile-card {
overflow: hidden;
border: 1px solid rgba(25, 118, 210, 0.1);
}
.card-header {
position: relative;
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.header-content {
position: relative;
z-index: 1;
}
.avatar-container {
position: relative;
display: inline-block;
}
.profile-avatar {
border: 4px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.status-badge {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.menu-tile {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
border: 1px solid rgba(25, 118, 210, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.menu-tile:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.tile-icon-wrapper {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.action-item {
transition: all 0.2s ease;
background: transparent;
}
.action-item:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
transform: translateX(4px);
}
.logout-btn {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
}
.logout-btn:hover {
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
}
.cursor-pointer {
cursor: pointer;
}
.opacity-90 {
opacity: 0.9;
}
</style>
-75
View File
@@ -1,75 +0,0 @@
<!-- <template>
<v-dialog v-model="dialog" max-width="500px">
<v-card>
<v-card-title class="text-h6 font-weight-bold">
Atur Urutan Menu
</v-card-title>
<v-card-text>
<v-list dense>
<draggable v-model="localMenus" item-key="title" @end="onDragEnd">
<template #item="{ element }">
<v-list-item class="reorder-item">
<v-list-item-content>
<v-list-item-title>{{ element.title }}</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-icon>mdi-drag</v-icon>
</v-list-item-icon>
</v-list-item>
</template>
</draggable>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" text @click="dialog = false">Batal</v-btn>
<v-btn color="blue" text @click="saveOrder">Simpan Urutan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue';
import draggable from 'vuedraggable';
import navigationItems from '~/data/menu.js';
const dialog = ref(false);
const localMenus = ref([]);
// Watch for changes in the dialog's visibility
watch(dialog, (val) => {
if (val) {
// Make a deep copy to avoid mutating the original data
localMenus.value = JSON.parse(JSON.stringify(navigationItems));
}
});
const onDragEnd = (event) => {
// Logic to handle the end of a drag event
};
const saveOrder = () => {
// Emit an event with the new menu order
// You would then handle this in the parent component
// to update the ~/data/menu.js file (or your state management)
// and trigger a UI refresh.
dialog.value = false;
};
const openDialog = () => {
dialog.value = true;
};
defineExpose({ openDialog });
</script>
<style scoped>
.reorder-item {
cursor: grab;
border-bottom: 1px solid #eee;
}
.reorder-item:active {
cursor: grabbing;
}
</style> -->
-103
View File
@@ -1,103 +0,0 @@
<!-- components/sideBar.vue -->
<template>
<v-navigation-drawer
:model-value="drawer"
:rail="rail"
permanent
app
@update:model-value="emit('update:drawer', $event)"
>
<v-list density="compact" nav>
<template v-for="item in items" :key="item.name">
<v-menu
v-if="item.children"
open-on-hover
:location="rail ? 'end' : undefined"
:offset="10"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.name"
:value="item.name"
:to="!rail ? item.path : undefined"
:link="!rail"
></v-list-item>
</template>
<v-card class="py-2" min-width="200">
<v-card-title class="text-subtitle-1 font-weight-bold px-4 py-2">
{{ item.name }}
</v-card-title>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item
v-for="child in item.children"
:key="child.name"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="px-4"
></v-list-item>
</v-list>
</v-card>
</v-menu>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.name"
:to="item.path"
link
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
const props = defineProps({
items: {
type: Array as () => NavItem[],
required: true,
},
rail: {
type: Boolean,
required: true,
},
drawer: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['update:drawer']);
</script>
<style scoped>
.v-navigation-drawer__content {
background-color: #ffffff;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<v-snackbar v-model="showModel" :color="color" :timeout="timeout">
<span class="body-3">{{ message }}</span>
<template #actions>
<v-btn variant="text" size="small" @click="showModel = false">
{{ closeText }}
</v-btn>
</template>
</v-snackbar>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
message: {
type: String,
default: ''
},
color: {
type: String,
default: 'success'
},
timeout: {
type: Number,
default: 3000
},
closeText: {
type: String,
default: 'Tutup'
}
});
const emit = defineEmits(['update:modelValue']);
const showModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
</script>
<style scoped lang="scss">
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
.body-3 {
font-size: 14px;
line-height: 20px;
font-family: $font-family-base;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="field-group">
<div class="group-label">
<v-icon v-if="icon" size="18" class="icon-label">{{ icon }}</v-icon>
<span>{{ title }}</span>
</div>
<slot></slot>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
icon: {
type: String,
default: ''
}
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.field-group {
background: $neutral-100;
padding: 20px;
border-radius: 12px;
margin-bottom: 0;
border: 1px solid $neutral-400;
}
.group-label {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $primary-600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.icon-label {
color: $primary-600 !important;
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<div class="page-header" :class="`page-header-${theme}`">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">{{ icon }}</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">{{ title }}</h2>
<p class="page-subtitle">{{ subtitle }}</p>
</div>
</div>
<slot name="actions">
<v-btn
v-if="showAddButton"
color="white"
elevation="0"
class="add-btn"
:class="`add-btn-${theme}`"
@click="$emit('add-click')"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
{{ addButtonText }}
</v-btn>
</slot>
</div>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
default: 'mdi-view-dashboard'
},
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
showAddButton: {
type: Boolean,
default: true
},
addButtonText: {
type: String,
default: 'Tambah'
},
theme: {
type: String,
default: 'primary', // 'primary', 'secondary', 'success', or 'accent'
validator: (value) => ['primary', 'secondary', 'success', 'accent'].includes(value)
}
});
defineEmits(['add-click']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$success-600: #009262;
$success-700: #1B6E53;
$accent-600: #8B5CF6;
$accent-700: #7C3AED;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.page-header {
border-radius: 16px 16px 0 0;
}
.page-header-primary {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
}
.page-header-secondary {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
box-shadow: 0 4px 16px rgba(6, 113, 224, 0.2);
}
.page-header-success {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
}
.page-header-accent {
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3);
}
.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;
}
.add-btn-primary {
color: $primary-600 !important;
}
.add-btn-secondary {
color: $secondary-600 !important;
}
.add-btn-success {
color: $success-600 !important;
}
.add-btn-accent {
color: $accent-600 !important;
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px">
<v-card>
<v-card-title class="dialog-header">
<v-btn icon size="small" @click="dialogModel = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<span>{{ title }}</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="searchModel"
:placeholder="searchPlaceholder"
density="compact"
hide-details
class="mb-4"
prepend-inner-icon="mdi-magnify"
/>
<v-row dense>
<v-col
v-for="item in filteredItems"
:key="item.id"
cols="6"
sm="4"
>
<v-card
class="selection-card"
@click="handleSelect(item)"
elevation="0"
>
<v-card-text class="text-center pa-3">
<div class="selection-name">{{ item.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
title: {
type: String,
default: 'Pilih Item'
},
items: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
},
searchPlaceholder: {
type: String,
default: 'Cari...'
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredItems = computed(() => {
if (!searchModel.value) return props.items;
const search = searchModel.value.toLowerCase();
return props.items.filter(item =>
item.name.toLowerCase().includes(search)
);
});
const handleSelect = (item) => {
emit('select', item);
dialogModel.value = false;
};
</script>
<style scoped lang="scss">
.dialog-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);
}
.selection-card {
cursor: pointer;
transition: all 0.2s;
border: 2px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.selection-card:hover {
border-color: var(--color-secondary-600);
background: var(--color-secondary-200);
}
.selection-name {
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-900);
}
</style>
@@ -0,0 +1,102 @@
<template>
<v-card class="filter-card" elevation="0">
<v-card-text>
<v-row align="center">
<v-col cols="12" md="6">
<v-text-field
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
prepend-inner-icon="mdi-magnify"
label="Pencarian"
:placeholder="searchPlaceholder"
variant="outlined"
density="comfortable"
hide-details
clearable
class="search-field"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
:model-value="statusFilter"
@update:model-value="$emit('update:statusFilter', $event)"
:items="statusOptions"
label="Status"
variant="outlined"
density="comfortable"
hide-details
clearable
class="filter-select"
/>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="primary-600"
variant="outlined"
block
size="large"
@click="$emit('refresh')"
:loading="loading"
class="refresh-btn"
>
<v-icon start>mdi-refresh</v-icon>
Refresh Data
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
searchQuery: {
type: String,
default: ''
},
statusFilter: {
type: String,
default: null
},
statusOptions: {
type: Array,
default: () => ['Terdaftar', 'Belum Terdaftar', 'Proses']
},
searchPlaceholder: {
type: String,
default: 'Cari nama pasien, No. RM, atau alamat...'
},
loading: {
type: Boolean,
default: false
}
});
defineEmits(['update:searchQuery', 'update:statusFilter', 'refresh']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.filter-card {
border-radius: 12px;
border: 1px solid $neutral-500;
background: $neutral-100;
margin-bottom: 16px;
}
.search-field,
.filter-select {
font-family: $font-family-base;
}
.refresh-btn {
text-transform: none;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,231 @@
<template>
<v-dialog v-model="dialogModel" max-width="600px">
<v-card class="dialog-card">
<v-card-title class="dialog-header" :class="`dialog-header-${type}`">
<v-icon class="mr-2">{{ icon }}</v-icon>
<span>{{ title }}</span>
<v-spacer />
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<!-- Patient Info Card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ patient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ patient.noRM }}</div>
</v-col>
</v-row>
</div>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
:label="searchLabel"
variant="outlined"
density="comfortable"
hide-details
class="mb-4 search-field"
/>
<!-- Options Grid -->
<v-row>
<v-col
v-for="option in filteredOptions"
:key="option.id"
cols="6"
>
<v-card
class="option-card"
:class="`option-card-${type}`"
@click="$emit('select', option)"
elevation="0"
>
<v-card-text class="text-center pa-4">
<v-icon :size="32" :color="iconColor" class="mb-2">
{{ icon }}
</v-icon>
<div class="option-title">{{ option.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
type: {
type: String,
default: 'klinik', // 'klinik' | 'penunjang'
validator: (value) => ['klinik', 'penunjang'].includes(value)
},
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
patient: {
type: Object,
default: null
},
options: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
},
searchLabel: {
type: String,
default: 'Cari...'
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredOptions = computed(() => {
if (!searchModel.value) return props.options;
const search = searchModel.value.toLowerCase();
return props.options.filter(opt =>
opt.name.toLowerCase().includes(search)
);
});
const iconColor = computed(() => {
return props.type === 'klinik' ? 'secondary-600' : 'accent-600';
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$neutral-900: #212121;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$accent-600: #8B5CF6;
$accent-700: #7C3AED;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
color: $neutral-100;
padding: 20px 24px;
font-size: 18px;
font-weight: $font-weight-semibold;
display: flex;
align-items: center;
}
.dialog-header-klinik {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
}
.dialog-header-penunjang {
background: linear-gradient(135deg, $accent-600 0%, $accent-700 100%);
}
.close-btn {
color: $neutral-100 !important;
}
.patient-card {
background: $neutral-300;
padding: 16px;
border-radius: 12px;
border: 1px solid $neutral-500;
}
.card-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
margin-bottom: 12px;
font-family: $font-family-base;
}
.info-label {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
margin-bottom: 4px;
font-family: $font-family-base;
}
.info-value {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.search-field {
font-family: $font-family-base;
}
.option-card {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid $neutral-500;
background: $neutral-100;
}
.option-card-klinik:hover {
border-color: $secondary-600;
background: var(--color-secondary-200);
transform: translateY(-2px);
}
.option-card-penunjang:hover {
border-color: $accent-600;
background: var(--color-accent-200);
transform: translateY(-2px);
}
.option-title {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,262 @@
<template>
<v-dialog v-model="dialogModel" max-width="800px">
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<v-icon class="mr-2">mdi-door-open</v-icon>
<span>Pilih Klinik Ruang</span>
<v-spacer />
<v-btn icon @click="dialogModel = false" size="small" class="close-btn">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<!-- Patient Info Card -->
<div v-if="patient" class="patient-card mb-4">
<h4 class="card-title">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ patient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ patient.noRM }}</div>
</v-col>
</v-row>
</div>
<!-- Search Field -->
<v-text-field
v-model="searchModel"
prepend-inner-icon="mdi-magnify"
label="Cari Klinik Ruang"
variant="outlined"
density="comfortable"
hide-details
class="mb-4 search-field"
/>
<!-- Ruang List with Expansion Panels -->
<v-expansion-panels class="ruang-panels">
<v-expansion-panel
v-for="klinikRuang in filteredRuang"
:key="klinikRuang.id"
class="panel-item"
>
<v-expansion-panel-title class="panel-title">
<div class="d-flex align-center w-100">
<v-chip size="small" color="primary-600" class="mr-2 kode-chip">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<strong class="klinik-name">{{ klinikRuang.namaKlinik }}</strong>
<v-spacer />
<v-chip size="small" variant="outlined" class="count-chip">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list density="compact" class="ruang-list">
<v-list-item
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
@click="$emit('select', klinikRuang, ruang)"
class="ruang-item"
>
<template #prepend>
<v-icon color="primary-600" size="20">mdi-door</v-icon>
</template>
<v-list-item-title class="ruang-title">
<strong>Ruang {{ ruang.nomorRuang }}</strong> - {{ ruang.namaRuang }}
</v-list-item-title>
<v-list-item-subtitle class="ruang-subtitle">
Screen: {{ ruang.nomorScreen }}
</v-list-item-subtitle>
<template #append>
<v-icon size="20">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
patient: {
type: Object,
default: null
},
ruangList: {
type: Array,
default: () => []
},
searchQuery: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'update:searchQuery', 'select']);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => emit('update:searchQuery', value)
});
const filteredRuang = computed(() => {
if (!searchModel.value) return props.ruangList;
const search = searchModel.value.toLowerCase();
return props.ruangList.filter(k =>
k.namaKlinik.toLowerCase().includes(search) ||
k.kodeKlinik.toLowerCase().includes(search)
);
});
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
font-size: 18px;
font-weight: $font-weight-semibold;
display: flex;
align-items: center;
}
.close-btn {
color: $neutral-100 !important;
}
.patient-card {
background: $neutral-300;
padding: 16px;
border-radius: 12px;
border: 1px solid $neutral-500;
}
.card-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
margin-bottom: 12px;
font-family: $font-family-base;
}
.info-label {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
margin-bottom: 4px;
font-family: $font-family-base;
}
.info-value {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.search-field {
font-family: $font-family-base;
}
.ruang-panels {
font-family: $font-family-base;
}
.panel-item {
margin-bottom: 8px;
border: 1px solid $neutral-500;
border-radius: 8px;
}
.panel-title {
font-family: $font-family-base;
}
.kode-chip {
font-weight: $font-weight-semibold;
font-size: 12px;
}
.klinik-name {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.count-chip {
font-size: 12px;
font-family: $font-family-base;
}
.ruang-list {
padding: 0;
}
.ruang-item {
cursor: pointer;
border-radius: 8px;
margin-bottom: 4px;
transition: background 0.2s;
font-family: $font-family-base;
}
.ruang-item:hover {
background: $neutral-300;
}
.ruang-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $neutral-900;
font-family: $font-family-base;
}
.ruang-subtitle {
font-size: 12px;
color: $neutral-700;
font-family: $font-family-base;
}
</style>
@@ -0,0 +1,195 @@
<template>
<v-card elevation="0" class="patient-table-card">
<v-card-title class="table-title">
<v-icon class="mr-2" color="primary-600">mdi-account-group</v-icon>
<span>Daftar Pasien - {{ items.length }} pasien</span>
</v-card-title>
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="items"
:search="searchQuery"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
density="comfortable"
>
<template #item.namaPasien="{ item }">
<div class="patient-info">
<div class="patient-name">{{ item.namaPasien }}</div>
<div class="patient-meta">{{ item.noRM }}</div>
</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
class="status-chip"
>
{{ item.status }}
</v-chip>
</template>
<template #item.buatAntrean="{ item }">
<div class="action-buttons">
<v-btn
size="small"
color="secondary-600"
variant="outlined"
@click="$emit('create-klinik', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-hospital-building</v-icon>
Klinik
</v-btn>
<v-btn
size="small"
color="primary-600"
variant="outlined"
@click="$emit('create-ruang', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-door-open</v-icon>
Klinik Ruang
</v-btn>
<v-btn
size="small"
color="accent-600"
variant="outlined"
@click="$emit('create-penunjang', item)"
class="action-btn"
>
<v-icon size="16" start>mdi-clipboard-pulse</v-icon>
Penunjang
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script setup>
defineProps({
items: {
type: Array,
required: true
},
searchQuery: {
type: String,
default: ''
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'Nama Pasien', value: 'namaPasien', sortable: true },
{ title: 'No. RM', value: 'noRM', sortable: true },
{ title: 'Alamat', value: 'alamat', sortable: true },
{ title: 'Status', value: 'status', sortable: true },
{ title: 'Buat Antrean', value: 'buatAntrean', sortable: false, width: '360px' },
];
defineEmits(['create-klinik', 'create-ruang', 'create-penunjang']);
const getStatusColor = (status) => {
const colors = {
'Terdaftar': 'var(--color-success-600)',
'Belum Terdaftar': 'var(--color-primary-600)',
'Proses': 'var(--color-secondary-600)'
};
return colors[status] || 'var(--color-neutral-600)';
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-400: #E5F7FA;
$neutral-500: #ABBED1;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-900: #212121;
$primary-600: #FFA532;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.patient-table-card {
border-radius: 12px;
border: 1px solid $neutral-500;
background: $neutral-100;
}
.table-title {
display: flex;
align-items: center;
padding: 16px 20px;
background: var(--color-neutral-300);
font-size: 16px;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
color: $neutral-900;
}
.patient-info {
padding: 4px 0;
}
.patient-name {
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
color: $neutral-900;
font-family: $font-family-base;
}
.patient-meta {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
font-family: $font-family-base;
}
.status-chip {
font-weight: $font-weight-semibold;
font-size: 11px;
font-family: $font-family-base;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.action-btn {
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 12px;
font-family: $font-family-base;
}
:deep(.data-table) {
font-family: $font-family-base;
}
:deep(.data-table th) {
font-family: $font-family-base;
font-weight: $font-weight-semibold !important;
color: $neutral-600 !important;
}
:deep(.data-table td) {
font-family: $font-family-base;
border-bottom: 1px solid $neutral-400 !important;
}
:deep(.data-table tbody tr:hover) {
background: var(--color-neutral-300) !important;
}
</style>
@@ -0,0 +1,83 @@
<template>
<div class="day-selector">
<v-chip
v-for="hari in dayList"
:key="hari.hari"
class="day-chip"
:class="{ 'day-chip-active': selectedDays.includes(hari.hari) }"
label
@click="toggleDay(hari.hari)"
>
<v-icon v-if="selectedDays.includes(hari.hari)" left size="16">mdi-check</v-icon>
{{ hari.hari }}
</v-chip>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const dayList = ref([
{ no: 1, hari: 'Senin' },
{ no: 2, hari: 'Selasa' },
{ no: 3, hari: 'Rabu' },
{ no: 4, hari: 'Kamis' },
{ no: 5, hari: 'Jum\'at' },
]);
const selectedDays = ref(props.modelValue);
const toggleDay = (hari) => {
const index = selectedDays.value.indexOf(hari);
if (index > -1) {
selectedDays.value.splice(index, 1);
} else {
selectedDays.value.push(hari);
}
emit('update:modelValue', selectedDays.value);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-500: #ABBED1;
$neutral-800: #4D4D4D;
$secondary-300: #DBEDFF;
$secondary-600: #0671E0;
$font-weight-medium: 500;
.day-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.day-chip {
cursor: pointer;
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-medium;
border: 1px solid $neutral-500;
background-color: $neutral-100;
color: $neutral-800;
&:hover {
background-color: $secondary-300;
}
}
.day-chip-active {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
border-color: $secondary-600 !important;
}
</style>
@@ -0,0 +1,276 @@
<template>
<v-dialog v-model="dialogModel" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Klinik' : 'Tambah Klinik' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Dasar -->
<FormFieldGroup title="Informasi Dasar">
<v-row dense>
<v-col cols="4">
<v-text-field
v-model="formModel.kode"
label="Kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode harus diisi']"
hide-details="auto"
placeholder="AN"
class="mb-3 input-field"
/>
<small class="caption-2">2 Huruf</small>
</v-col>
<v-col cols="4">
<v-text-field
v-model="formModel.nama"
label="Nama"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama harus diisi']"
hide-details="auto"
placeholder="Gigi dan Mulut"
class="mb-3 input-field"
/>
</v-col>
<v-col cols="4">
<v-text-field
v-model.number="formModel.shift"
label="Jumlah Shift"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Shift harus diisi', v => v > 0 || 'Minimal 1']"
hide-details="auto"
class="mb-3 input-field"
@update:model-value="updateShiftCount"
/>
</v-col>
</v-row>
<v-checkbox
v-model="formModel.autoShift"
label="Auto Shift"
hide-details
color="#0671E0"
density="compact"
class="checkbox-field"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi Shift & Kuota -->
<FormFieldGroup title="Konfigurasi Shift & Kuota" icon="mdi-clock-outline">
<ShiftConfiguration
:shifts="formModel.jamShiftList"
@update:shifts="formModel.jamShiftList = $event"
@remove-shift="removeShift"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Jadwal Klinik -->
<FormFieldGroup title="Jadwal Klinik" icon="mdi-calendar-check">
<DaySelector v-model="formModel.jadwalKlinik" />
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="handleClose"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="handleSubmit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
import ShiftConfiguration from '@/components/master/ShiftConfiguration.vue';
import DaySelector from '@/components/master/DaySelector.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const updateShiftCount = (newShiftCount) => {
const currentCount = formModel.value.jamShiftList.length;
if (newShiftCount > currentCount) {
for (let i = currentCount; i < newShiftCount; i++) {
let defaultDari = '07:00', defaultSampai = '11:00';
if (i === 1) { defaultDari = '13:00'; defaultSampai = '16:00'; }
else if (i === 2) { defaultDari = '18:00'; defaultSampai = '20:00'; }
else if (i > 2) {
const prevShift = formModel.value.jamShiftList[i - 1];
const [prevHour] = prevShift.sampai.split(':');
const nextHour = (parseInt(prevHour) + 1) % 24;
defaultDari = `${String(nextHour).padStart(2, '0')}:00`;
defaultSampai = `${String((nextHour + 4) % 24).padStart(2, '0')}:00`;
}
formModel.value.jamShiftList.push({ dari: defaultDari, sampai: defaultSampai, kuota: 0 });
}
} else if (newShiftCount < currentCount && newShiftCount > 0) {
formModel.value.jamShiftList = formModel.value.jamShiftList.slice(0, newShiftCount);
}
};
const removeShift = (index) => {
if (formModel.value.jamShiftList.length > 1) {
formModel.value.jamShiftList.splice(index, 1);
formModel.value.shift = formModel.value.jamShiftList.length;
}
};
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (valid) {
emit('submit', formModel.value);
}
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
color: $neutral-700;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.checkbox-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,171 @@
<template>
<div>
<div v-for="(shift, index) in shifts" :key="index" class="shift-item">
<v-row dense align="center">
<v-col cols="2">
<div class="shift-badge body-3">Shift {{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
v-model="shift.dari"
label="Mulai"
type="time"
variant="outlined"
density="compact"
hide-details
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model="shift.sampai"
label="Selesai"
type="time"
variant="outlined"
density="compact"
hide-details
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model.number="shift.kuota"
label="Kuota"
type="number"
variant="outlined"
density="compact"
hide-details
placeholder="0"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="1">
<v-btn
v-if="shifts.length > 1"
icon
size="small"
variant="text"
class="btn-delete-shift"
@click="removeShift(index)"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<div class="total-badge">
<v-icon size="18" class="icon-success">mdi-sigma</v-icon>
<span class="body-3 text-medium">Total Kuota:</span>
<v-chip size="small" variant="flat" class="chip-success">
{{ totalQuota }}
</v-chip>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
shifts: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:shifts', 'remove-shift']);
const totalQuota = computed(() => {
return props.shifts.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
});
const emitUpdate = () => {
emit('update:shifts', props.shifts);
};
const removeShift = (index) => {
emit('remove-shift', index);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$neutral-700: #717171;
$secondary-600: #0671E0;
$secondary-700: #0053AD;
$success-200: #F1FBF8;
$success-300: #84DFC1;
$success-600: #009262;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.shift-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.shift-badge {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-delete-shift {
color: $danger-600 !important;
}
.total-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: $success-200;
border-radius: 8px;
margin-top: 8px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.chip-success {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
}
</style>
@@ -0,0 +1,111 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template #item.shift="{ item }">
<v-chip size="small" class="chip-secondary">
{{ item.shift }}
</v-chip>
</template>
<template #item.totalQuota="{ item }">
<span class="body-2 text-semibold">{{ item.totalQuota }}</span>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
class="btn-edit mr-2"
variant="flat"
@click="$emit('edit', item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
class="btn-delete"
variant="outlined"
@click="$emit('delete', item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'No', value: 'no', sortable: true },
{ title: 'Kode', value: 'kode', sortable: true },
{ title: 'Nama Klinik', value: 'nama', sortable: true },
{ title: 'Shift', value: 'shift', sortable: true },
{ title: 'Total Kuota', value: 'totalQuota', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-800: #4D4D4D;
$primary-600: #FFA532;
$secondary-600: #0671E0;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-secondary {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.body-2 {
font-size: 16px;
line-height: 24px;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,327 @@
<template>
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Loket' : 'Tambah Loket Baru' }}</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="handleClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Loket -->
<FormFieldGroup title="Informasi Loket">
<v-text-field
v-model="formModel.namaLoket"
label="Nama Loket"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama loket harus diisi']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Loket 1"
/>
<v-text-field
v-model.number="formModel.kuota"
label="Kuota Bangku"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota minimal 1']"
hide-details="auto"
class="mb-3 input-field"
placeholder="500"
/>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi -->
<FormFieldGroup title="Konfigurasi">
<v-select
v-model="formModel.statusPelayanan"
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
hide-details="auto"
class="mb-3 input-field"
/>
<v-row dense>
<v-col cols="6">
<v-select
v-model="formModel.pembayaran"
label="Pembayaran"
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Pembayaran harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="formModel.keterangan"
label="Keterangan"
:items="['ONLINE', 'OFFLINE']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Keterangan harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
</v-row>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Pelayanan -->
<FormFieldGroup title="Pelayanan" icon="mdi-hospital-box">
<v-select
v-model="formModel.pelayanan"
label="Pilih Pelayanan"
:items="availableServices"
item-title="nama"
item-value="id"
variant="outlined"
density="compact"
multiple
chips
closable-chips
:rules="[v => v.length > 0 || 'Pilih minimal 1 pelayanan']"
hide-details="auto"
class="input-field"
>
<template #chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
class="chip-primary"
>
{{ item.raw.nama }}
</v-chip>
</template>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<v-chip size="x-small" class="chip-primary-small">{{ item.raw.id }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<!-- Selected Services Preview -->
<div v-if="formModel.pelayanan.length > 0" class="selected-preview">
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
<small class="caption-2">{{ formModel.pelayanan.length }} pelayanan dipilih</small>
</div>
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
class="btn-cancel"
@click="handleClose"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
class="btn-submit"
@click="handleSubmit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
},
availableServices: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (valid) {
emit('submit', formModel.value);
}
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$primary-600: #FFA532;
$primary-700: #FF9B1B;
$success-200: #F1FBF8;
$success-300: #84DFC1;
$success-600: #009262;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
color: $neutral-700;
}
.chip-primary {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-primary-small {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
.selected-preview {
margin-top: 8px;
padding: 8px 12px;
background: $success-200;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,125 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template #item.pelayanan="{ item }">
<v-chip
v-for="(serviceKode, idx) in item.pelayanan.slice(0, 2)"
:key="idx"
size="small"
class="mr-1 mb-1 chip-primary-outline"
>
{{ getKlinikName(serviceKode) }}
</v-chip>
<v-chip
v-if="item.pelayanan.length > 2"
size="small"
class="chip-neutral"
>
+{{ item.pelayanan.length - 2 }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
variant="flat"
class="btn-edit mr-2"
@click="$emit('edit', item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
variant="outlined"
class="btn-delete"
@click="$emit('delete', item)"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
},
getKlinikName: {
type: Function,
required: true
}
});
const headers = [
{ title: "No", value: "no" },
{ title: "Nama Loket", value: "namaLoket" },
{ title: "Kuota", value: "kuota" },
{ title: "Pelayanan", value: "pelayanan" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Keterangan", value: "keterangan" },
{ title: "Aksi", value: "aksi", sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-600: #89939E;
$primary-600: #FFA532;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-primary-outline {
border: 1px solid $primary-600;
background-color: transparent !important;
color: $primary-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.chip-neutral {
background-color: $neutral-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,245 @@
<template>
<v-dialog v-model="dialogModel" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">{{ isEdit ? 'Edit Klinik Ruang' : 'Tambah Klinik Ruang' }}</span>
<v-btn icon variant="text" @click="handleClose" size="small" class="btn-close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Pilih Klinik -->
<FormFieldGroup title="Pilih Klinik" icon="mdi-hospital-building">
<v-autocomplete
label="Kode Klinik"
v-model="formModel.kodeKlinik"
:items="klinikList"
item-title="nama"
item-value="kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode klinik harus dipilih']"
hide-details="auto"
placeholder="Pilih Klinik"
@update:model-value="handleKlinikChange"
class="mb-3 input-field"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-chip size="x-small" class="chip-success-small">{{ item.raw.kode }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-autocomplete>
</FormFieldGroup>
<v-divider class="my-4 divider-section"/>
<!-- Daftar Ruangan -->
<FormFieldGroup title="Daftar Ruangan" icon="mdi-door">
<RoomListManager
:rooms="formModel.ruangList"
@update:rooms="formModel.ruangList = $event"
@add-room="addRoom"
@remove-room="removeRoom"
/>
</FormFieldGroup>
</v-form>
</v-card-text>
<v-divider/>
<v-card-actions class="dialog-actions">
<v-spacer/>
<v-btn
variant="outlined"
@click="handleClose"
class="btn-cancel"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
@click="handleSubmit"
class="btn-submit"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import FormFieldGroup from '@/components/common/FormFieldGroup.vue';
import RoomListManager from '@/components/master/RoomListManager.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
isEdit: {
type: Boolean,
default: false
},
formData: {
type: Object,
required: true
},
klinikList: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'update:formData', 'submit', 'close', 'klinik-change']);
const formRef = ref(null);
const dialogModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const formModel = computed({
get: () => props.formData,
set: (value) => emit('update:formData', value)
});
const handleKlinikChange = (kode) => {
emit('klinik-change', kode);
};
const addRoom = () => {
formModel.value.ruangList.push({
nomorRuang: '',
namaRuang: '',
nomorScreen: ''
});
};
const removeRoom = (index) => {
if (formModel.value.ruangList.length > 1) {
formModel.value.ruangList.splice(index, 1);
}
};
const handleClose = () => {
emit('close');
};
const handleSubmit = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
if (formModel.value.ruangList.length === 0) {
// Emit validation error
emit('validation-error', 'Tambahkan minimal 1 ruangan');
return;
}
emit('submit', formModel.value);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-400: #E5F7FA;
$neutral-600: #89939E;
$neutral-700: #717171;
$neutral-800: #4D4D4D;
$success-600: #009262;
$success-700: #1B6E53;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-semibold: 600;
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
.chip-success-small {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
.btn-cancel {
border-color: $neutral-600 !important;
color: $neutral-800 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
.btn-submit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<div>
<div v-for="(ruang, index) in rooms" :key="index" class="ruang-item">
<v-row dense align="center">
<v-col cols="1">
<div class="ruang-badge body-3">{{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Ruang"
v-model="ruang.nomorRuang"
variant="outlined"
density="compact"
hide-details
placeholder="1"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="4">
<v-text-field
label="Nama Ruang"
v-model="ruang.namaRuang"
variant="outlined"
density="compact"
hide-details
placeholder="Ruang Konsultasi"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Screen"
v-model="ruang.nomorScreen"
variant="outlined"
density="compact"
hide-details
placeholder="101"
class="input-field"
@update:model-value="emitUpdate"
/>
</v-col>
<v-col cols="1">
<v-btn
v-if="rooms.length > 1"
icon
size="small"
variant="text"
@click="removeRoom(index)"
class="btn-delete-ruang"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<v-btn
variant="outlined"
size="small"
@click="addRoom"
class="btn-add-ruang mt-2"
>
<v-icon left size="18">mdi-plus</v-icon>
Tambah Ruang
</v-btn>
</div>
</template>
<script setup>
const props = defineProps({
rooms: {
type: Array,
required: true
}
});
const emit = defineEmits(['update:rooms', 'add-room', 'remove-room']);
const emitUpdate = () => {
emit('update:rooms', props.rooms);
};
const addRoom = () => {
emit('add-room');
};
const removeRoom = (index) => {
emit('remove-room', index);
};
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$neutral-300: #F5F7FA;
$neutral-500: #ABBED1;
$success-600: #009262;
$success-700: #1B6E53;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-semibold: 600;
.ruang-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.ruang-badge {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.btn-delete-ruang {
color: $danger-600 !important;
}
.btn-add-ruang {
border-color: $success-600 !important;
color: $success-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,108 @@
<template>
<v-data-table
:headers="headers"
:items="items"
:items-per-page="itemsPerPage"
class="elevation-0 data-table"
>
<template v-slot:item.namaKlinik="{ item }">
<v-chip size="small" class="chip-success-outline">
{{ item.kodeKlinik }}
</v-chip>
<span class="ml-2 body-3 text-medium">{{ item.namaKlinik }}</span>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
@click="$emit('edit', item)"
class="btn-edit mr-2"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
@click="$emit('delete', item)"
class="btn-delete"
variant="outlined"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
itemsPerPage: {
type: Number,
default: 10
}
});
const headers = [
{ title: 'No', value: 'no', sortable: true },
{ title: 'Nama Klinik', value: 'namaKlinik', sortable: true },
{ title: 'Kode', value: 'kodeKlinik', sortable: true },
{ title: 'Nama Ruang', value: 'namaRuang', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
];
defineEmits(['edit', 'delete']);
</script>
<style scoped lang="scss">
$neutral-100: #FFFFFF;
$success-600: #009262;
$danger-600: #E02B1D;
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-medium: 500;
$font-weight-semibold: 600;
.data-table {
font-family: $font-family-base;
}
.chip-success-outline {
border: 1px solid $success-600;
background-color: transparent !important;
color: $success-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.body-3 {
font-size: 14px;
line-height: 20px;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.btn-edit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
</style>
@@ -0,0 +1,347 @@
<!-- components/TabelData.vue -->
<template>
<v-card-text>
<!-- Title Section -->
<v-row no-gutters class="mb-3" v-if="title">
<v-col cols="12">
<v-card-title
class="text-subtitle-1 font-weight-bold pa-0"
:class="getTitleClass(title)"
>
{{ title }}
</v-card-title>
</v-col>
</v-row>
<!-- Controls Section -->
<v-row no-gutters class="d-flex align-center mb-4">
<v-col cols="12" sm="6" class="d-flex align-center">
<div class="d-flex align-center">
<span>Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
variant="outlined"
hide-details
class="mx-2"
style="width: 80px;"
/>
<span>entries</span>
</div>
</v-col>
<v-col cols="12" sm="6" class="d-flex justify-end align-center">
<div v-if="showSearch" class="d-flex align-center">
<span class="mr-2">Search:</span>
<v-text-field
v-model="search"
hide-details
density="compact"
variant="outlined"
style="width: 200px;"
/>
</div>
</v-col>
</v-row>
<v-data-table
:headers="headers"
:items="paginatedItems"
:search="search"
no-data-text="No data available in table"
hide-default-footer
class="elevation-1"
item-value="no"
>
<!-- Custom slot untuk nomor urut -->
<template v-slot:item.no="{ index }">
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
</template>
<!-- Custom slot untuk jam panggil dengan highlighting -->
<template v-slot:item.jamPanggil="{ item }">
<slot name="item.jamPanggil" :item="item">
<span>{{ item.jamPanggil }}</span>
</slot>
</template>
<!-- Custom slot untuk status -->
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
text-color="white"
>
{{ item.status }}
</v-chip>
</template>
<!-- Custom slot untuk barcode dengan formatting -->
<template v-slot:item.barcode="{ item }">
<span class="font-mono">{{ item.barcode }}</span>
</template>
<!-- Custom slot untuk no antrian dengan highlighting -->
<template v-slot:item.noAntrian="{ item }">
<div>
<span class="font-weight-medium">{{ item.noAntrian.split(' |')[0] }}</span>
<br>
<small class="text-grey-darken-1">{{ item.noAntrian.split(' |')[1] }}</small>
</div>
</template>
<!-- Custom slot untuk klinik dengan chip styling -->
<template v-slot:item.klinik="{ item }">
<v-chip
size="small"
variant="outlined"
:color="getKlinikColor(item.klinik)"
>
{{ item.klinik }}
</v-chip>
</template>
<!-- Custom slot untuk fast track -->
<template v-slot:item.fastTrack="{ item }">
<v-chip
size="small"
color="info"
variant="tonal"
>
{{ item.fastTrack }}
</v-chip>
</template>
<!-- Custom slot untuk pembayaran -->
<template v-slot:item.pembayaran="{ item }">
<v-chip
size="small"
color="success"
variant="tonal"
>
{{ item.pembayaran }}
</v-chip>
</template>
<!-- Custom slot untuk keterangan -->
<template v-slot:item.keterangan="{ item }">
<span v-if="item.keterangan" class="text-green font-weight-medium">
{{ item.keterangan }}
</span>
<span v-else>-</span>
</template>
<!-- Slot untuk aksi -->
<template v-slot:item.aksi="{ item }">
<slot name="actions" :item="item" />
</template>
<template #no-data>
<div class="text-center pa-4">No data available in table</div>
</template>
</v-data-table>
<!-- Footer Pagination -->
<div class="d-flex justify-space-between align-center mt-4">
<div class="text-body-2 text-grey-darken-1">
Showing {{ currentPageStart }} to {{ currentPageEnd }} of {{ filteredTotal }} entries
</div>
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
/>
</div>
</v-card-text>
</template>
<script setup>
import { ref, computed, watch } from "vue";
const props = defineProps({
headers: {
type: Array,
required: true,
},
items: {
type: Array,
required: true,
},
title: {
type: String,
default: "",
},
showSearch: {
type: Boolean,
default: true,
},
});
const search = ref("");
const itemsPerPage = ref(10);
const currentPage = ref(1);
// Filter items based on search
const filteredItems = computed(() => {
if (!search.value) {
return props.items;
}
const searchLower = search.value.toLowerCase();
return props.items.filter(item => {
return Object.values(item).some(value =>
String(value).toLowerCase().includes(searchLower)
);
});
});
const filteredTotal = computed(() => filteredItems.value.length);
const totalPages = computed(() => Math.ceil(filteredTotal.value / itemsPerPage.value));
// Paginate the filtered items
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return filteredItems.value.slice(start, end);
});
const currentPageStart = computed(() => {
if (filteredTotal.value === 0) return 0;
return (currentPage.value - 1) * itemsPerPage.value + 1;
});
const currentPageEnd = computed(() => {
const end = currentPage.value * itemsPerPage.value;
return Math.min(end, filteredTotal.value);
});
// Method untuk mendapatkan warna status
const getStatusColor = (status) => {
switch (status) {
case 'Tunggu Daftar':
return 'orange';
case 'Barcode':
return 'blue';
case 'Selesai':
return 'green';
case 'Batal':
return 'red';
case 'Aktif':
return 'success';
case 'Menunggu':
return 'warning';
default:
return 'grey';
}
};
// Method untuk mendapatkan warna klinik
const getKlinikColor = (klinik) => {
switch (klinik) {
case 'KANDUNGAN':
return 'pink';
case 'IPD':
return 'blue';
case 'THT':
return 'orange';
case 'SARAF':
return 'purple';
default:
return 'grey';
}
};
// Method untuk mendapatkan class title
const getTitleClass = (title) => {
if (title.includes('TERLAMBAT')) {
return 'text-warning';
} else if (title.includes('PENDING')) {
return 'text-info';
} else if (title.includes('DI LOKET')) {
return 'text-success';
}
return 'text-primary';
};
// Watch untuk reset halaman ketika items per page berubah
watch(itemsPerPage, () => {
currentPage.value = 1;
});
// Watch untuk reset halaman ketika items berubah
watch(() => props.items, () => {
currentPage.value = 1;
});
// Watch untuk reset halaman ketika search berubah
watch(search, () => {
currentPage.value = 1;
});
</script>
<style scoped>
.text-red {
color: #f44336 !important;
}
.text-warning {
color: #ff9800 !important;
}
.text-info {
color: #2196f3 !important;
}
.text-success {
color: #4caf50 !important;
}
.text-primary {
color: #1976d2 !important;
}
.font-mono {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
/* Table enhancements */
:deep(.v-data-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.v-data-table tbody tr) {
transition: background-color 0.2s ease;
}
:deep(.v-data-table tbody tr:hover) {
background: rgba(25, 118, 210, 0.04) !important;
}
:deep(.v-data-table th) {
font-weight: 600 !important;
background: #fafafa !important;
color: #424242 !important;
}
:deep(.v-data-table td) {
border-bottom: 1px solid #e0e0e0 !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
:deep(.v-data-table) {
font-size: 0.875rem;
}
.v-pagination {
:deep(.v-pagination__item) {
min-width: 32px;
height: 32px;
}
}
}
</style>
@@ -0,0 +1,52 @@
<template>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="elevation-1"
>
<template v-slot:item.pilih="{ item }">
<v-checkbox
:model-value="isSelected(item.id)"
@change="toggleService(item.id)"
color="primary"
></v-checkbox>
</template>
<template v-slot:item.no="{ item }">
{{ item.no }}
</template>
</v-data-table>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
headers: Array,
items: Array,
selectedItems: Array,
});
const emit = defineEmits(['update:selectedItems']);
const isSelected = (id) => props.selectedItems.includes(id);
const toggleService = (id) => {
const newSelection = isSelected(id)
? props.selectedItems.filter(serviceId => serviceId !== id)
: [...props.selectedItems, id];
emit('update:selectedItems', newSelection);
};
</script>
<style scoped>
.v-data-table {
border-radius: 8px;
}
.v-checkbox {
justify-content: center;
}
</style>
@@ -0,0 +1,320 @@
<template>
<div>
<!-- Filter Section -->
<v-row class="mb-4">
<v-col cols="12" class="d-flex align-center flex-wrap ml-4">
<div style="width: 200px;" class="mr-4">
<v-text-field
v-model="filterDate"
type="date"
label="Tanggal"
density="compact"
hide-details
variant="outlined"
/>
</div>
<div style="width: 150px;" class="mr-4">
<v-select
v-model="filterStatus"
:items="statusOptions"
label="Status"
density="compact"
hide-details
variant="outlined"
/>
</div>
<v-btn color="primary" @click="searchData" class="mr-3">
SEARCH
</v-btn>
<v-btn color="success" variant="outlined" @click="exportLaporan" class="mr-3">
Laporan Pasien
</v-btn>
<v-btn color="info" variant="outlined" @click="exportLaporanPerKlinik">
Laporan Pasien Per Klinik
</v-btn>
</v-col>
</v-row>
<!-- Table Controls -->
<v-row class="mb-3">
<v-col cols="12" md="6" class="d-flex align-center">
<span class="mr-2 pa-4">Show</span>
<div style="width: 100px;">
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
hide-details
variant="outlined"
/>
</div>
<span class="ml-2">entries</span>
</v-col>
<v-col cols="12" md="6" class="d-flex justify-end">
<div class="d-flex align-center pa-4">
<span class="mr-2">Search:</span>
<v-text-field
v-model="search"
density="compact"
hide-details
style="min-width: 200px"
variant="outlined"
/>
</div>
</v-col>
</v-row>
<!-- Data Table -->
<v-data-table
:headers="headers"
:items="paginatedItems"
:search="search"
hide-default-footer
class="elevation-1"
>
<!-- Custom Status Column -->
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
>
{{ item.status }}
</v-chip>
</template>
<!-- Custom Keterangan Column -->
<template v-slot:item.keterangan="{ item }">
<span :class="getKeteranganClass(item.keterangan)">
{{ item.keterangan }}
</span>
</template>
<!-- No Data -->
<template #no-data>
<div class="text-center pa-4">
No data available in table
</div>
</template>
</v-data-table>
<!-- Pagination -->
<div class="d-flex justify-space-between align-center pa-4">
<div class="text-body-2 text-grey-darken-1">
Showing {{ currentPageStart }} to {{ currentPageEnd }} of {{ totalFilteredItems }} entries
</div>
<div class="d-flex align-center">
<v-btn
:disabled="currentPage === 1"
@click="previousPage"
variant="text"
size="small"
>
Previous
</v-btn>
<template v-for="page in visiblePages" :key="page">
<v-btn
v-if="page !== '...'"
:color="page === currentPage ? 'primary' : ''"
:variant="page === currentPage ? 'flat' : 'text'"
@click="goToPage(page)"
size="small"
class="mx-1"
min-width="40"
>
{{ page }}
</v-btn>
<span v-else class="mx-1">...</span>
</template>
<v-btn
:disabled="currentPage === totalPages"
@click="nextPage"
variant="text"
size="small"
>
Next
</v-btn>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['search', 'export-laporan', 'export-laporan-per-klinik'])
// Filter states
const filterDate = ref('')
const filterStatus = ref('Semua')
const search = ref('')
// Pagination states
const itemsPerPage = ref(10)
const currentPage = ref(1)
// Status options
const statusOptions = ['Semua', 'Online', 'Offline', 'Tunggu Daftar', 'Barcode']
// Table headers
const headers = [
{ title: 'No', key: 'no', sortable: false, width: '60px' },
{ title: 'Tgl Periksa', key: 'tglPeriksa', sortable: true },
{ title: 'NIK', key: 'nik', sortable: true },
{ title: 'RM', key: 'rm', sortable: true },
{ title: 'Barcode', key: 'barcode', sortable: true },
{ title: 'No Antrian', key: 'noAntrian', sortable: true },
{ title: 'Klinik', key: 'klinik', sortable: true },
{ title: 'First Name Last Name', key: 'fullName', sortable: true },
{ title: 'Shift', key: 'shift', sortable: true },
{ title: 'Pembayaran', key: 'pembayaran', sortable: true },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Status', key: 'status', sortable: true }
]
// Computed properties
const filteredItems = computed(() => {
let filtered = props.items
// Filter by date
if (filterDate.value) {
filtered = filtered.filter(item =>
item.tglPeriksa === filterDate.value
)
}
// Filter by status
if (filterStatus.value && filterStatus.value !== 'Semua') {
filtered = filtered.filter(item =>
item.status === filterStatus.value
)
}
return filtered
})
const totalFilteredItems = computed(() => filteredItems.value.length)
const totalPages = computed(() => Math.ceil(totalFilteredItems.value / itemsPerPage.value))
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredItems.value.slice(start, end).map((item, index) => ({
...item,
no: start + index + 1
}))
})
const currentPageStart = computed(() =>
totalFilteredItems.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1
)
const currentPageEnd = computed(() =>
Math.min(currentPage.value * itemsPerPage.value, totalFilteredItems.value)
)
const visiblePages = computed(() => {
const pages = []
const total = totalPages.value
const current = currentPage.value
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
if (current <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
pages.push('...')
pages.push(total)
} else if (current >= total - 3) {
pages.push(1)
pages.push('...')
for (let i = total - 4; i <= total; i++) {
pages.push(i)
}
} else {
pages.push(1)
pages.push('...')
for (let i = current - 1; i <= current + 1; i++) {
pages.push(i)
}
pages.push('...')
pages.push(total)
}
}
return pages
})
// Methods
const searchData = () => {
currentPage.value = 1
emit('search', {
date: filterDate.value,
status: filterStatus.value
})
}
const exportLaporan = () => {
emit('export-laporan')
}
const exportLaporanPerKlinik = () => {
// Navigate to laporan pasien per klinik page
navigateTo('/laporan-pasien-per-klinik')
}
const getStatusColor = (status) => {
const colorMap = {
'Online': 'success',
'Offline': 'error',
'Tunggu Daftar': 'warning',
'Barcode': 'info'
}
return colorMap[status] || 'default'
}
const getKeteranganClass = (keterangan) => {
return keterangan === 'Online' ? 'text-success' : ''
}
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++
}
}
const goToPage = (page) => {
currentPage.value = page
}
// Watchers
watch(itemsPerPage, () => {
currentPage.value = 1
})
watch([filterDate, filterStatus], () => {
currentPage.value = 1
})
</script>
<style scoped>
.text-success {
color: rgb(76, 175, 80) !important;
}
</style>
@@ -0,0 +1,129 @@
<template>
<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="patient" class="patient-details" :class="`patient-details-${theme}`">
<div class="patient-number mb-2">{{ patient.noAntrian.split(" |")[0] }}</div>
<div class="patient-info-text mb-3">
<div>{{ patient.barcode }}</div>
<div>{{ patient.klinik }} | {{ patient.pembayaran }}</div>
</div>
<div class="action-grid">
<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 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 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 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>
</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>
</template>
<script setup>
defineProps({
patient: {
type: Object,
default: null
},
theme: {
type: String,
default: 'primary', // 'primary', 'secondary', or 'accent'
validator: (value) => ['primary', 'secondary', 'accent'].includes(value)
},
changeButtonText: {
type: String,
default: 'Ubah Klinik'
}
});
defineEmits(['action', 'change-klinik']);
</script>
<style scoped lang="scss">
.current-patient-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 {
border-radius: 8px;
padding: 16px;
}
.patient-details-primary {
background: linear-gradient(135deg, var(--color-primary-200) 0%, var(--color-primary-300) 100%);
}
.patient-details-secondary {
background: linear-gradient(135deg, var(--color-secondary-200) 0%, var(--color-secondary-300) 100%);
}
.patient-details-accent {
background: linear-gradient(135deg, var(--color-accent-200) 0%, var(--color-accent-300) 100%);
}
.patient-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;
}
@media (max-width: 960px) {
.action-grid {
grid-template-columns: 1fr;
}
}
</style>
+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>
@@ -0,0 +1,143 @@
<template>
<v-card class="queue-actions-card" elevation="0">
<v-card-text class="pa-4">
<div class="section-label mb-3">PANGGIL ANTREAN</div>
<div class="quota-info mb-3">
<div class="quota-item">
<span class="quota-label">Kuota</span>
<span class="quota-value">{{ totalQuota }}</span>
</div>
<div class="quota-item">
<span class="quota-label">Tersedia</span>
<span class="quota-value quota-available">{{ availableQuota }}</span>
</div>
<div class="quota-item full-width">
<v-progress-linear
:model-value="quotaPercentage"
color="success-600"
height="6"
rounded
class="mt-1"
/>
<span class="quota-used">Terpakai: {{ usedQuota }}</span>
</div>
</div>
<div class="call-buttons">
<v-btn
color="success-600"
variant="outlined"
@click="$emit('call', 1)"
:disabled="!hasNext"
>
1
</v-btn>
<v-btn color="secondary-600" variant="outlined" @click="$emit('call', 5)">5</v-btn>
<v-btn color="primary-600" variant="outlined" @click="$emit('call', 10)">10</v-btn>
<v-btn color="danger-600" variant="outlined" @click="$emit('call', 20)">20</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
totalQuota: {
type: Number,
default: 150
},
usedQuota: {
type: Number,
default: 0
},
hasNext: {
type: Boolean,
default: false
}
});
const availableQuota = computed(() => props.totalQuota - props.usedQuota);
const quotaPercentage = computed(() => (props.usedQuota / props.totalQuota) * 100);
defineEmits(['call']);
</script>
<style scoped lang="scss">
.queue-actions-card {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.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;
}
@media (max-width: 960px) {
.call-buttons {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -0,0 +1,284 @@
<template>
<v-card class="patient-data-container" elevation="0">
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">DATA PASIEN</div>
<div class="filters">
<v-chip-group v-model="selectedStatusModel" mandatory class="status-filter">
<v-chip
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
:class="{ 'active-chip': selectedStatusModel === status.value }"
>
{{ status.label }} ({{ status.count }})
</v-chip>
</v-chip-group>
<v-text-field
v-model="searchModel"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
</div>
</div>
<!-- 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>
<!-- 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>
<!-- Pagination -->
<div v-if="filteredAndSearchedItems.length > itemsPerPage" class="pagination-container mt-4">
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
rounded="circle"
/>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue';
import PatientCard from './PatientCard.vue';
const props = defineProps({
items: {
type: Array,
default: () => []
},
selectedStatus: {
type: String,
default: 'all'
},
searchQuery: {
type: String,
default: ''
},
diLoketCount: {
type: Number,
default: 0
},
terlambatCount: {
type: Number,
default: 0
},
pendingCount: {
type: Number,
default: 0
},
itemsPerPage: {
type: Number,
default: 9
},
statusLabels: {
type: Object,
default: () => ({
all: 'Semua',
diloket: 'Di Loket',
terlambat: 'Terlambat',
pending: 'Pending'
})
}
});
const emit = defineEmits(['update:selectedStatus', 'update:searchQuery', 'action']);
const currentPage = ref(1);
const selectedStatusModel = computed({
get: () => props.selectedStatus,
set: (value) => {
currentPage.value = 1; // Reset to first page on filter change
emit('update:selectedStatus', value);
}
});
const searchModel = computed({
get: () => props.searchQuery,
set: (value) => {
currentPage.value = 1; // Reset to first page on search
emit('update:searchQuery', value);
}
});
const statusOptions = computed(() => [
{ value: 'all', label: props.statusLabels.all, count: props.items.length },
{ value: 'diloket', label: props.statusLabels.diloket, count: props.diLoketCount },
{ value: 'terlambat', label: props.statusLabels.terlambat, count: props.terlambatCount },
{ value: 'pending', label: props.statusLabels.pending, count: props.pendingCount }
]);
const filteredItems = computed(() => {
if (selectedStatusModel.value === 'all') return props.items;
return props.items.filter(p => p.status === selectedStatusModel.value);
});
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 totalPages = computed(() =>
Math.ceil(filteredAndSearchedItems.value.length / props.itemsPerPage)
);
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * props.itemsPerPage;
const end = start + props.itemsPerPage;
return filteredAndSearchedItems.value.slice(start, end);
});
const handleAction = (patient, action) => {
emit('action', patient, action);
};
</script>
<style scoped lang="scss">
.patient-data-container {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.data-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
color: var(--color-neutral-600);
transition: all 0.2s ease;
&:hover {
background: var(--color-neutral-300);
}
}
.status-filter .v-chip.active-chip {
background: var(--color-secondary-600);
color: var(--color-neutral-100);
border-color: var(--color-secondary-600);
}
.search-field {
max-width: 300px;
}
.patient-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-text {
font-size: 16px;
font-weight: 600;
color: var(--color-neutral-700);
}
.empty-subtext {
font-size: 13px;
color: var(--color-neutral-600);
margin-top: 4px;
}
.pagination-container {
display: flex;
justify-content: center;
padding-top: 16px;
border-top: 1px solid var(--color-neutral-400);
}
:deep(.v-pagination__item) {
font-weight: 600;
}
@media (max-width: 960px) {
.filters {
flex-direction: column;
align-items: stretch;
}
.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>
@@ -1,3 +1,4 @@
<!-- components/AppBar.vue -->
<template>
<v-app-bar app color="#ff9248" dark>
<v-app-bar-nav-icon @click="emit('toggle-rail')"></v-app-bar-nav-icon>
+158
View File
@@ -0,0 +1,158 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
offset="8"
origin="bottom right"
transition="slide-y-transition"
>
<template v-slot:activator="{ props: menuProps }">
<div
v-bind="menuProps"
class="d-flex align-center cursor-pointer pa-2 rounded-lg hover-bg"
>
<v-avatar size="40">
<v-img
:src="user.picture"
:alt="`${user.name} Profile`"
></v-img>
<v-badge
dot
color="orange"
location="bottom right"
offset-x="2"
offset-y="2"
></v-badge>
</v-avatar>
<div v-show="!rail" class="ml-3 flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">
{{ user.name }}
</div>
<div class="text-caption text-grey">
{{ user.email }}
</div>
</div>
<v-btn v-show="!rail" icon size="small" variant="text">
<v-icon size="20">mdi-dots-vertical</v-icon>
</v-btn>
</div>
</template>
<v-card width="300" elevation="8" rounded="lg">
<v-card-text class="pa-4">
<div class="d-flex align-center mb-4">
<v-avatar size="48">
<v-img :src="user.picture"></v-img>
</v-avatar>
<div class="ml-3">
<div class="text-subtitle-1 font-weight-bold">
{{ user.name }}
</div>
<div class="text-caption text-grey">
{{ user.email }}
</div>
</div>
</div>
<v-list density="compact" class="pa-0">
<v-list-item
prepend-icon="mdi-cog-outline"
class="px-2 rounded-lg"
link
@click="handleAction('setting')"
>
<v-list-item-title class="text-body-2">Setting</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-help-circle-outline"
class="px-2 rounded-lg"
link
@click="handleAction('help')"
>
<v-list-item-title class="text-body-2">Bantuan</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
prepend-icon="mdi-logout"
class="px-2 rounded-lg text-red"
link
@click="signOut"
:disabled="isLoggingOut"
>
<v-list-item-title class="text-body-2">
{{ isLoggingOut ? 'Log out...' : 'Log out' }}
</v-list-item-title>
</v-list-item>
</v-list>
<div class="text-caption text-grey mt-3 text-center">
v2.5.18 · Terms & Conditions
</div>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
// --- PROPS & EMITS ---
const props = defineProps({
user: {
type: Object,
// Diubah menjadi required: false dengan default yang aman
required: false,
default: () => ({
name: 'Guest',
email: '[email protected]',
picture: 'https://i.pravatar.cc/150?img=33'
})
},
rail: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['logout']); // Hanya emit logout
// --- STATE ---
const menu = ref(false);
const isLoggingOut = ref(false);
// --- METHODS ---
const signOut = () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
// Memicu event untuk ditangani oleh komponen induk (SideBar)
emit('logout');
// Biarkan parent yang mengurus redirect/state global
setTimeout(() => isLoggingOut.value = false, 1000);
};
const handleAction = (action) => {
console.log(`[PopupSidebar] Action: ${action} triggered.`);
// TODO: Tambahkan logika navigasi/fungsi di sini, misal: router.push('/settings')
menu.value = false;
};
</script>
<style scoped>
/* Tambahkan kembali style yang relevan untuk aktivator di sini */
.hover-bg:hover {
background-color: #f5f5f5;
}
.cursor-pointer {
cursor: pointer;
}
.text-red {
color: rgb(244, 67, 54) !important;
}
</style>
+416
View File
@@ -0,0 +1,416 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
location="top end"
origin="bottom right"
transition="scale-transition"
open-on-hover
>
<template v-slot:activator="{ props: menuProps }">
<v-list-item
v-if="!rail"
v-bind="menuProps"
:title="user?.name || user?.preferred_username || 'User'"
:subtitle="user?.email || 'No email'"
class="pa-3 ma-1 rounded-xl profile-activator"
link
>
<template v-slot:prepend>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</template>
<template v-slot:append>
<v-icon size="20" class="chevron-icon">mdi-menu-down</v-icon>
</template>
</v-list-item>
<v-btn
v-else
icon
v-bind="menuProps"
size="large"
variant="flat"
class="avatar-btn"
:title="user?.name || 'Profile'"
>
<div class="avatar-wrapper">
<v-avatar size="44" class="avatar-glow">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-dot"></div>
</div>
</v-btn>
</template>
<v-card class="profile-card rounded-xl elevation-12" width="340">
<!-- Decorative Header -->
<div class="card-header">
<div class="header-pattern"></div>
<div class="header-content pa-6 text-center">
<div class="avatar-container mb-4">
<v-avatar size="90" class="profile-avatar">
<v-img
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<div class="status-badge">
<v-icon size="12" color="white">mdi-check</v-icon>
</div>
</div>
<h3 class="text-h6 font-weight-bold text-white mb-1">
{{ user?.name || user?.preferred_username || 'User' }}
</h3>
<p class="text-body-2 text-white opacity-90 mb-2">
{{ user?.email || 'No email' }}
</p>
<v-chip
size="small"
class="glass-chip"
prepend-icon="mdi-identifier"
>
{{ user?.id?.substring(0, 10) }}...
</v-chip>
</div>
</div>
<!-- Menu Items -->
<v-card-text class="pa-4">
<div class="menu-grid">
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('profile')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="blue-darken-2">mdi-account-circle</v-icon>
</div>
<div class="text-caption font-weight-medium">Profil</div>
</div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('account')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" color="orange-darken-2">mdi-cog-outline</v-icon>
</div>
<div class="text-caption font-weight-medium">Pengaturan</div>
</div>
<div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('darkMode')"
>
<div class="tile-icon-wrapper mb-2 mx-auto">
<v-icon size="24" :color="darkMode ? 'deep-purple-darken-2' : 'amber-darken-2'">
{{ darkMode ? 'mdi-moon-waning-crescent' : 'mdi-white-balance-sunny' }}
</v-icon>
</div>
<div class="text-caption font-weight-medium">
{{ darkMode ? 'Gelap' : 'Terang' }}
</div>
</div>
</div>
<v-divider class="my-4"></v-divider>
<!-- Quick Actions -->
<div class="quick-actions">
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('notifications')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-bell-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Notifikasi
</v-list-item-title>
<template v-slot:append>
<v-badge color="orange-darken-2" content="3" inline></v-badge>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('help')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-help-circle-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Bantuan & Dukungan
</v-list-item-title>
</v-list-item>
</div>
<v-divider class="my-4"></v-divider>
<!-- Logout Button -->
<v-btn
block
class="rounded-lg logout-btn"
variant="flat"
color="error"
prepend-icon="mdi-logout"
@click="signOut"
:disabled="isLoggingOut"
:loading="isLoggingOut"
>
{{ isLoggingOut ? 'Keluar...' : 'Keluar' }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
const props = defineProps({
user: {
type: Object,
required: true
},
rail: {
type: Boolean,
required: true
}
})
const menu = ref(false);
const darkMode = ref(false);
const isLoggingOut = ref(false);
const emit = defineEmits(['logout']);
const signOut = async () => {
if (isLoggingOut.value) return;
isLoggingOut.value = true;
menu.value = false;
try {
emit('logout');
} finally {
isLoggingOut.value = false;
}
};
const handleAction = (action) => {
switch(action) {
case 'account':
navigateTo('/Profile/Pengaturan')
break;
case 'profile':
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
}
menu.value = false;
};
</script>
<style scoped>
.profile-activator {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border: 1px solid rgba(25, 118, 210, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-activator:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.15) 0%, rgba(245, 124, 0, 0.15) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-2px);
}
.avatar-wrapper {
position: relative;
}
.avatar-glow {
border: 3px solid white;
box-shadow: 0 0 20px rgba(25, 118, 210, 0.4);
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 2px solid white;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
.chevron-icon {
transition: transform 0.3s ease;
}
.profile-activator:hover .chevron-icon {
transform: translateY(2px);
}
.avatar-btn {
background: transparent !important;
}
.profile-card {
overflow: hidden;
border: 1px solid rgba(25, 118, 210, 0.1);
}
.card-header {
position: relative;
background: linear-gradient(135deg, #1976d2 0%, #fb8c00 100%);
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.header-content {
position: relative;
z-index: 1;
}
.avatar-container {
position: relative;
display: inline-block;
}
.profile-avatar {
border: 4px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.status-badge {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.menu-tile {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
border: 1px solid rgba(25, 118, 210, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.menu-tile:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.1) 0%, rgba(245, 124, 0, 0.1) 100%);
border-color: rgba(25, 118, 210, 0.3);
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.tile-icon-wrapper {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.action-item {
transition: all 0.2s ease;
background: transparent;
}
.action-item:hover {
background: linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(245, 124, 0, 0.05) 100%);
transform: translateX(4px);
}
.logout-btn {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
}
.logout-btn:hover {
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
}
.cursor-pointer {
cursor: pointer;
}
.opacity-90 {
opacity: 0.9;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<template>
<v-navigation-drawer
:model-value="drawer"
:rail="rail"
permanent
app
class="bg-white d-flex flex-column"
@update:model-value="emit('update:drawer', $event)"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<v-list-item class="py-4 px-2" :class="{ 'text-center': rail }">
<div class="d-flex align-center" :class="{ 'justify-center': rail }">
<img
src="/Antrean Logo.png"
alt="Antrean Logo"
style="width: 72px; height: 72px; object-fit: contain"
/>
<v-list-item-title
v-if="!rail"
class="ml-2 text-h6 font-weight-bold text-blue-grey-darken-4"
>
<span class="text-orange-darken-2">Antrean</span> RSSA
</v-list-item-title>
</div>
</v-list-item>
<v-divider></v-divider>
<v-list
density="compact"
nav
class="mt-2 flex-grow-1"
v-model:opened="openedGroups"
>
<template v-for="item in items" :key="item.name">
<v-list-group v-if="item.children" :value="item.name" :disabled="rail">
<template v-slot:activator="{ props: groupProps }">
<v-list-item
v-bind="groupProps"
:prepend-icon="item.icon"
:title="item.name"
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
</template>
<v-list-item
v-for="child in item.children"
:key="child.name"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="pl-8"
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
</v-list-group>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="tooltipProps"
:prepend-icon="item.icon"
:title="item.name"
:to="item.path"
link
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
<v-divider></v-divider>
<v-list v-if="user" nav density="compact" class="pa-2 flex-shrink-0">
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
</v-list>
<v-list v-else nav density="compact" class="flex-shrink-0">
<v-list-item
@click="redirectToLogin"
prepend-icon="mdi-login"
title="Login"
link
color="blue-grey-darken-4"
></v-list-item>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref, watch } from "vue";
import { navigateTo } from "#app";
import ProfilePopup from "./ProfilePopup.vue";
import { useAuth } from "~/composables/useAuth";
const { user, logout, checkAuth } = useAuth();
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
const props = defineProps({
items: {
type: Array as () => NavItem[],
required: true,
},
rail: {
type: Boolean,
required: true,
},
drawer: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:drawer", "toggle-rail"]);
const openedGroups = ref<string[]>([]);
watch(
() => props.rail,
(newRailState) => {
if (newRailState === true) {
openedGroups.value = [];
}
}
);
const isHovering = ref(false);
const handleMouseEnter = () => {
if (props.rail) {
isHovering.value = true;
emit("toggle-rail");
}
};
const handleMouseLeave = () => {
if (isHovering.value) {
isHovering.value = false;
emit("toggle-rail");
}
};
// --- AUTH LOGIC (Kept the same) ---
const handleLogout = async () => {
console.log("🚪 SideBar logout initiated...");
try {
await logout();
} catch (error) {
console.error("❌ SideBar logout error:", error);
}
};
const redirectToLogin = () => {
navigateTo("/LoginPage");
};
onMounted(async () => {
await checkAuth();
});
</script>
<style scoped>
/* No specific overrides needed */
</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>
+36
View File
@@ -0,0 +1,36 @@
// /composables/usePermissions.ts
export const usePermission = () => {
/**
* Extract group and role from path
* Example: "/Instalasi STIM/Devops/Superadmin"
* → { group: "STIM", role: "superadmin" }
*/
const parsePath = (path: string) => {
const parts = path.split("/").filter(Boolean);
const group = parts[1] || ""; // "STIM"
const role = parts.at(-1)?.toLowerCase() || ""; // "superadmin"
return { group, role };
};
/**
* Fetch permission from backend API
*/
const fetchPermission = async (path: string) => {
const { group, role } = parsePath(path);
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`;
const { data, error } = await useFetch(url, {
method: "GET",
});
if (error.value) {
console.error("❌ Gagal mengambil permission:", error.value);
throw error.value;
}
console.log("✅ Permission data:", data.value);
return data.value;
};
return { parsePath, fetchPermission };
};
+183
View File
@@ -0,0 +1,183 @@
// composables/useQueue.js
import { ref, computed } from "vue";
import { useQueueStore } from "../stores/queueStore";
export const useQueue = (adminType = "loket") => {
const queueStore = useQueueStore();
// Local state
const snackbar = ref(false);
const snackbarText = ref("");
const snackbarColor = ref("success");
// Dialog states
const showKlinikDialog = ref(false);
const showPenunjangDialog = ref(false);
const showChangeKlinikDialog = ref(false);
const klinikSearch = ref("");
const penunjangSearch = ref("");
const changeKlinikSearch = ref("");
const selectedPatientForPenunjang = ref(null);
// FIXED: Get stage patients once, then derive others from it
const stagePatients = computed(() => {
// Don't use .value here - getPatientsByStage returns a computed already
const patients = queueStore.getPatientsByStage(adminType);
return patients.value;
});
// Computed from store - filtered by stage
const currentProcessingPatient = computed(() => {
return queueStore.currentProcessingPatient[adminType];
});
// Derive from stagePatients
const diLoketPatients = computed(() => stagePatients.value.diLoket || []);
const terlambatPatients = computed(() => stagePatients.value.terlambat || []);
const pendingPatients = computed(() => stagePatients.value.pending || []);
const waitingPatients = computed(() => stagePatients.value.waiting || []);
const nextPatient = computed(() => {
return waitingPatients.value[0] || null;
});
// Total pasien hanya untuk stage admin ini
const totalPasien = computed(() => {
const total = queueStore.getTotalPasienByStage(adminType);
return total.value;
});
const quotaUsed = computed(() => queueStore.quotaUsed);
// Filtered lists
const filteredKliniks = computed(() => {
if (!klinikSearch.value) return queueStore.kliniks;
return queueStore.kliniks.filter((k) =>
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
);
});
const filteredPenunjangs = computed(() => {
if (!penunjangSearch.value) return queueStore.penunjangs;
return queueStore.penunjangs.filter((p) =>
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
);
});
const filteredChangeKliniks = computed(() => {
if (!changeKlinikSearch.value) return queueStore.kliniks;
return queueStore.kliniks.filter((k) =>
k.name.toLowerCase().includes(changeKlinikSearch.value.toLowerCase())
);
});
// Methods
const showSnackbar = (text, color = "success") => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
const callNext = () => {
const result = queueStore.callNext(adminType);
showSnackbar(result.message, result.success ? "success" : "warning");
};
const callMultiplePatients = (count) => {
const result = queueStore.callMultiplePatients(count, adminType);
showSnackbar(result.message, result.success ? "success" : "warning");
};
const processPatient = (patient, action) => {
const result = queueStore.processPatient(patient, action, adminType);
let color = "success";
if (action === "terlambat") color = "warning";
else if (action === "pending") color = "info";
showSnackbar(result.message, color);
};
const selectKlinik = (klinik) => {
const result = queueStore.createAntreanKlinik(klinik, adminType);
showSnackbar(result.message, "success");
showKlinikDialog.value = false;
};
const selectPenunjang = (penunjang) => {
const result = queueStore.createAntreanPenunjang(
penunjang,
selectedPatientForPenunjang.value,
adminType
);
showSnackbar(result.message, "success");
showPenunjangDialog.value = false;
selectedPatientForPenunjang.value = null;
};
const openPenunjangDialog = (patient = null) => {
selectedPatientForPenunjang.value = patient;
showPenunjangDialog.value = true;
};
const changeKlinik = (klinik) => {
if (currentProcessingPatient.value) {
const result = queueStore.changeKlinik(
currentProcessingPatient.value,
klinik,
adminType
);
showSnackbar(result.message, result.success ? "success" : "error");
showChangeKlinikDialog.value = false;
}
};
const getRowClass = (item) => {
if (item.status === "current") {
return "text-success font-weight-bold";
}
return "";
};
return {
// State
snackbar,
snackbarText,
snackbarColor,
showKlinikDialog,
showPenunjangDialog,
showChangeKlinikDialog,
klinikSearch,
penunjangSearch,
changeKlinikSearch,
selectedPatientForPenunjang,
// Computed
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
waitingPatients,
nextPatient,
totalPasien,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
filteredChangeKliniks,
stagePatients,
// Methods
showSnackbar,
callNext,
callMultiplePatients,
processPatient,
selectKlinik,
selectPenunjang,
openPenunjangDialog,
changeKlinik,
getRowClass,
};
};
+286
View File
@@ -0,0 +1,286 @@
<template>
<v-app>
<!-- <LazyAppBar @toggle-rail="rail = !rail" /> -->
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
app
color="white"
width="280"
rail-width="72"
>
<div
class="pa-4 d-flex align-center cursor-pointer"
style="height: 64px;"
@click="rail = !rail"
>
<v-icon color="#ff9248" size="32">mdi-hospital-building</v-icon>
<v-toolbar-title v-show="!rail" class="ml-3 text-h6">
Antrean RSSA
</v-toolbar-title>
</div>
<v-divider></v-divider>
<v-list density="default" nav class="py-2">
<v-list-subheader v-show="!rail" class="text-caption text-grey">
OVERVIEW
</v-list-subheader>
<template v-for="item in items" :key="item.title">
<v-menu
v-if="item.children"
open-on-hover
location="end"
:disabled="!rail"
>
<template v-slot:activator="{ props }">
<v-list-group v-if="!rail" :value="item.title">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="item.icon"
:title="item.title"
:active="item.title === currentActiveMenu"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</template>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
:title="child.title"
:active="child.to === currentRoute.path"
rounded="lg"
class="mx-2 my-1 pl-12"
>
</v-list-item>
</v-list-group>
<v-list-item
v-else
v-bind="props"
:prepend-icon="item.icon"
:active="item.title === currentActiveMenu"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</template>
<v-list class="py-2" style="min-width: 200px;">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ item.title }}
</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item
v-for="child in item.children"
:key="child.title"
:to="child.to"
:title="child.title"
:active="child.to === currentRoute.path"
rounded="lg"
class="mx-2"
>
</v-list-item>
</v-list>
</v-menu>
<v-list-item
v-else
:prepend-icon="item.icon"
:title="!rail ? item.title : ''"
:to="item.to"
:active="item.to === currentRoute.path"
rounded="lg"
class="mx-2 my-1"
>
<template v-slot:append v-if="item.badge && !rail">
<v-chip size="x-small" color="primary">{{ item.badge }}</v-chip>
</template>
</v-list-item>
</template>
<v-divider class="my-4"></v-divider>
<v-list-subheader v-show="!rail" class="text-caption text-grey">
ACCOUNT
</v-list-subheader>
<v-list-item
prepend-icon="mdi-cog-outline"
:title="!rail ? 'Settings' : ''"
to="/settings"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
<v-list-item
prepend-icon="mdi-logout"
:title="!rail ? 'Log out' : ''"
rounded="lg"
class="mx-2 my-1"
>
</v-list-item>
</v-list>
<template v-slot:append>
<div class="pa-4 d-flex justify-center align-center">
<v-icon color="primary" size="20">mdi-white-balance-sunny</v-icon>
<v-switch
v-show="!rail"
v-model="darkMode"
hide-details
density="compact"
color="primary"
class="mx-2"
></v-switch>
<v-icon v-show="!rail" size="20">mdi-moon-waning-crescent</v-icon>
</div>
<v-divider></v-divider>
<div class="pa-4">
<div class="d-flex align-center">
<v-avatar color="#ff9248" size="40">
<span class="text-white">AS</span>
</v-avatar>
<div v-show="!rail" class="ml-3 flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">Adam Sulfat</div>
<div class="text-caption text-grey">[email protected]</div>
</div>
<v-btn
v-show="!rail"
icon
size="small"
variant="text"
>
<v-icon size="20">mdi-dots-vertical</v-icon>
</v-btn>
</div>
</div>
</template>
</v-navigation-drawer>
<v-main>
<v-container fluid class="pa-6">
<slot></slot>
</v-container>
</v-main>
<v-footer app class="bg-grey-lighten-4">
<v-container fluid class="py-2">
<v-row no-gutters align="center">
<v-col cols="12" md="6">
<span class="text-caption text-grey-darken-2">
RSUD Dr. Saiful Anwar Malang | Jl. Jaksa Agung Suprapto No. 2 Malang | Telp: 0341-362101
</span>
</v-col>
<v-col cols="12" md="6" class="text-right">
<span class="text-caption text-grey-darken-2">
ITIKom Antrian RSSA Ver. 0.2
</span>
</v-col>
</v-row>
</v-container>
</v-footer>
</v-app>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRoute } from 'vue-router';
// NOTE: Make sure to check if LazyAppBar is still needed,
// as its original purpose might have been to handle the rail toggle.
import AppBar from "../components/AppBar.vue";
// Use auth composable
// Assuming useAuth() is defined elsewhere
// const { user, isAuthenticated, isLoading, checkAuth, logout } = useAuth()
// console.log(user)
// State
const drawer = ref(true);
const rail = ref(false);
const darkMode = ref(false);
// Menu Items
const items = ref([
{ title: "Dashboard", icon: "mdi-view-dashboard-outline", to: "/dashboard" },
{
title: "Setting",
icon: "mdi-cog-outline",
children: [
{ title: "Hak Akses", to: "/setting/hak-akses" },
{ title: "User Login", to: "/setting/user-login" },
{ title: "Master Loket", to: "/setting/master-loket" },
{ title: "Master Klinik", to: "/setting/master-klinik" },
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
{ title: "Screen", to: "/setting/screen" },
],
},
{ title: "Loket Admin", icon: "mdi-account-supervisor-outline", to: "/loket-admin" },
{ title: "Ranap Admin", icon: "mdi-bed-outline", to: "/ranap-admin" },
{
title: "Anjungan",
icon: "mdi-account-box-multiple-outline",
children: [
{ title: "Anjungan", to: "/anjungan/anjungan" },
{ title: "Klinik Ruang", to: "/anjungan/AntrianKlinik"},
],
},
{ title: "Data Pasien", icon: "mdi-account-multiple-outline", to: "/data-pasien" },
]);
const currentRoute = useRoute();
const currentActiveMenu = computed(() => {
const currentItem = items.value.find(item => item.to === currentRoute.path);
if (currentItem) {
return currentItem.title;
}
for (const item of items.value) {
if (item.children) {
const childItem = item.children.find(child => child.to === currentRoute.path);
if (childItem) {
return item.title;
}
}
}
return '';
});
</script>
<style scoped>
/* Added cursor style for the new clickable header */
.cursor-pointer {
cursor: pointer;
}
.v-list-item--active {
background-color: #e8f5e9 !important;
color: #2e7d32 !important;
}
.v-list-item--active :deep(.v-list-item__prepend) {
color: #2e7d32 !important;
}
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: #f5f5f5;
}
.v-navigation-drawer {
border-right: 1px solid #e0e0e0 !important;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<template>
<v-app id="inspire">
<SideBar
:items="navItemsStore.navItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<v-main app>
<slot />
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SideBar from "../components/layout/SideBar.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '~/stores/navItems1';
definePageMeta({
middleware: 'auth'
})
// State for controlling the sidebar
const drawer = ref(true);
const rail = ref(true);
const navItemsStore = useNavItemsStore();
</script>
<style scoped>
/* Global styles for layout */
</style>
-37
View File
@@ -1,37 +0,0 @@
<!-- layouts/default.vue -->
<template>
<v-app id="inspire">
<AppBar @toggle-rail="rail = !rail" />
<SideBar :items="navItemsStore.navItems" v-model:drawer="drawer" :rail="rail" />
<v-main app>
<slot />
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { ref, watchEffect } from "vue";
import AppBar from "../components/AppBar.vue";
import SideBar from "../components/SideBar.vue";
import { useNavItemsStore } from '@/stores/navItems'; // Import the new store
definePageMeta({
middleware: 'auth'
})
const drawer = ref(true);
const rail = ref(true);
const navItemsStore = useNavItemsStore();
// Your logic to check user access and filter the menu can go here
// For example:
// const filteredItems = computed(() => {
// return navItemsStore.navItems.filter(item => userHasAccess(item.path));
// });
</script>
<style scoped>
/* Global styles for layout */
</style>
+19 -73
View File
@@ -1,101 +1,49 @@
// export default defineNuxtRouteMiddleware(async (to) => {
// console.log('🛡️ Auth middleware triggered for:', to.path)
// // Skip middleware on server-side during build/generation
// if (process.server && process.env.NODE_ENV === 'development') {
// console.log('⏭️ Skipping auth check on server-side during development')
// return
// }
// // Allow the login page to handle its own checks
// if (to.path === '/LoginPage') {
// console.log('⏭️ Allowing access to LoginPage')
// return
// }
// // This is the crucial change: check for the authentication signal
// const isAuthRedirect = to.query.authenticated === 'true';
// // If this is a redirect from a successful login, we need to let the route load
// if (isAuthRedirect) {
// console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
// // We navigate to a clean URL to remove the query parameter
// return navigateTo({ path: to.path, query: {} }, { replace: true });
// }
// try {
// console.log('🔍 Checking authentication status...')
// const session = await $fetch<{ user: any } | null>('/api/auth/session').catch(() => null)
// if (session && session.user) {
// console.log('✅ User is authenticated:', session.user.name || session.user.email)
// return
// } else {
// console.log('❌ No valid session found, redirecting to login')
// return navigateTo('/LoginPage')
// }
// } catch (error) {
// console.error('❌ Auth middleware error:', error)
// console.log('🔄 Redirecting to login due to error')
// return navigateTo('/LoginPage')
// }
// })
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
// Import the useAuth composable (it will be auto-imported, but this helps TypeScript/VS Code)
import { useAuth } from '~/composables/useAuth';
// Define the shape of the user object returned by your authentication API.
// This provides type safety for session.user.
interface User {
name?: string | null;
email: string;
// Add other properties from your user object as needed.
}
// Define the shape of the full session object returned by the API.
interface Session {
user: User;
}
// NOTE: We don't need the interfaces here anymore, as useAuth handles the typing.
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
console.log('🛡️ Auth middleware triggered for:', to.path);
// Skip middleware on server-side during development build/generation
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping auth check on server-side during development');
return;
}
// Allow the login page to handle its own checks without redirection loops
if (to.path === '/LoginPage') {
console.log('⏭️ Allowing access to LoginPage');
return;
}
// Skip auth check if it's the development server side render pass.
// Although we still recommend using the checkAuth() composable for better SSR handling.
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping intensive check on server-side during development');
// Using the composable here ensures it is registered correctly, even if we skip the main check
useAuth();
return;
}
// Check for the authentication signal from a successful login redirect
const isAuthRedirect: boolean = to.query.authenticated === 'true';
// If this is a redirect from a successful login, allow the route to load.
// We navigate to a clean URL to remove the query parameter.
if (isAuthRedirect) {
console.log('⏳ Client-side is processing a new login session, allowing the route to load...');
return navigateTo({ path: to.path, query: {} }, { replace: true });
}
try {
console.log('🔍 Checking authentication status...');
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication status using useAuth...');
// Use the defined Session interface to type the fetch response
const session: Session | null = await $fetch<Session>('/api/auth/session').catch(() => null);
// Use the composable's function to check the session
const user = await checkAuth();
// Check if a valid session and user exist using optional chaining
if (session?.user) {
console.log('✅ User is authenticated:', session.user.name || session.user.email);
if (user) {
console.log('✅ User is authenticated:', user.name || user.preferred_username || user.email);
return;
} else {
console.log('❌ No valid session found, redirecting to login');
// If checkAuth fails, it already cleared the user state. Redirect.
return navigateTo('/LoginPage');
}
} catch (error) {
@@ -104,5 +52,3 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
return navigateTo('/LoginPage');
}
});
+66 -31
View File
@@ -1,31 +1,47 @@
// nuxt.config.ts
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
compatibilityDate: "2025-05-15",
devtools: {
enabled: true,
timeline: {
enabled: true,
},
},
modules: [
'@nuxt/content',
'@nuxt/eslint',
'@nuxt/fonts',
'@nuxt/icon',
'@nuxt/image',
'@nuxt/scripts',
'@nuxt/test-utils',
'@nuxt/ui',
'@pinia/nuxt',
// Remove '@sidebase/nuxt-auth' completely
// "@nuxt/content",
"@nuxt/eslint",
"@nuxt/fonts",
"@nuxt/icon",
"@nuxt/image",
"@nuxt/scripts",
"@nuxt/test-utils",
"@nuxt/ui",
"@pinia/nuxt",
"@vesp/nuxt-fontawesome",
"@nuxtjs/google-fonts",
(_options, nuxt) => {
nuxt.hooks.hook('vite:extendConfig', (config) => {
nuxt.hooks.hook("vite:extendConfig", (config) => {
// @ts-expect-error
config.plugins.push(vuetify({ autoImport: true }))
})
config.plugins.push(vuetify({ autoImport: true }));
});
},
],
// Remove the auth configuration completely
// auth: { ... } <- Remove this entire block
fontawesome: {
icons: {
solid: ["dna", "user", "home", "gear"],
regular: ["heart"],
brands: ["github"],
},
},
googleFonts: {
families: {
Inter: [400, 500, 600, 700],
},
display: "swap",
},
runtimeConfig: {
authSecret: process.env.NUXT_AUTH_SECRET,
@@ -33,23 +49,42 @@ export default defineNuxtConfig({
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001' || 'http://localhost:3000'
}
authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
},
},
build: {
transpile: ['vuetify']
transpile: ["vuetify"],
},
css: [
'vuetify/lib/styles/main.sass',
'@mdi/font/css/materialdesignicons.min.css',
"vuetify/lib/styles/main.sass",
"@mdi/font/css/materialdesignicons.min.css",
"~/assets/scss/main.scss",
],
devServer: {
host: "10.10.150.114",
port: 3001,
},
// 'http://10.10.150.114:3001/'
vite: {
ssr: {
noExternal: ['vuetify']
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
additionalData: `
@use "sass:math";
@use "sass:map";
@use "~/assets/scss/_variables.scss" as *;
@use "~/assets/scss/_colors.scss" as *;
`,
},
},
},
plugins: [
vuetify({ autoImport: true })
]
}
})
ssr: {
noExternal: ["vuetify"],
},
},
});
+3371 -4520
View File
File diff suppressed because it is too large Load diff
+13 -1
View File
@@ -4,12 +4,17 @@
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"_command_dev": "nuxt dev -o --host --port 3001",
"_command_dev2": "nuxt dev -o --port 3001",
"dev": "nuxt dev -o --port 3001",
"generate": "nuxt generate",
"preview": "nuxt preview",
"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",
@@ -19,12 +24,16 @@
"@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",
"eslint": "^9.32.0",
"nuxt": "^3.17.7",
"nuxt-qrcode": "^0.4.8",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"typescript": "^5.8.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
@@ -32,6 +41,9 @@
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
"@types/node": "^24.7.2",
"sass": "^1.93.3",
"sass-embedded": "^1.89.2",
"vite-plugin-vuetify": "^2.1.2",
"vuetify": "^3.9.3"
+235
View File
@@ -0,0 +1,235 @@
<template>
<div class="loket-container">
<!-- Compact Header -->
<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 & Queue Actions -->
<v-col cols="12" md="5">
<!-- Current Patient Card -->
<CurrentPatientCard
:patient="currentProcessingPatient"
theme="secondary"
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
/>
<!-- 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 create-btn"
@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"
class="create-btn text-white"
@click="openPenunjangDialog()"
>
<v-icon start size="20">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
</div>
</v-col>
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<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 -->
<SelectionDialog
v-model="showKlinikDialog"
title="Pilih Klinik"
:items="filteredKliniks"
v-model:search-query="klinikSearch"
search-placeholder="Cari klinik..."
@select="selectKlinik"
/>
<!-- Penunjang Dialog -->
<SelectionDialog
v-model="showPenunjangDialog"
title="Pilih Penunjang"
:items="filteredPenunjangs"
v-model:search-query="penunjangSearch"
search-placeholder="Cari penunjang..."
@select="selectPenunjang"
/>
<!-- Change Klinik Dialog -->
<SelectionDialog
v-model="showChangeKlinikDialog"
title="Ubah Klinik"
:items="filteredChangeKliniks"
v-model:search-query="changeKlinikSearch"
search-placeholder="Cari klinik..."
@select="changeKlinik"
/>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
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,
snackbarText,
snackbarColor,
showKlinikDialog,
showPenunjangDialog,
showChangeKlinikDialog,
klinikSearch,
penunjangSearch,
changeKlinikSearch,
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
nextPatient,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
filteredChangeKliniks,
callNext,
callMultiplePatients,
processPatient,
selectKlinik,
selectPenunjang,
openPenunjangDialog,
changeKlinik,
} = useQueue("klinik");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
const selectedStatus = ref("all");
const searchQuery = ref("");
// 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 {
const diLoketList = diLoketPatients.value || [];
const terlambatList = terlambatPatients.value || [];
const pendingList = pendingPatients.value || [];
const diLoket = diLoketList.map(p => ({ ...p, status: 'diloket' }));
const terlambat = terlambatList.map(p => ({ ...p, status: 'terlambat' }));
const pending = pendingList.map(p => ({ ...p, status: 'pending' }));
return [...diLoket, ...terlambat, ...pending];
} catch (error) {
console.error('Error in allPatients computed:', error);
return [];
}
});
const handlePatientAction = (action) => {
if (currentProcessingPatient.value) {
processPatient(currentProcessingPatient.value, action);
}
};
const handleCall = (count) => {
if (count === 1) {
callNext();
} else {
callMultiplePatients(count);
}
};
const handleTableAction = (item, action) => {
processPatient(item, action);
};
</script>
<style scoped lang="scss">
.loket-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
}
.content-grid {
margin: 0 -8px;
}
.content-grid > .v-col {
padding: 8px;
}
.create-buttons .create-btn {
text-transform: none;
font-weight: 600;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
}
</style>
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="loket-container">
<!-- Compact Header -->
<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 & Queue Actions -->
<v-col cols="12" md="5">
<!-- Current Patient Card -->
<CurrentPatientCard
:patient="currentProcessingPatient"
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
/>
<!-- 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-icon start size="20">mdi-hospital-building</v-icon>
Buat Antrean Klinik
</v-btn>
<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>
</div>
</v-col>
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<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 -->
<SelectionDialog
v-model="showKlinikDialog"
title="Pilih Klinik"
:items="filteredKliniks"
v-model:search-query="klinikSearch"
search-placeholder="Cari klinik..."
@select="selectKlinik"
/>
<!-- Penunjang Dialog -->
<SelectionDialog
v-model="showPenunjangDialog"
title="Pilih Penunjang"
:items="filteredPenunjangs"
v-model:search-query="penunjangSearch"
search-placeholder="Cari penunjang..."
@select="selectPenunjang"
/>
<!-- Change Klinik Dialog -->
<SelectionDialog
v-model="showChangeKlinikDialog"
title="Ubah Klinik"
:items="filteredChangeKliniks"
v-model:search-query="changeKlinikSearch"
search-placeholder="Cari klinik..."
@select="changeKlinik"
/>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
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,
snackbarText,
snackbarColor,
showKlinikDialog,
showPenunjangDialog,
showChangeKlinikDialog,
klinikSearch,
penunjangSearch,
changeKlinikSearch,
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
nextPatient,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
filteredChangeKliniks,
callNext,
callMultiplePatients,
processPatient,
selectKlinik,
selectPenunjang,
openPenunjangDialog,
changeKlinik,
} = useQueue("loket");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
const selectedStatus = ref("all");
const searchQuery = ref("");
// Combine all patients with status
const allPatients = computed(() => {
const diLoket = (diLoketPatients.value || []).map(p => ({ ...p, status: 'diloket' }));
const terlambat = (terlambatPatients.value || []).map(p => ({ ...p, status: 'terlambat' }));
const pending = (pendingPatients.value || []).map(p => ({ ...p, status: 'pending' }));
return [...diLoket, ...terlambat, ...pending];
});
const handlePatientAction = (action) => {
if (currentProcessingPatient.value) {
processPatient(currentProcessingPatient.value, action);
}
};
const handleCall = (count) => {
if (count === 1) {
callNext();
} else {
callMultiplePatients(count);
}
};
const handleTableAction = (item, action) => {
processPatient(item, action);
};
</script>
<style scoped lang="scss">
.loket-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
}
.content-grid {
margin: 0 -8px;
}
.content-grid > .v-col {
padding: 8px;
}
.create-buttons .v-btn {
text-transform: none;
font-weight: 600;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
}
</style>
+683
View File
@@ -0,0 +1,683 @@
<!-- pages/AdminPenunjang.vue -->
<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-clipboard-pulse</v-icon>
<div class="header-text">
<h1 class="page-title">Admin Penunjang</h1>
<p class="page-subtitle">{{ currentDate }}</p>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient -->
<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 Ruang
</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>
<!-- 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>
<!-- Create Queue Button -->
<div class="create-buttons mt-3">
<v-btn color="#3b82f6" variant="flat" block size="large" @click="showPenunjangDialog = true">
<v-icon start size="20">mdi-clipboard-pulse</v-icon>
Buat Antrean Penunjang
</v-btn>
</div>
</v-col>
<!-- 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 Penunjang ({{ (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>
</v-col>
</v-row>
<!-- 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 Ruang 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>
<!-- Change Ruang 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 Ruang Penunjang</span>
</v-card-title>
<v-card-text class="pa-4">
<v-text-field
v-model="changeKlinikSearch"
placeholder="Cari ruang..."
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>
<!-- 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>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useQueue } from "../composables/useQueue";
const {
snackbar,
snackbarText,
snackbarColor,
showPenunjangDialog,
showChangeKlinikDialog,
penunjangSearch,
changeKlinikSearch,
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
nextPatient,
quotaUsed,
filteredPenunjangs,
filteredChangeKliniks,
callNext,
callMultiplePatients,
processPatient,
selectPenunjang,
changeKlinik,
} = useQueue("penunjang");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
const selectedStatus = ref("all");
const searchQuery = ref("");
// Combine all patients with status - Fixed to prevent infinite loop
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' }));
return [...diLoket, ...terlambat, ...pending];
} catch (error) {
console.error('Error in allPatients computed:', error);
return [];
}
});
// 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 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 Penunjang",
terlambat: "Terlambat",
pending: "Pending"
};
return labels[status] || status;
};
</script>
<style scoped>
.loket-container {
background: #f8fafc;
min-height: 100vh;
padding: 16px;
}
.page-header {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
.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;
}
.content-grid > .v-col {
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, #f3e8ff 0%, #e9d5ff 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 {
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: #8b5cf6;
background: #f5f3ff;
}
.klinik-name,
.penunjang-name {
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.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>
+242
View File
@@ -0,0 +1,242 @@
<template>
<v-container fluid class="bg-grey-lighten-4 pa-4">
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Admin Anjungan</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
</div>
</div>
</div>
</div>
<v-card flat>
<v-card-text>
<v-row align="center">
<v-col cols="12" md="4">
<v-text-field
label="Barcode"
placeholder="Masukkan Barcode"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-chip color="#B71C1C" class="text-caption" text-color="white">
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
J20073010005 "Hiraukan huruf 'J' nya")
</v-chip>
</v-col>
<v-col cols="12" md="2">
<v-btn block color="#ff9248" style="color: white"
>Pendaftaran Online</v-btn
>
</v-col>
</v-row>
</v-card-text>
<v-divider :thickness="5" color="deep-orange-darken-4"></v-divider>
<TabelData
:headers="lateHeaders"
:items="lateVisitors"
title="DATA PENGUNJUNG TERLAMBAT"
/>
<v-divider :thickness="5" color="deep-orange-darken-4"></v-divider>
<TabelData
:headers="mainHeaders"
:items="mainPatients"
title="DATA PENGUNJUNG"
>
<template v-slot:actions="{ item }">
<div class="d-flex ga-1">
<v-btn
small
color="#ff9248"
class="d-flex flex-row"
variant="flat"
style="color: white"
>PASIEN</v-btn
>
<v-btn
small
color="grey-lighten-3"
class="d-flex flex-row"
variant="flat"
>PENGANTAR</v-btn
>
<v-btn small color="info" class="d-flex flex-row" variant="flat"
>ByPass</v-btn
>
</div>
</template>
</TabelData>
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { ref } from "vue";
import TabelData from "../components/TabelData.vue"; // Pastikan path-nya benar
// Ini adalah data yang akan menjadi "single source of truth"
// untuk tabel Anda. Data ini dikirim sebagai props ke komponen anak.
const mainHeaders = ref([
{ title: "No", value: "no", sortable: false },
{ title: "Tgl Daftar", value: "tglDaftar", sortable: true },
{ title: "RM", value: "rm", sortable: true },
{ title: "Barcode", value: "barcode", sortable: true },
{ title: "No Antrian", value: "noAntrian", sortable: true },
{ title: "No Klinik", value: "noKlinik", sortable: true },
{ title: "Shift", value: "shift", sortable: true },
{ title: "Klinik", value: "klinik", sortable: true },
{ title: "Pembayaran", value: "pembayaran", sortable: true },
{ title: "Masuk", value: "masuk", sortable: true },
{ title: "Aksi", value: "aksi", sortable: false },
]);
const mainPatients = ref([
{
no: 1,
tglDaftar: "12:49",
rm: "250811100163",
noAntrian: "UM1001 | Online - 250811100163",
noKlinik: "THT",
barcode: "2321232",
shift: "Shift 1",
klinik: "KANDUNGAN",
pembayaran: "UMUM",
masuk: "TIDAK",
status: "current",
},
{
no: 2,
tglDaftar: "18:23",
rm: "42081123200199",
noAntrian: "UM1001 | Online - 250811100163",
noKlinik: "THT",
barcode: "2321985",
shift: "Shift 1",
klinik: "DALAM",
pembayaran: "UMUM",
masuk: "TIDAK",
status: "current",
},
{
no: 3,
tglDaftar: "02:19",
rm: "15092710084",
noAntrian: "UM1001 | Online - 250811100163",
noKlinik: "THT",
barcode: "2321777",
shift: "Shift 1",
klinik: "ANAK",
pembayaran: "UMUM",
masuk: "TIDAK",
status: "current",
},
{
no: 4,
tglDaftar: "10:09",
rm: "250254310011",
noAntrian: "UM1001 | Online - 250811100163",
noKlinik: "THT",
barcode: "2321298",
shift: "Shift 1",
klinik: "JANTUNG",
pembayaran: "UMUM",
masuk: "TIDAK",
status: "current",
},
]);
const lateHeaders = ref([
{ title: "No", value: "no", sortable: false },
// Tambahkan headers spesifik untuk tabel ini jika berbeda
]);
const lateVisitors = ref([
// Tambahkan data spesifik untuk tabel ini jika ada
]);
// ... Sisa kode lainnya yang tidak terkait dengan tabel ...
const items = ref([
{ title: "Dashboard", icon: "mdi-view-dashboard", to: "/dashboard" },
{
title: "Setting",
icon: "mdi-cog",
children: [
{ title: "Hak Akses", to: "/setting/hak-akses" },
{ title: "User Login", to: "/setting/user-login" },
{ title: "Master Loket", to: "/setting/master-loket" },
{ title: "Master Klinik", to: "/setting/master-klinik" },
{ title: "Master Klinik Ruang", to: "/setting/master-klinik-ruang" },
{ title: "Screen", to: "/setting/screen" },
],
},
{ title: "Loket Admin", icon: "mdi-account-supervisor" },
{ title: "Ranap Admin", icon: "mdi-bed" },
{ title: "Klinik Admin", icon: "mdi-hospital-box" },
{ title: "Klinik Ruang Admin", icon: "mdi-hospital-marker" },
{ title: "Anjungan", icon: "mdi-account-box-multiple", to: "/anjungan" },
{ title: "Fast Track", icon: "mdi-clock-fast" },
{ title: "Data Pasien", icon: "mdi-account-multiple" },
{ title: "Screen", icon: "mdi-monitor" },
{ title: "List Pasien", icon: "mdi-format-list-bulleted" },
]);
</script>
<style scoped>
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: white;
}
.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: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
}
</style>
-189
View File
@@ -1,189 +0,0 @@
<template>
<v-divider class="my-8"></v-divider>
<v-main class="bg-grey-lighten-3">
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri -->
<div :style="contentStyle">
<v-container fluid>
<h1 class="text-h4">Admin Anjungan</h1>
<v-card class="pa-5 mb-5" color="white" flat>
<v-row align="center">
<v-col cols="12" md="4">
<v-text-field
label="Barcode"
placeholder="Masukkan Barcode"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-chip color="#B71C1C" class="text-caption">
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
J20073010005 "Hiraukan huruf 'J' nya")
</v-chip>
</v-col>
<v-col cols="12" md="2">
<v-btn block color="info">Pendaftaran Online</v-btn>
</v-col>
</v-row>
</v-card>
<v-divider class="my-5"></v-divider>
<v-card class="mb-5">
<v-toolbar flat color="transparent" dense>
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
DATA PENGUNJUNG TERLAMBAT
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="searchLate"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
dense
class="mr-2"
variant="outlined"
></v-text-field>
<v-select
:items="[10, 25, 50, 100]"
label="Show"
dense
single-line
hide-details
class="shrink"
variant="outlined"
></v-select>
</v-toolbar>
<v-card-text>
<v-data-table
:headers="lateHeaders"
:items="lateVisitors"
:search="searchLate"
no-data-text="No data available in table"
hide-default-footer
class="elevation-1"
></v-data-table>
<div class="d-flex justify-end pt-2">
<v-pagination
v-model="page"
:length="10"
:total-visible="5"
></v-pagination>
</div>
</v-card-text>
</v-card>
<v-divider class="my-5"></v-divider>
<v-card>
<v-toolbar flat color="transparent" dense>
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
DATA PENGUNJUNG
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
dense
class="mr-2"
variant="outlined"
></v-text-field>
<v-select
:items="[10, 25, 50, 100]"
label="Show"
dense
single-line
hide-details
class="shrink"
variant="outlined"
></v-select>
</v-toolbar>
<v-card-text>
<v-data-table
:headers="headers"
:items="visitors"
:search="search"
no-data-text="No data available in table"
class="elevation-1"
>
<template v-slot:item.aksi="{ item }">
<div class="d-flex flex-column">
<v-btn small color="success" class="my-1">Tiket</v-btn>
<v-btn small color="primary" class="my-1">Tiket Pengantar</v-btn>
<v-btn small color="warning" class="my-1">ByPass</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</div>
</v-main>
</template>
<script setup>
import { ref, computed } from "vue";
// Definisikan props untuk menerima status 'rail' dari layout induk
const props = defineProps({
rail: Boolean,
});
// Reactive data
const search = ref("");
const searchLate = ref("");
const page = ref(1);
// Gaya komputasi untuk menyesuaikan padding
const contentStyle = computed(() => {
return {
paddingLeft: props.rail ? '56px' : '64px',
transition: 'padding-left 0.3s ease-in-out',
};
});
// Table headers for late visitors
const lateHeaders = [
{ text: 'No', value: 'no' },
{ text: 'Barcode', value: 'barcode' },
{ text: 'No Rekamedik', value: 'noRekamedik' },
{ text: 'No Antrian', value: 'noAntrian' },
{ text: 'No Antrian Klinik', value: 'noAntrianKlinik' },
{ text: 'Shift', value: 'shift' },
{ text: 'Pembayaran', value: 'pembayaran' },
{ text: 'Status', value: 'status' },
];
// Table headers for all visitors
const headers = [
{ text: 'No', value: 'no' },
{ text: 'Barcode', value: 'barcode' },
{ text: 'No Rekamedik', value: 'noRekamedik' },
{ text: 'No Antrian', value: 'noAntrian' },
{ text: 'Shift', value: 'shift' },
{ text: 'Ket', value: 'ket' },
{ text: 'Fast Track', value: 'fastTrack' },
{ text: 'Pembayaran', value: 'pembayaran' },
{ text: 'Panggil', value: 'panggil' },
{ text: 'Aksi', value: 'aksi' },
];
// Mock data for late visitors
const lateVisitors = ref([
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', noAntrianKlinik: 'K1', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', noAntrianKlinik: 'K2', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', noAntrianKlinik: 'K3', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
]);
// Mock data for all visitors
const visitors = ref([
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', shift: 'Shift 1', ket: '', fastTrack: 'Ya', pembayaran: 'JKN', panggil: 'Ya' },
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
]);
</script>
File diff suppressed because it is too large Load diff
File diff suppressed because it is too large Load diff
+752
View File
@@ -0,0 +1,752 @@
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-door-open</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN KLINIK</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Hero Call Section -->
<div v-if="currentCalledQueue" class="hero-call-section">
<div class="call-label">
<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>
<div class="call-clinic">{{ currentCalledQueue.klinik }} - {{ currentCalledQueue.ruang }}</div>
</div>
<!-- Main Grid - Display Ruang -->
<div class="clinics-grid">
<div
v-for="ruang in displayedRuang"
:key="`${ruang.kodeKlinik}-${ruang.nomorRuang}`"
class="clinic-box"
>
<!-- Clinic Header -->
<div class="clinic-header-bar">
<span class="clinic-title">{{ ruang.namaKlinik }} - {{ ruang.namaRuang }}</span>
<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>
<!-- Queue Content -->
<div class="clinic-content">
<!-- Current Queue -->
<div v-if="ruang.currentQueue" class="current-section">
<div class="current-label">SEKARANG</div>
<div class="current-number">
{{ ruang.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Next Queues -->
<div v-if="ruang.nextQueues.length > 0" class="next-section">
<div class="next-label">SELANJUTNYA</div>
<div class="next-numbers">
<div
v-for="queue in ruang.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-item"
>
{{ queue.noAntrian.split(' |')[0] }}
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!ruang.currentQueue && ruang.nextQueues.length === 0" class="empty-state">
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<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>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<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>
<div class="stat-label">Menunggu</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<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>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<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>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '../stores/queueStore'
import { useMasterStore } from '../stores/masterStore'
definePageMeta({
layout: false,
});
const queueStore = useQueueStore()
const masterStore = useMasterStore()
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
const klinikPatients = computed(() => {
return queueStore.getPatientsByStage('klinik').value.all
})
const allRuangList = computed(() => {
return masterStore.getAllRuangList || []
})
const displayedRuang = computed(() => {
const ruangToDisplay = (allRuangList.value || []).slice(0, 9)
return ruangToDisplay.map(ruang => {
const queues = klinikPatients.value
.filter(p => p.klinik === ruang.namaKlinik)
.sort((a, b) => {
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'terlambat': 3,
'pending': 4
}
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
const timeA = a.jamPanggil.split(':').map(Number)
const timeB = b.jamPanggil.split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
)
return {
kodeKlinik: ruang.kodeKlinik,
namaKlinik: ruang.namaKlinik,
nomorRuang: ruang.nomorRuang,
namaRuang: ruang.namaRuang,
nomorScreen: ruang.nomorScreen,
currentQueue: currentQueue ? { ...currentQueue, ruang: ruang.namaRuang } : null,
nextQueues: nextQueues,
totalQueues: queues.length
}
})
})
const currentCalledQueue = computed(() => {
const calledQueues = klinikPatients.value
.filter(p => p.status === 'di-loket')
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
if (calledQueues.length > 0) {
const patient = calledQueues[0]
const ruang = (allRuangList.value || []).find(r => r.namaKlinik === patient.klinik)
return {
...patient,
ruang: ruang ? ruang.namaRuang : 'Ruang 1'
}
}
return null
})
const statistics = computed(() => {
const all = klinikPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped lang="scss">
.antrian-display-container {
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 ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 20px;
box-shadow: 0 8px 24px rgba(255, 155, 27, 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.15);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.hospital-name {
font-size: 48px;
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
}
.display-subtitle {
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 56px;
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;
color: var(--color-neutral-100);
margin-top: 8px;
font-weight: 500;
opacity: 0.95;
}
/* ========== HERO CALL SECTION ========== */
.hero-call-section {
background: linear-gradient(135deg, var(--color-danger-600) 0%, var(--color-danger-700) 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.35);
}
.call-label {
font-size: 28px;
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: 140px;
font-weight: 900;
color: var(--color-neutral-100);
margin: 16px 0;
letter-spacing: 8px;
line-height: 1;
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.call-clinic {
font-size: 36px;
font-weight: 700;
color: var(--color-neutral-100);
letter-spacing: 1px;
}
/* ========== CLINICS GRID ========== */
.clinics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
flex: 1;
}
.clinic-box {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(255, 155, 27, 0.12);
min-height: 240px;
display: flex;
flex-direction: column;
}
.clinic-header-bar {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.clinic-title {
font-size: 22px;
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: 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 {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px;
background: var(--color-primary-100);
}
.current-section {
text-align: center;
margin-bottom: 12px;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: var(--color-success-600);
margin-bottom: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.current-number {
font-size: 80px;
font-weight: 900;
color: var(--color-neutral-900);
letter-spacing: 4px;
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.next-section {
text-align: center;
margin-top: 8px;
}
.next-label {
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
margin-bottom: 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.next-numbers {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.next-item {
background: var(--color-secondary-600);
color: var(--color-neutral-100);
padding: 8px 16px;
border-radius: 12px;
font-size: 22px;
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 {
text-align: center;
padding: 32px 0;
}
.empty-text {
font-size: 16px;
color: var(--color-neutral-600);
margin-top: 12px;
font-weight: 600;
}
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
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 12px rgba(255, 155, 27, 0.12);
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
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: 40px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
color: var(--color-neutral-700);
font-weight: 600;
margin-top: 4px;
opacity: 0.95;
}
.stat-divider {
width: 2px;
height: 60px;
background: var(--color-primary-200);
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
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>
+520
View File
@@ -0,0 +1,520 @@
<!-- pages/AntrianPenunjang.vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-clipboard-pulse</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">RUMAH SAKIT</h1>
<p class="display-subtitle">Antrian Penunjang</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- 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>
NOMOR ANTRIAN YANG DIPANGGIL
</div>
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="call-clinic">{{ currentCalledQueue.klinik }}</div>
</div>
<!-- Main Grid - 3x3 Penunjang -->
<div class="clinics-grid">
<div
v-for="penunjang in displayedPenunjangs"
:key="penunjang.name"
class="clinic-box"
>
<!-- Penunjang Header -->
<div class="clinic-header-bar" style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);">
<span class="clinic-title">{{ penunjang.name }}</span>
<v-chip
:color="penunjang.totalQueues > 0 ? '#9C27B0' : '#89939E'"
size="small"
class="clinic-count"
>
<v-icon size="16" class="mr-1">mdi-account-multiple</v-icon>
{{ penunjang.totalQueues }}
</v-chip>
</div>
<!-- Queue Content -->
<div class="clinic-content">
<!-- Current Queue -->
<div v-if="penunjang.currentQueue" class="current-section">
<div class="current-label">SEKARANG</div>
<div class="current-number">
{{ penunjang.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Next Queues -->
<div v-if="penunjang.nextQueues.length > 0" class="next-section">
<div class="next-label">SELANJUTNYA</div>
<div class="next-numbers">
<div
v-for="queue in penunjang.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-item"
style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);"
>
{{ queue.noAntrian.split(' |')[0] }}
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!penunjang.currentQueue && penunjang.nextQueues.length === 0" class="empty-state">
<v-icon size="56" color="#F5F7FA">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon" style="background: #F3E5F5;">
<v-icon size="32" color="#9C27B0">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon" style="background: #F3E5F5;">
<v-icon size="32" color="#9C27B0">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.waiting }}</div>
<div class="stat-label">Menunggu</div>
</div>
</div>
<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>
<div class="stat-info">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="#9C27B0" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor antrian Anda di layar</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '../stores/queueStore'
definePageMeta({
layout: false,
});
const queueStore = useQueueStore()
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
const penunjangPatients = computed(() => {
return queueStore.getPatientsByStage('penunjang').value.all
})
const displayedPenunjangs = computed(() => {
const allPenunjangs = queueStore.penunjangs.slice(0, 9)
return allPenunjangs.map(penunjang => {
const queues = penunjangPatients.value
.filter(p => p.klinik === penunjang.name)
.sort((a, b) => {
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'terlambat': 3,
'pending': 4
}
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
const timeA = a.jamPanggil.split(':').map(Number)
const timeB = b.jamPanggil.split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
)
return {
name: penunjang.name,
currentQueue: currentQueue,
nextQueues: nextQueues,
totalQueues: queues.length
}
})
})
const currentCalledQueue = computed(() => {
const calledQueues = penunjangPatients.value
.filter(p => p.status === 'di-loket')
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return calledQueues[0] || null
})
const statistics = computed(() => {
const all = penunjangPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped>
.antrian-display-container {
background: #FFFFFF;
min-height: 100vh;
padding: 24px;
font-family: 'Roboto', sans-serif;
}
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 20px;
padding: 24px 40px;
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(255, 252, 255, 0.2);
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.2);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.hospital-name {
font-size: 42px;
font-weight: 900;
color: #FFFFFF;
margin: 0;
letter-spacing: 1px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.display-subtitle {
font-size: 22px;
font-weight: 500;
color: #FFFFFF;
margin: 0;
opacity: 0.95;
}
.time-large {
font-size: 56px;
font-weight: 800;
color: #FFFFFF;
line-height: 1;
letter-spacing: 2px;
}
.date-small {
font-size: 18px;
color: #FFFFFF;
margin-top: 8px;
font-weight: 400;
opacity: 0.95;
}
.hero-call-section {
background: linear-gradient(135deg, #E01507 0%, #E02B1D 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 24px;
text-align: center;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
animation: pulse-shadow 2s infinite;
}
.call-label {
font-size: 28px;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 2px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.call-number {
font-size: 110px;
font-weight: 900;
color: #FFFFFF;
line-height: 1;
margin: 16px 0;
letter-spacing: 4px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2);
}
.call-clinic {
font-size: 38px;
font-weight: 700;
color: #FFFFFF;
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 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.clinic-box {
background: #FFFFFF;
border: 2px solid #F3E5F5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(156, 39, 176, 0.08);
transition: all 0.3s ease;
min-height: 260px;
display: flex;
flex-direction: column;
}
.clinic-box:hover {
box-shadow: 0 8px 24px rgba(156, 39, 176, 0.15);
transform: translateY(-2px);
}
.clinic-header-bar {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.clinic-title {
font-size: 26px;
font-weight: 800;
color: #FFFFFF;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.clinic-count {
background: rgba(255, 255, 255, 0.2) !important;
color: #FFFFFF !important;
font-weight: 700;
font-size: 16px;
backdrop-filter: blur(10px);
}
.clinic-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 24px;
background: #FAFBFC;
}
.current-section {
text-align: center;
margin-bottom: 16px;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: #18B653;
margin-bottom: 8px;
letter-spacing: 1px;
}
.current-number {
font-size: 68px;
font-weight: 900;
color: #212121;
line-height: 1;
letter-spacing: 2px;
}
.next-section {
text-align: center;
}
.next-label {
font-size: 13px;
font-weight: 700;
color: #717171;
margin-bottom: 10px;
letter-spacing: 1px;
text-transform: uppercase;
}
.next-numbers {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.next-item {
color: #FFFFFF;
padding: 8px 18px;
border-radius: 10px;
font-size: 22px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.2);
letter-spacing: 1px;
}
.empty-state {
text-align: center;
padding: 32px 0;
}
.empty-text {
font-size: 18px;
color: #89939E;
margin-top: 12px;
font-weight: 600;
}
.footer-stats-bar {
background: #FFFFFF;
border: 2px solid #F3E5F5;
border-radius: 20px;
padding: 24px 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 4px 16px rgba(156, 39, 176, 0.08);
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
color: #212121;
line-height: 1;
}
.stat-label {
font-size: 15px;
color: #717171;
font-weight: 600;
margin-top: 4px;
}
.stat-divider {
width: 2px;
height: 64px;
background: #F3E5F5;
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: #4D4D4D;
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>
+543
View File
@@ -0,0 +1,543 @@
<!-- pages/AntrianKlinik.vue -->
<template>
<div class="tv-display-container">
<!-- Header -->
<div class="tv-header">
<div class="tv-header-left">
<div class="hospital-logo">
<v-icon size="64" color="white">mdi-hospital-box</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN KLINIK</h1>
<p class="display-title">RSUD Dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div class="tv-header-right">
<div class="current-time">{{ currentTime }}</div>
<div class="current-date">{{ currentDate }}</div>
</div>
</div>
<!-- Current Called Queue - Super Prominent -->
<div v-if="currentCalledQueue" class="hero-queue">
<div class="hero-label">SEDANG DIPANGGIL</div>
<div class="hero-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="hero-clinic">{{ currentCalledQueue.klinik }}</div>
</div>
<!-- Clinic Grid - 3x3 Layout -->
<div class="clinic-grid">
<div
v-for="clinic in displayedClinics"
:key="clinic.name"
class="clinic-card"
>
<div class="clinic-name">{{ clinic.name }}</div>
<div class="queue-display">
<!-- Show current queue number -->
<div v-if="clinic.currentQueue" class="current-queue-large">
<div class="queue-label">SEKARANG</div>
<div class="queue-number-huge">
{{ clinic.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Show next queues -->
<div v-if="clinic.nextQueues.length > 0" class="next-queues">
<div class="queue-label-small">SELANJUTNYA</div>
<div class="next-queue-numbers">
<span
v-for="(queue, index) in clinic.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-number"
>
{{ queue.noAntrian.split(' |')[0] }}
</span>
</div>
</div>
<!-- Empty state -->
<div v-if="!clinic.currentQueue && clinic.nextQueues.length === 0" class="empty-queue">
<v-icon size="48" color="grey-lighten-2">mdi-checkbox-blank-circle-outline</v-icon>
<div class="empty-text">Tidak Ada Antrian</div>
</div>
</div>
<!-- Queue count badge -->
<div class="queue-count">
<v-icon size="20" class="mr-1">mdi-account-multiple</v-icon>
{{ clinic.totalQueues }}
</div>
</div>
</div>
<!-- Footer Info -->
<div class="tv-footer">
<div class="footer-stats">
<div class="stat-box">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ statistics.waiting }}</div>
<div class="stat-label">Menunggu</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" class="mr-2">mdi-information</v-icon>
Harap perhatikan nomor antrian Anda di layar
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '../stores/queueStore'
definePageMeta({
layout: false,
});
// Store
const queueStore = useQueueStore()
// Reactive data
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
let refreshInterval = null
// Computed - Get all klinik patients (stage: klinik)
const klinikPatients = computed(() => {
return queueStore.getPatientsByStage('klinik').value.all
})
// Get clinics with their queues organized
const displayedClinics = computed(() => {
const allKliniks = queueStore.kliniks.slice(0, 9) // Max 9 clinics for 3x3 grid
return allKliniks.map(klinik => {
const queues = klinikPatients.value
.filter(p => p.klinik === klinik.name)
.sort((a, b) => {
// Sort by status priority then time
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'terlambat': 3,
'pending': 4
}
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
// Sort by time if same status
const timeA = a.jamPanggil.split(':').map(Number)
const timeB = b.jamPanggil.split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
// Get current (first di-loket or first waiting)
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
// Get next queues (excluding current)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
)
return {
name: klinik.name,
currentQueue: currentQueue,
nextQueues: nextQueues,
totalQueues: queues.length
}
})
})
// Current queue being called (most recent di-loket)
const currentCalledQueue = computed(() => {
const calledQueues = klinikPatients.value
.filter(p => p.status === 'di-loket')
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return calledQueues[0] || null
})
// Statistics
const statistics = computed(() => {
const all = klinikPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
// Methods
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// Lifecycle
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
// Auto refresh every 5 seconds
refreshInterval = setInterval(() => {
// Data will auto-update due to reactive store
}, 5000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
if (refreshInterval) clearInterval(refreshInterval)
})
</script>
<style scoped>
.tv-display-container {
background: linear-gradient(135deg, #e7e7e7 0%, #FFDCAF 100%);
min-height: 100vh;
padding: 20px;
font-family: 'Roboto', sans-serif;
color: #fff;
overflow: hidden;
}
/* Header */
.tv-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #FAFAFA;
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 20px 40px;
margin-bottom: 20px;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
}
.tv-header-left {
display: flex;
align-items: center;
gap: 20px;
}
.hospital-logo {
background: #1e3c72;
border-radius: 50%;
padding: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.hospital-name {
font-size: 36px;
margin: 0;
letter-spacing: 2px;
color: #1e3c72;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.display-title {
font-size: 24px;
font-weight: 600;
margin: 0;
color: #1e3c72;
opacity: 0.9;
}
.tv-header-right {
text-align: right;
}
.current-time {
font-size: 48px;
font-weight: 700;
line-height: 1;
color: #1e3c72;
text-shadow: 2px 2px 4px rgba(124, 124, 124, 0.3);
}
.current-date {
font-size: 18px;
opacity: 0.9;
margin-top: 5px;
}
/* Hero Queue - Sedang Dipanggil */
.hero-queue {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
border-radius: 24px;
padding: 30px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
animation: pulse-glow 2s infinite;
}
.hero-label {
font-size: 32px;
font-weight: 700;
letter-spacing: 3px;
margin-bottom: 10px;
text-transform: uppercase;
}
.hero-number {
font-size: 120px;
font-weight: 900;
line-height: 1;
margin: 10px 0;
text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
letter-spacing: 4px;
}
.hero-clinic {
font-size: 40px;
font-weight: 600;
margin-top: 10px;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
}
50% {
box-shadow: 0 16px 64px rgba(255, 107, 107, 0.6);
}
}
/* Clinic Grid - 3x3 */
.clinic-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 20px;
}
.clinic-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
position: relative;
min-height: 200px;
display: flex;
flex-direction: column;
}
.clinic-name {
font-size: 28px;
font-weight: 800;
color: #1e3c72;
text-align: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 3px solid #2a5298;
text-transform: uppercase;
letter-spacing: 1px;
}
.queue-display {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* Current Queue Display */
.current-queue-large {
text-align: center;
margin-bottom: 10px;
}
.queue-label {
font-size: 18px;
font-weight: 600;
color: #4caf50;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
.queue-number-huge {
font-size: 72px;
font-weight: 900;
color: #1e3c72;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
/* Next Queues */
.next-queues {
text-align: center;
}
.queue-label-small {
font-size: 14px;
font-weight: 600;
color: #666;
margin-bottom: 5px;
text-transform: uppercase;
}
.next-queue-numbers {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.next-number {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 12px;
font-size: 24px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
/* Empty State */
.empty-queue {
text-align: center;
opacity: 0.5;
}
.empty-text {
font-size: 18px;
color: #999;
margin-top: 10px;
font-weight: 500;
}
/* Queue Count Badge */
.queue-count {
position: absolute;
top: 15px;
right: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Footer */
.tv-footer {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.footer-stats {
display: flex;
gap: 40px;
}
.stat-box {
text-align: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
line-height: 1;
color:#1e3c72;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.stat-label {
font-size: 16px;
opacity: 0.9;
margin-top: 5px;
color:#1e3c72;
font-weight: 500;
}
.footer-message {
font-size: 22px;
font-weight: 600;
display: flex;
align-items: center;
opacity: 0.95;
color:#1e3c72;
}
/* Responsive for different TV sizes */
@media (max-width: 1920px) {
.hero-number {
font-size: 100px;
}
.queue-number-huge {
font-size: 64px;
}
}
@media (max-width: 1366px) {
.clinic-grid {
gap: 10px;
}
.clinic-card {
padding: 15px;
min-height: 180px;
}
.clinic-name {
font-size: 24px;
}
.queue-number-huge {
font-size: 56px;
}
.hero-number {
font-size: 90px;
}
}
/* Animation for smooth transitions */
.clinic-card {
transition: all 0.3s ease;
}
.queue-number-huge,
.hero-number {
transition: all 0.3s ease;
}
</style>
+858
View File
@@ -0,0 +1,858 @@
<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-plus</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Buat Antrean</h2>
<p class="page-subtitle">{{ currentDate }} - Manajemen Pembuatan Antrean</p>
</div>
</div>
<v-chip color="white" variant="flat" class="stat-chip">
<v-icon start size="20" color="primary">mdi-account-multiple</v-icon>
<span class="text-semibold">{{ totalPasien }} Pasien</span>
</v-chip>
</div>
</div>
<!-- Filters & Search -->
<v-card-text class="pa-4">
<v-row dense class="mb-4">
<v-col cols="12" md="8">
<v-text-field
v-model="search"
placeholder="Cari nama pasien, No. RM, atau alamat..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="search-field"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
:items="['Semua Status', ...statusOptions]"
variant="outlined"
density="compact"
hide-details
class="filter-select"
/>
</v-col>
<v-col cols="12" md="1">
<v-btn
color="primary"
variant="outlined"
block
:loading="loading"
@click="refreshData"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-col>
</v-row>
<!-- Data Table -->
<v-data-table
:headers="headers"
:items="filteredPasien"
:items-per-page="10"
class="elevation-0 data-table"
>
<template #item.namaPasien="{ item }">
<div class="patient-info">
<div class="body-2 text-semibold">{{ item.namaPasien }}</div>
<div class="caption-2 text-muted">{{ item.noRM }}</div>
</div>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="status-chip"
>
{{ item.status }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<div class="action-buttons">
<v-btn
size="small"
color="primary"
variant="flat"
class="btn-action mr-1"
@click="openKlinikDialog(item)"
>
<v-icon size="16" left>mdi-hospital-box</v-icon>
Klinik
</v-btn>
<v-btn
size="small"
color="warning"
variant="flat"
class="btn-action mr-1"
@click="openKlinikRuangDialog(item)"
>
<v-icon size="16" left>mdi-door</v-icon>
Ruang
</v-btn>
<v-btn
size="small"
color="#3b82f6"
variant="flat"
class="btn-action"
@click="openPenunjangDialog(item)"
>
<v-icon size="16" left>mdi-clipboard-pulse</v-icon>
Penunjang
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- Dialog Klinik -->
<v-dialog v-model="showKlinikDialog" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header">
<span class="headline-4">Pilih Klinik</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="selectedPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Nama</span>
<span class="body-2 text-semibold">{{ selectedPatient.namaPasien }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. RM</span>
<span class="body-2 text-semibold">{{ selectedPatient.noRM }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikSearch"
placeholder="Cari Klinik..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-row dense>
<v-col
v-for="klinik in filteredKliniks"
:key="klinik.id"
cols="6"
sm="4"
>
<v-card
class="option-card"
elevation="0"
@click="buatAntreanKlinik(klinik)"
>
<v-icon size="40" color="primary">mdi-hospital-box</v-icon>
<span class="body-3 text-semibold text-center">{{ klinik.name }}</span>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- Dialog Klinik Ruang -->
<v-dialog v-model="showKlinikRuangDialog" max-width="900px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header dialog-header-warning">
<span class="headline-4">Pilih Klinik Ruang</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikRuangDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="selectedPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Nama</span>
<span class="body-2 text-semibold">{{ selectedPatient.namaPasien }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. RM</span>
<span class="body-2 text-semibold">{{ selectedPatient.noRM }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikRuangSearch"
placeholder="Cari Klinik Ruang..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-expansion-panels class="expansion-panels">
<v-expansion-panel
v-for="klinikRuang in filteredKlinikRuang"
:key="klinikRuang.id"
class="expansion-panel"
>
<v-expansion-panel-title class="expansion-title">
<div class="expansion-header">
<v-chip size="small" color="primary" class="mr-2">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<span class="body-2 text-semibold">{{ klinikRuang.namaKlinik }}</span>
</div>
<template #actions>
<v-chip size="x-small" variant="outlined">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="ruang-list">
<div
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
class="ruang-item"
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
>
<v-icon color="warning" size="20">mdi-door</v-icon>
<div class="ruang-info">
<span class="body-3 text-semibold">Ruang {{ ruang.nomorRuang }} - {{ ruang.namaRuang }}</span>
<span class="caption-2 text-muted">Screen: {{ ruang.nomorScreen }}</span>
</div>
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
<!-- Dialog Penunjang -->
<v-dialog v-model="showPenunjangDialog" max-width="700px" persistent scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header dialog-header-secondary">
<span class="headline-4">Pilih Penunjang</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closePenunjangDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="selectedPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Nama</span>
<span class="body-2 text-semibold">{{ selectedPatient.namaPasien }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. RM</span>
<span class="body-2 text-semibold">{{ selectedPatient.noRM }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="penunjangSearch"
placeholder="Cari Penunjang..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-row dense>
<v-col
v-for="penunjang in filteredPenunjangs"
:key="penunjang.id"
cols="6"
sm="4"
>
<v-card
class="option-card"
elevation="0"
@click="buatAntreanPenunjang(penunjang)"
>
<v-icon size="40" color="secondary">mdi-clipboard-pulse</v-icon>
<span class="body-3 text-semibold text-center">{{ penunjang.name }}</span>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000" location="top right">
<span class="body-3">{{ snackbarText }}</span>
<template #actions>
<v-btn variant="text" size="small" @click="snackbar = false">Tutup</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
const masterStore = useMasterStore();
// State
const loading = ref(false);
const search = ref('');
const statusFilter = ref('Semua Status');
const snackbar = ref(false);
const snackbarText = ref('');
const snackbarColor = ref('success');
const currentDate = ref(
new Date().toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
);
// Dialog states
const showKlinikDialog = ref(false);
const showKlinikRuangDialog = ref(false);
const showPenunjangDialog = ref(false);
const selectedPatient = ref(null);
// Search states
const klinikSearch = ref('');
const klinikRuangSearch = ref('');
const penunjangSearch = ref('');
// Status options
const statusOptions = ref(['Terdaftar', 'Belum Terdaftar', 'Proses']);
// Table headers
const headers = ref([
{ title: 'Nama Pasien', value: 'namaPasien', sortable: true },
{ title: 'No. RM', value: 'noRM', sortable: true },
{ title: 'Alamat', value: 'alamat', sortable: true },
{ title: 'Status', value: 'status', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
]);
// Mock data
const pasienList = ref([
{ id: 1, namaPasien: 'Ahmad Fauzi', noRM: '250811100163', alamat: 'Jl. Merdeka No. 123', status: 'Terdaftar' },
{ id: 2, namaPasien: 'Siti Aminah', noRM: '250811100155', alamat: 'Jl. Sudirman No. 45', status: 'Terdaftar' },
{ id: 3, namaPasien: 'Budi Santoso', noRM: '250811100200', alamat: 'Jl. Gatot Subroto No. 78', status: 'Belum Terdaftar' },
{ id: 4, namaPasien: 'Dewi Lestari', noRM: '250811100210', alamat: 'Jl. Ahmad Yani No. 90', status: 'Terdaftar' },
{ id: 5, namaPasien: 'Eko Prasetyo', noRM: '250811100220', alamat: 'Jl. Diponegoro No. 15', status: 'Proses' },
]);
// Computed - Get data from Master Store
const kliniksList = computed(() => {
return masterStore.klinikList.map(k => ({
id: k.id,
name: k.nama,
kode: k.kode
}));
});
const klinikRuangList = computed(() => {
return masterStore.ruangData;
});
const penunjangsList = computed(() => {
return masterStore.getActivePenunjangList();
});
const totalPasien = computed(() => pasienList.value.length);
const filteredPasien = computed(() => {
let filtered = pasienList.value;
if (statusFilter.value && statusFilter.value !== 'Semua Status') {
filtered = filtered.filter((p) => p.status === statusFilter.value);
}
if (search.value) {
const query = search.value.toLowerCase();
filtered = filtered.filter(
(p) =>
p.namaPasien.toLowerCase().includes(query) ||
p.noRM.toLowerCase().includes(query) ||
p.alamat.toLowerCase().includes(query)
);
}
return filtered;
});
const filteredKliniks = computed(() => {
if (!klinikSearch.value) return kliniksList.value;
return kliniksList.value.filter((k) =>
k.name.toLowerCase().includes(klinikSearch.value.toLowerCase())
);
});
const filteredKlinikRuang = computed(() => {
if (!klinikRuangSearch.value) return klinikRuangList.value;
return klinikRuangList.value.filter(
(k) =>
k.namaKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase()) ||
k.kodeKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase())
);
});
const filteredPenunjangs = computed(() => {
if (!penunjangSearch.value) return penunjangsList.value;
return penunjangsList.value.filter((p) =>
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
);
});
// Methods
const showSnackbarMessage = (text, color = 'success') => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
const getStatusColor = (status) => {
const colors = {
Terdaftar: 'success',
'Belum Terdaftar': 'warning',
Proses: 'info',
};
return colors[status] || 'default';
};
const refreshData = () => {
loading.value = true;
setTimeout(() => {
loading.value = false;
showSnackbarMessage('Data berhasil diperbarui', 'success');
}, 1000);
};
const openKlinikDialog = (patient) => {
selectedPatient.value = patient;
showKlinikDialog.value = true;
};
const closeKlinikDialog = () => {
showKlinikDialog.value = false;
selectedPatient.value = null;
klinikSearch.value = '';
};
const openKlinikRuangDialog = (patient) => {
selectedPatient.value = patient;
showKlinikRuangDialog.value = true;
};
const closeKlinikRuangDialog = () => {
showKlinikRuangDialog.value = false;
selectedPatient.value = null;
klinikRuangSearch.value = '';
};
const openPenunjangDialog = (patient) => {
selectedPatient.value = patient;
showPenunjangDialog.value = true;
};
const closePenunjangDialog = () => {
showPenunjangDialog.value = false;
selectedPatient.value = null;
penunjangSearch.value = '';
};
const buatAntreanKlinik = (klinik) => {
showSnackbarMessage(
`Antrean klinik ${klinik.name} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`,
'success'
);
closeKlinikDialog();
};
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
showSnackbarMessage(
`Antrean ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`,
'success'
);
closeKlinikRuangDialog();
};
const buatAntreanPenunjang = (penunjang) => {
showSnackbarMessage(
`Antrean penunjang ${penunjang.name} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`,
'success'
);
closePenunjangDialog();
};
</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: #FF9B1B;
$primary-600: #FFA532;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$warning-600: #FFB95F;
$success-600: #009262;
$success-300: #84DFC1;
// 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(255, 165, 50, 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;
}
.stat-chip {
font-weight: $font-weight-semibold;
color: $primary-600 !important;
}
// ============================================
// FILTERS & SEARCH
// ============================================
.search-field,
.filter-select {
font-size: 14px;
line-height: 20px;
}
// ============================================
// DATA TABLE
// ============================================
.data-table {
font-family: $font-family-base;
}
.patient-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.text-muted {
color: $neutral-700;
}
.status-chip {
font-weight: $font-weight-semibold;
font-size: 11px;
text-transform: uppercase;
}
.action-buttons {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.btn-action {
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 12px;
}
// ============================================
// DIALOG
// ============================================
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header-warning {
background: linear-gradient(135deg, $warning-600 0%, $primary-700 100%);
}
.dialog-header-secondary {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.headline-5 {
font-size: 18px;
line-height: 24px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-900;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
// ============================================
// PATIENT CARD
// ============================================
.patient-card {
background: $neutral-100;
border-radius: 12px;
padding: 20px;
border: 1px solid $neutral-400;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
}
.body-2 {
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
// ============================================
// OPTION CARDS
// ============================================
.option-card {
cursor: pointer;
border: 2px solid $neutral-400;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
background: $neutral-100;
&:hover {
border-color: $secondary-600;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(6, 113, 224, 0.15);
}
}
// ============================================
// EXPANSION PANELS
// ============================================
.expansion-panels {
background: transparent;
}
.expansion-panel {
background: $neutral-100;
border: 1px solid $neutral-400;
border-radius: 12px !important;
margin-bottom: 12px;
overflow: hidden;
&::before {
box-shadow: none !important;
}
}
.expansion-title {
background: $neutral-100;
min-height: 60px;
}
.expansion-header {
display: flex;
align-items: center;
}
.ruang-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.ruang-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: $neutral-300;
border: 1px solid $neutral-400;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: $warning-600;
background: lighten($warning-600, 35%);
}
}
.ruang-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 960px) {
.page-header {
.header-content {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
}
.action-buttons {
flex-direction: column;
width: 100%;
.btn-action {
width: 100%;
}
}
}
</style>
File diff suppressed because it is too large Load diff
+512 -200
View File
@@ -1,114 +1,153 @@
<template>
<v-container fluid class="pa-6 bg-grey-lighten-4">
<div class="d-flex justify-space-between align-center mb-6">
<v-container fluid class="pa-8 dashboard-bg">
<div class="d-flex justify-space-between align-center mb-10">
<div>
<h1 class="text-h4 font-weight-bold">Dashboard</h1>
<p v-if="user" class="text-subtitle-1 text-grey-darken-1 mt-1">
Selamat Datang, {{ user.name || user.preferred_username }}!
<h1 class="dashboard-title">
Antrean Dashboard
</h1>
<p v-if="user" class="dashboard-subtitle">
Selamat Datang,
<span class="user-name">
{{ user.name || user.preferred_username }}
</span>! 🍊
</p>
</div>
<div class="d-flex align-center">
<v-chip color="green-lighten-1" class="mr-2 pa-3 font-weight-bold">
<v-icon start icon="mdi-calendar"></v-icon>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="primary-600"
variant="flat"
class="export-btn mr-4"
>
<v-icon start icon="mdi-download-box-outline"></v-icon>
Export Data ({{ exportType }})
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, index) in exportOptions"
:key="index"
@click="handleExport(item.type)"
>
<v-list-item-title>
<v-icon start :icon="item.icon"></v-icon>
{{ item.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<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>
</div>
<v-row class="mb-6">
<v-row class="mb-10">
<v-col cols="12" sm="6" md="3">
<v-card class="pa-4 rounded-xl elevation-6" color="blue-lighten-1" theme="dark">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-group</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">2635</div>
<div class="text-subtitle-1">Total Visitors</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-4 rounded-xl elevation-6" color="cyan-lighten-1" theme="dark">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus</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">759</div>
<div class="text-subtitle-1">Offline Registrants</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-4 rounded-xl elevation-6" color="green-lighten-1" theme="dark">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-account-multiple-plus-outline</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">1876</div>
<div class="text-subtitle-1">Online Registrants</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-4 rounded-xl elevation-6" color="orange-lighten-1" theme="dark">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="64" class="mr-4">mdi-ticket</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">248</div>
<div class="text-subtitle-1">Total Tickets Printed</div>
<div class="stat-value">7.5s</div>
<div class="stat-label">Avg. Check-in Time</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6 font-weight-bold">Grafik Jumlah Antrean</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory divided variant="outlined" color="primary" class="text-caption">
<v-btn size="small" value="day">Hari</v-btn>
<v-btn size="small" value="week">Minggu</v-btn>
<v-btn size="small" value="month">Bulan</v-btn>
<v-btn size="small" value="year">Tahun</v-btn>
<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>
<v-card-text>
<v-card-text class="pa-0">
<Bar
:data="queueChartData"
:options="queueChartOptions"
style="height: 300px"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6 font-weight-bold">Monthly Visitor Data</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' ? 'green-lighten-1' : 'grey-lighten-2'"
class="mr-2 text-caption font-weight-bold cursor-pointer"
:color="activeYear === '2024' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2024' ? 'white' : 'neutral-700'"
variant="flat"
class="year-chip mr-2"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? 'green-lighten-1' : 'grey-lighten-2'"
class="text-caption font-weight-bold cursor-pointer"
:color="activeYear === '2025' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2025' ? 'white' : 'neutral-700'"
variant="flat"
class="year-chip"
@click="changeYear('2025')"
>
2025
</v-chip>
</div>
</v-card-title>
<v-card-text>
<v-card-text class="pa-0">
<Bar
:data="barData"
:options="barOptions"
style="height: 300px"
style="height: 350px"
/>
</v-card-text>
</v-card>
@@ -117,26 +156,34 @@
<v-row>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="text-h6 font-weight-bold">Realtime Ticket Queue</v-card-title>
<v-card-text>
<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>
</v-card-title>
<v-card-text class="pa-0">
<Line
ref="realtimeChart"
:data="initialLineData"
:data="realtimeLineData"
:options="lineOptions"
style="height: 300px"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-4 rounded-xl elevation-6">
<v-card-title class="text-h6 font-weight-bold">Registrant Breakdown</v-card-title>
<v-card-text>
<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"
:options="pieOptions"
style="height: 300px"
style="max-height: 100%; max-width: 100%;"
/>
</v-card-text>
</v-card>
@@ -144,8 +191,8 @@
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
<p class="mt-4">Loading dashboard...</p>
<v-progress-circular indeterminate size="64" color="primary-600"></v-progress-circular>
<p class="loading-text">Loading dashboard...</p>
</v-overlay>
</v-container>
</template>
@@ -154,12 +201,13 @@
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import dayjs from 'dayjs';
// Tambahkan plugin Dayjs
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import 'dayjs/locale/id';
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
dayjs.locale('id');
import {
Chart as ChartJS,
@@ -178,128 +226,128 @@ definePageMeta({
middleware:['auth']
})
// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// Use the auth composable
const { user, isLoading, checkAuth, logout } = useAuth()
const isLoggingOut = ref(false)
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);
const exportType = ref('JSON');
const exportOptions = ref([
{ title: 'JSON (.json)', type: 'json', icon: 'mdi-code-json' },
{ title: 'CSV (.csv)', type: 'csv', icon: 'mdi-file-delimited' },
{ title: 'Excel (.xlsx) - Server Only', type: 'excel', icon: 'mdi-file-excel' },
{ title: 'PDF (.pdf) - Placeholder', type: 'pdf', icon: 'mdi-file-pdf' },
]);
// Dashboard data
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('month');
const queueTimePeriod = ref('day');
// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
const mockQueueData = ref([
// ... (data tetap sama)
{ date: '2025-09-22', count: 120 },
{ date: '2025-09-23', count: 150 },
{ date: '2025-09-24', count: 135 },
{ date: '2025-09-25', count: 160 },
{ date: '2025-09-26', count: 145 },
{ date: '2025-09-27', count: 170 },
{ date: '2025-09-28', count: 180 },
{ date: '2025-08-10', count: 300 },
{ date: '2025-08-20', count: 350 },
{ date: '2025-09-01', count: 400 },
{ date: '2025-09-15', count: 450 },
{ date: '2024-01-01', count: 1200 },
{ date: '2024-06-01', count: 1800 },
{ date: '2025-01-01', count: 2000 },
{ date: '2025-06-01', count: 2500 },
{ date: '2025-10-13', registrants: 300, attendees: 250 },
{ date: '2025-10-14', registrants: 450, attendees: 400 },
{ date: '2025-10-15', registrants: 500, attendees: 420 },
{ date: '2025-10-16', registrants: 400, attendees: 350 },
{ date: '2025-10-17', registrants: 550, attendees: 500 },
{ date: '2025-10-18', registrants: 435, attendees: 380 },
]);
// --- AKHIR MOCK DATA ANTREEAN ---
// --- REFACTORED REALTIME LOGIC ---
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
// --- OPTIMIZED REALTIME CHART LOGIC ---
const realtimeChart = ref(null);
const maxDataPoints = 10;
let realtimeInterval = null;
// Initial data structure (non-reactive for update function)
const initialLineData = {
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Tickets Processed',
backgroundColor: '#FF5722',
borderColor: '#FF5722',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
fill: false,
tension: 0.1,
},
],
};
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
const realtimeLineData = ref({
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 30, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Check-ins/min',
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: '#0663C7',
},
plugins: {
legend: { display: true },
title: { display: false }
],
});
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 }, // Disable animation for better performance
plugins: {
legend: { display: true },
title: { display: false }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 80,
title: { display: true, text: 'Check-ins per Minute' },
grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 200,
title: { display: true, text: 'Count' },
grid: { display: true }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
}
});
// Function to simulate realtime data update using chart.update()
const updateRealtimeData = () => {
const chart = realtimeChart.value?.chart;
if (!chart) return;
// 1. Get the current data arrays
const dataArray = chart.data.datasets[0].data;
const labelArray = chart.data.labels;
// 2. Shift (remove) the oldest data point and label
dataArray.shift();
labelArray.shift();
// 3. Generate new data point and time label
const newDataPoint = Math.floor(Math.random() * 50) + 100;
const newTimeLabel = dayjs().format('HH:mm:ss');
// 4. Push the new data and label
dataArray.push(newDataPoint);
labelArray.push(newTimeLabel);
// 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
chart.update();
if (!realtimeChart.value) return; // Guard clause
const chart = realtimeChart.value.chart;
if (!chart) return;
// Direct chart data manipulation (more efficient)
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
const newDataPoint = Math.floor(Math.random() * 20) + 40;
const newTimeLabel = dayjs().format('HH:mm:ss');
chart.data.labels.push(newTimeLabel);
chart.data.datasets[0].data.push(newDataPoint);
chart.update('none'); // Update without animation
};
// --- END REFACTORED REALTIME LOGIC ---
// Start/stop interval management
const startRealtimeUpdates = () => {
if (realtimeInterval) return; // Prevent multiple intervals
realtimeInterval = setInterval(updateRealtimeData, 3000); // Update every 3 seconds
};
// Example data for both years (Tetap)
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
const stopRealtimeUpdates = () => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
realtimeInterval = null;
}
};
// Check authentication and setup on page load
// Lifecycle hooks
onMounted(() => {
startRealtimeUpdates();
});
onUnmounted(() => {
stopRealtimeUpdates();
});
// --- END OPTIMIZED REALTIME CHART LOGIC ---
onMounted(async () => {
try {
const sessionUser = await checkAuth()
if (sessionUser) {
// Set current date
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
currentDate.value = new Date().toLocaleDateString('id-ID', options);
// Start realtime data update interval
// Pastikan chart instance sudah siap sebelum memulai interval
realtimeInterval = setInterval(updateRealtimeData, 5000);
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
realtimeInterval = setInterval(updateRealtimeData, 5000);
} else {
await navigateTo('/LoginPage');
}
@@ -309,34 +357,81 @@ onMounted(async () => {
}
});
// Clear the interval when the component is unmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
}
});
// Updated logout handler (Tetap)
const handleLogout = async () => {
if (isLoggingOut.value) return
try {
isLoggingOut.value = true
console.log('🚪 Dashboard logout initiated...')
await logout()
} catch (error) {
console.error('❌ Dashboard logout error:', error)
} finally {
isLoggingOut.value = false
}
};
// Function to change the active year (Tetap)
const changeYear = (year) => {
activeYear.value = year;
};
// --- LOGIKA UTAMA UNTUK GRAFIK ANTREEAN (Tetap) ---
const getKpiData = () => {
return [
{ name: 'Antrean Online', value: 150 },
{ name: 'Antrean MJKN', value: 100 },
{ name: 'Total Kunjungan', value: 500 },
{ name: 'Avg. Check-in Time', value: '7.5s' },
];
};
const convertJsonToCsv = (jsonData, fields) => {
if (!jsonData.length) return '';
const header = fields.join(',');
const csv = jsonData.map(row =>
fields.map(fieldName => {
let value = row[fieldName];
if (typeof value === 'string') {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
).join('\n');
return header + '\n' + csv;
};
const downloadFile = (data, filename, mimeType) => {
const dataStr = `data:${mimeType};charset=utf-8,` + encodeURIComponent(data);
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", filename);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
const handleExport = (type) => {
exportType.value = type.toUpperCase();
const filenamePrefix = "antrean_data_" + dayjs().format('YYYYMMDD');
const dataToExport = mockQueueData.value;
if (type === 'json') {
downloadFile(JSON.stringify(dataToExport, null, 2), `${filenamePrefix}.json`, 'application/json');
alert("Data Antrean berhasil diexport sebagai JSON! ✅");
} else if (type === 'csv') {
const fields = ['date', 'registrants', 'attendees'];
const csvContent = convertJsonToCsv(dataToExport, fields);
const kpiData = getKpiData().map(kpi => `# ${kpi.name}: ${kpi.value}`).join('\n');
const finalCsvContent = kpiData + '\n' + csvContent;
downloadFile(finalCsvContent, `${filenamePrefix}.csv`, 'text/csv');
alert("Data Antrean berhasil diexport sebagai CSV! 📊");
} else if (type === 'excel') {
alert("Excel (.xlsx) export requires a dedicated server endpoint. Simulating download... ⚠️");
} else if (type === 'pdf') {
alert("PDF (.pdf) export requires client-side libraries (like jsPDF) or a server service. Simulating download... 📝");
}
};
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
@@ -347,37 +442,49 @@ const processQueueData = (data, period) => {
sortedDates.forEach(item => {
let key;
let label;
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
label = item.date.format('DD/MM');
label = item.date.format('ddd, DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
label = `Wk ${item.date.week()} ${item.date.year()}`;
label = `Wk ${item.date.week()}`;
} else if (period === 'month') {
key = item.date.format('YYYY-MM');
label = item.date.format('MMM YYYY');
} else if (period === 'year') {
key = item.date.format('YYYY');
label = item.date.format('YYYY');
}
if (!grouped[key]) {
grouped[key] = { label: label, count: 0 };
grouped[key] = { label: label, registrants: 0, attendees: 0 };
}
grouped[key].count += item.count;
grouped[key].registrants += item.registrants;
grouped[key].attendees += item.attendees;
});
const finalLabels = Object.values(grouped).map(g => g.label);
const finalCounts = Object.values(grouped).map(g => g.count);
const finalRegistrants = Object.values(grouped).map(g => g.registrants);
const finalAttendees = Object.values(grouped).map(g => g.attendees);
return {
labels: finalLabels,
datasets: [
{
label: `Total Antrean per ${period}`,
backgroundColor: '#FFB300',
data: finalCounts,
label: `Registrants`,
backgroundColor: '#FFB95F',
data: finalRegistrants,
yAxisID: 'y1',
type: 'bar',
borderRadius: 6,
},
{
label: `Attendees`,
backgroundColor: 'transparent',
borderColor: '#0671E0',
data: finalAttendees,
yAxisID: 'y1',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#0663C7',
tension: 0.4,
borderWidth: 3,
},
],
};
@@ -392,66 +499,94 @@ const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
legend: { display: true, position: 'top' },
},
scales: {
y: {
y1: {
beginAtZero: true,
grid: { display: true }
title: { display: true, text: 'Count' },
grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
grid: { display: false }
}
}
});
// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
const conversionRate2025 = [2.5, 3.1, 4.0, 4.5, 4.2, 5.5, 6.0, 5.8, 5.7, 5.2, 6.5, 7.5];
// Computed property Monthly Visitor (Tetap)
const barData = computed(() => {
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
const conversionData = activeYear.value === '2024' ? [2.0, 2.5, 3.5, 3.8, 3.6, 4.5, 5.0, 5.2, 4.8, 4.5, 5.5, 6.0] : conversionRate2025;
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
label: `Total Visitors ${activeYear.value}`,
backgroundColor: '#2196F3',
label: `Total Registrants`,
backgroundColor: '#FFB95F',
data: dataForYear,
yAxisID: 'y1',
type: 'bar',
borderRadius: 6,
},
{
label: `Conv. Rate (%)`,
borderColor: '#0671E0',
backgroundColor: 'transparent',
data: conversionData,
yAxisID: 'y2',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#0663C7',
tension: 0.4,
borderWidth: 3,
}
],
};
});
// Bar chart options (Tetap)
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
legend: { display: true, position: 'top' },
},
scales: {
y: {
y1: {
position: 'left',
beginAtZero: true,
title: { display: true, text: 'Registrants' },
grid: { display: false }
},
y2: {
position: 'right',
beginAtZero: true,
suggestedMax: 10,
title: { display: true, text: 'Conv. Rate (%)' },
grid: { drawOnChartArea: false }
},
x: {
grid: { display: false }
}
}
});
// Pie chart data (Tetap)
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#2196F3', '#4CAF50'],
backgroundColor: ['#0671E0', '#FFB95F'],
hoverBackgroundColor: ['#0053AD', '#FF9B1B'],
data: [759, 1876],
borderWidth: 3,
borderColor: '#ffffff',
},
],
});
// Pie chart options (Tetap)
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
@@ -461,7 +596,9 @@ const pieOptions = ref({
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
return `${label}: ${value}`;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
}
@@ -469,8 +606,183 @@ const pieOptions = ref({
});
</script>
<style scoped>
.cursor-pointer {
<style scoped lang="scss">
/* Background */
.dashboard-bg {
background: var(--color-neutral-300);
}
/* Typography */
.dashboard-title {
font-size: 32px;
font-weight: 800;
color: var(--color-neutral-900);
margin: 0;
line-height: 1.2;
}
.dashboard-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-700);
margin: 8px 0 0 0;
}
.user-name {
font-weight: 700;
color: var(--color-primary-600);
}
/* Export Button */
.export-btn {
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);
}
/* Date Chip */
.date-chip {
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);
}
/* 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>
-372
View File
@@ -1,372 +0,0 @@
<template>
<v-main class="bg-grey-lighten-3">
<v-container fluid class="pa-6">
<!-- Colored Header with Quota Chip -->
<v-card class="elevation-4 rounded-xl mb-6 header-banner d-flex align-center justify-space-between pa-4">
<div class="d-flex align-center">
<v-icon size="48" color="white" class="mr-4">mdi-account-group-outline</v-icon>
<h1 class="text-h4 font-weight-bold text-white">Klinik Admin</h1>
</div>
<v-tooltip text="Jumlah Maksimal Bangku Tersedia" location="bottom">
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
class="text-white px-4 py-2"
color="green-lighten-1"
variant="flat"
rounded="xl"
>
<v-icon start>mdi-chair-rolling</v-icon>
Max Quota Bangku 0
</v-chip>
</template>
</v-tooltip>
</v-card>
<!-- Loket Admin Table -->
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
Loket Admin
<div>
<v-btn
color="green-lighten-1"
variant="flat"
rounded="xl"
class="text-white elevation-4 mr-2 btn-call-group"
@click="handleCallClick(1)"
>
<v-icon start>mdi-numeric-1-box</v-icon>
<span class="d-none d-md-inline">Panggil 1 Antrian</span>
</v-btn>
<v-btn
color="blue-lighten-1"
variant="flat"
rounded="xl"
class="text-white elevation-4 mr-2 btn-call-group"
@click="handleCallClick(5)"
>
<v-icon start>mdi-numeric-5-box</v-icon>
<span class="d-none d-md-inline">Panggil 5 Antrian</span>
</v-btn>
<v-btn
color="orange-lighten-1"
variant="flat"
rounded="xl"
class="text-white elevation-4 mr-2 btn-call-group"
@click="handleCallClick(10)"
>
<v-icon start>mdi-numeric-10-box</v-icon>
<span class="d-none d-md-inline">Panggil 10 Antrian</span>
</v-btn>
<v-btn
color="red-lighten-1"
variant="flat"
rounded="xl"
class="text-white elevation-4 btn-call-group"
@click="handleCallClick(20)"
>
<v-icon start>mdi-numeric-20-box</v-icon>
<span class="d-none d-md-inline">Panggil 20 Antrian</span>
</v-btn>
</div>
</v-card-title>
<!-- Pilihan Show Entries untuk Loket Admin -->
<div class="d-flex justify-end mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show Entries:</span>
<v-select
:items="[10, 25, 50]"
v-model="itemsPerPageLoket"
variant="outlined"
density="compact"
hide-details
class="show-entries-select"
></v-select>
</div>
</div>
<v-table class="mt-3 custom-table rounded-lg elevation-0">
<thead>
<tr>
<th v-for="header in loketHeaders" :key="header.text">
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in paginatedLoketData" :key="index">
<td>{{ item.no }}</td>
<td>{{ item.barcode }}</td>
<td>{{ item.noRekamedik }}</td>
<td>{{ item.noAntrian }}</td>
<td>{{ item.shift }}</td>
<td>{{ item.ket }}</td>
<td>{{ item.fastTrack }}</td>
<td>{{ item.pembayaran }}</td>
<td>
<v-btn size="small" color="primary" class="text-white rounded-lg" @click="handlePanggil(item)">
<v-icon start>mdi-phone-incoming</v-icon>
Panggil
</v-btn>
</td>
<td>
<v-btn size="small" color="red-darken-1" class="text-white rounded-lg" @click="handleBatalkan(item)">
<v-icon start>mdi-close</v-icon>
Batalkan
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div class="d-flex justify-space-between align-center mt-3 pa-2">
<span>
Menampilkan {{ (currentPageLoket - 1) * itemsPerPageLoket + 1 }} hingga
{{ Math.min(currentPageLoket * itemsPerPageLoket, loketData.length) }} dari
{{ loketData.length }} entri
</span>
<div>
<v-btn size="small" variant="flat" :disabled="currentPageLoket === 1" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket - 1)">Previous</v-btn>
<v-btn size="small" variant="flat" :disabled="currentPageLoket >= totalPagesLoket" class="pagination-btn" @click="handlePageChangeLoket(currentPageLoket + 1)">Next</v-btn>
</div>
</div>
</v-card>
<!-- Data Pengunjung Table -->
<v-card class="pa-6 rounded-xl elevation-2">
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
Data Pengunjung: Loket
</v-card-title>
<!-- Pilihan Show Entries untuk Data Pengunjung -->
<div class="d-flex justify-end mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show Entries:</span>
<v-select
:items="[10, 25, 50]"
v-model="itemsPerPagePengunjung"
variant="outlined"
density="compact"
hide-details
class="show-entries-select"
></v-select>
</div>
</div>
<v-table class="mt-3 custom-table rounded-lg elevation-0">
<thead>
<tr>
<th v-for="header in pengunjungHeaders" :key="header.text">
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in paginatedPengunjungData" :key="index">
<td>{{ item.no }}</td>
<td>{{ item.barcode }}</td>
<td>{{ item.noRekamedik }}</td>
<td>{{ item.noAntrian }}</td>
<td>{{ item.noAntrianKlinik }}</td>
<td>{{ item.shift }}</td>
<td>{{ item.pembayaran }}</td>
<td>
<v-chip :color="item.status === 'Selesai' ? 'green' : 'orange'" size="small">
{{ item.status }}
</v-chip>
</td>
</tr>
</tbody>
</v-table>
<div class="d-flex justify-space-between align-center mt-3 pa-2">
<span>
Menampilkan {{ (currentPagePengunjung - 1) * itemsPerPagePengunjung + 1 }} hingga
{{ Math.min(currentPagePengunjung * itemsPerPagePengunjung, pengunjungData.length) }} dari
{{ pengunjungData.length }} entri
</span>
<div>
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung === 1" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung - 1)">Previous</v-btn>
<v-btn size="small" variant="flat" :disabled="currentPagePengunjung >= totalPagesPengunjung" class="pagination-btn" @click="handlePageChangePengunjung(currentPagePengunjung + 1)">Next</v-btn>
</div>
</div>
</v-card>
</v-container>
</v-main>
</template>
<script setup>
import { ref, computed } from "vue";
definePageMeta({
middleware:['auth']
})
// Generate dummy data
const generateDummyData = (count) => {
const data = [];
const shiftOptions = ['Pagi', 'Siang', 'Sore'];
const pembayaranOptions = ['BPJS', 'Umum', 'Asuransi'];
const statusOptions = ['Menunggu', 'Selesai', 'Di-cancel'];
for (let i = 1; i <= count; i++) {
data.push({
no: i,
barcode: `B${1000 + i}`,
noRekamedik: `RM${100 + i}`,
noAntrian: `A${100 + i}`,
noAntrianKlinik: `K${100 + i}`,
shift: shiftOptions[Math.floor(Math.random() * shiftOptions.length)],
ket: "Dummy Data",
fastTrack: Math.random() > 0.5 ? "Ya" : "Tidak",
pembayaran: pembayaranOptions[Math.floor(Math.random() * pembayaranOptions.length)],
status: statusOptions[Math.floor(Math.random() * statusOptions.length)]
});
}
return data;
};
// State for Loket Admin table
const loketData = ref(generateDummyData(50));
const currentPageLoket = ref(1);
const itemsPerPageLoket = ref(10);
// State for Data Pengunjung table
const pengunjungData = ref(generateDummyData(50));
const currentPagePengunjung = ref(1);
const itemsPerPagePengunjung = ref(10);
// Computed properties for Loket Admin table
const paginatedLoketData = computed(() => {
const start = (currentPageLoket.value - 1) * itemsPerPageLoket.value;
const end = start + itemsPerPageLoket.value;
return loketData.value.slice(start, end);
});
const totalPagesLoket = computed(() => {
return Math.ceil(loketData.value.length / itemsPerPageLoket.value);
});
// Computed properties for Data Pengunjung table
const paginatedPengunjungData = computed(() => {
const start = (currentPagePengunjung.value - 1) * itemsPerPagePengunjung.value;
const end = start + itemsPerPagePengunjung.value;
return pengunjungData.value.slice(start, end);
});
const totalPagesPengunjung = computed(() => {
return Math.ceil(pengunjungData.value.length / itemsPerPagePengunjung.value);
});
// Method to handle page change for Loket Admin table
const handlePageChangeLoket = (page) => {
if (page >= 1 && page <= totalPagesLoket.value) {
currentPageLoket.value = page;
}
};
// Method to handle page change for Data Pengunjung table
const handlePageChangePengunjung = (page) => {
if (page >= 1 && page <= totalPagesPengunjung.value) {
currentPagePengunjung.value = page;
}
};
// Methods to handle button clicks (unchanged)
const loketHeaders = [
{ text: 'No' },
{ text: 'Barcode' },
{ text: 'No Rekamedik' },
{ text: 'No Antrian' },
{ text: 'Shift' },
{ text: 'Ket' },
{ text: 'Fast Track' },
{ text: 'Pembayaran' },
{ text: 'Panggil' },
{ text: 'Aksi' },
];
const pengunjungHeaders = [
{ text: 'No' },
{ text: 'Barcode' },
{ text: 'No Rekamedik' },
{ text: 'No Antrian' },
{ text: 'No Antrian Klinik' },
{ text: 'Shift' },
{ text: 'Pembayaran' },
{ text: 'Status' },
];
const handleCallClick = (value) => {
console.log(`Panggil ${value} antrian diklik!`);
};
const handlePanggil = (item) => {
console.log('Panggil pasien:', item);
};
const handleBatalkan = (item) => {
console.log('Batalkan pasien:', item);
};
</script>
<style scoped>
/* Main container padding */
.v-container {
max-width: 1400px;
}
.header-banner {
background: linear-gradient(45deg, #1A237E, #283593); /* Deep blue gradient */
color: white;
}
/* General card styling */
.v-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
/* Call buttons group */
.btn-call-group {
min-width: 50px;
height: 40px !important;
font-weight: bold;
}
/* Table styling */
.custom-table {
border: 1px solid #e0e0e0;
}
.custom-table :deep(th) {
background-color: #f5f5f5;
font-weight: bold;
font-size: 14px;
text-transform: uppercase;
}
.custom-table :deep(tr:hover) {
background-color: #e8eaf6 !important; /* Light blue on hover */
cursor: pointer;
}
.custom-table :deep(tbody tr:nth-of-type(odd)) {
background-color: #fafafa;
}
.pagination-btn {
margin: 0 4px;
background-color: #e0e0e0 !important;
}
/* Custom styling for status chip */
.v-chip.v-chip--size-small {
padding: 4px 8px;
font-size: 0.75rem;
}
.show-entries-select {
max-width: 100px;
}
</style>
+142 -632
View File
@@ -1,78 +1,6 @@
<!-- // Pages/LoginPage.vue -->
<template>
<v-container fluid fill-height class="login-background">
<!-- Navigation Bar -->
<v-app-bar class="navbar" flat>
<v-toolbar-title class="brand-logo">
<v-icon class="mr-2" color="white">mdi-hospital-building</v-icon>
<span class="font-weight-bold text-blue-darken-3">ANTREAN</span> <span class="font-weight-bold" >RSSA</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Navigation with Dropdown Menus -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
color="white"
class="nav-link"
v-bind="props"
>
Tentang
<v-icon right small>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="nav-dropdown">
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-hospital-building</v-icon>
<v-list-item-title>Profil Rumah Sakit</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
color="white"
class="nav-link"
v-bind="props"
>
Kontak
<v-icon right small>mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="nav-dropdown">
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-phone</v-icon>
<v-list-item-title>Hubungi Kami</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-map-marker</v-icon>
<v-list-item-title>Alamat & Lokasi</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-email</v-icon>
<v-list-item-title>Email</v-list-item-title>
</v-list-item>
<v-list-item class="dropdown-item">
<v-icon class="mr-3">mdi-help-circle</v-icon>
<v-list-item-title>Bantuan</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn icon color="white" class="ml-4">
<v-icon>mdi-menu</v-icon>
</v-btn>
</v-app-bar>
<!-- Floating Medical Icons Background -->
<div class="floating-medical-icon icon-1">
<v-icon size="144">mdi-heart-pulse</v-icon>
</div>
@@ -93,168 +21,139 @@
</div>
<v-row class="fill-height align-center justify-center">
<!-- Left Content -->
<v-col cols="12" md="6" class="text-section">
<div class="hero-content">
<div class="logo-section mb-6">
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
<h1 class="hero-title">Sistem Terbaik</h1>
<h1 class="hero-title">Untuk Pelayanan</h1>
<h1 class="hero-title">Kesehatan</h1>
</div>
<p class="hero-description">
Tingkatkan efisiensi layanan rumah sakit dengan sistem antrean RSSA yang canggih dan intuitif.
Dirancang dengan kesederhanaan, keamanan, dan kecepatan, memastikan perjalanan
Anda ke platform kami semudah mungkin. Mari kita buat inovasi menjadi sederhana!
</p>
</div>
</v-col>
<v-col cols="12" class="d-flex justify-center align-center">
<v-card class="main-card white-card rounded-xl pa-0" max-width="450" width="100%">
<v-row class="ma-0">
<v-col cols="12" class="login-section-box d-flex align-center pa-8">
<div class="login-content w-100">
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<!-- Right Login Card -->
<v-col cols="12" md="6" class="d-flex justify-center">
<v-card class="login-card white-card rounded-xl pa-8" max-width="450" width="100%">
<!-- Header -->
<div class="text-center mb-6">
<h2 class="welcome-title-dark">SELAMAT DATANG KEMBALI</h2>
<p class="login-instruction-dark">MASUK UNTUK MELANJUTKAN</p>
</div>
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark">Antrean RSSA</span>
<img
src="/AntreanLogobg.png"
alt="Antrean Logo"
class="mt-3 hospital-logo"
/>
</div>
<!-- Logo Section -->
<div class="d-flex flex-column align-center text-center mb-6">
<span class="text-h5 font-weight-bold app-title-dark text-blue-darken-3">Antrean RSSA</span>
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="Logo Rumah Sakit"
class="mt-3 hospital-logo"
/>
</div>
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<!-- Alert Messages -->
<v-alert v-if="errorMessage" type="error" class="mb-4" dismissible @click:close="errorMessage = ''">
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
<!-- SSO Info -->
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<!-- Keycloak Login Button -->
<v-btn
@click="handleLogin"
class="login-btn"
block
rounded="lg"
size="large"
:loading="isLoading"
:disabled="isLoading"
>
<v-icon left>mdi-shield-key</v-icon>
<span class="font-weight-bold">
{{ isLoading ? 'Connecting to Keycloak...' : 'Login dengan Keycloak' }}
</span>
<v-icon right>mdi-arrow-right</v-icon>
</v-btn>
<v-divider class="my-6 custom-divider-dark"></v-divider>
<!-- Registration Section -->
<v-divider class="my-6 custom-divider-dark"></v-divider>
<div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<div class="text-center mb-6">
<p class="sso-text-dark">Login menggunakan Single Sign-On</p>
</div>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#0053AD"
size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div>
<!-- <div class="text-center">
<v-btn
@click="showRegistrationDialog = true"
class="register-btn-dark"
variant="outlined"
block
rounded="lg"
size="large"
>
<v-icon left>mdi-account-plus</v-icon>
<span class="font-weight-bold">Daftar Akun Baru</span>
</v-btn>
<!-- Password Help -->
<div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div>
<div class="text-center mt-4">
<span class="help-text-dark">
Belum memiliki akun?
</span>
<br>
<v-btn
@click="showAdminContact = true"
variant="text"
color="#FF9B1B" size="small"
class="contact-link-dark mt-1"
>
Hubungi Administrator
</v-btn>
</div>
</div> -->
<!-- <div class="text-center mt-4">
<span class="help-link-dark">Masalah dengan kata sandi Anda?</span>
</div> -->
</div>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
<!-- Registration Information Dialog -->
<v-dialog v-model="showRegistrationDialog" max-width="500">
<!-- <v-dialog v-model="showRegistrationDialog" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#0053AD">mdi-account-plus</v-icon>
Pendaftaran Akun Baru
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#FF9B1B">mdi-account-plus</v-icon> Pendaftaran Akun Baru
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#0053AD" class="mb-4">mdi-information</v-icon>
</div>
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-information</v-icon> </div>
<p class="text-body-1 mb-4">
Untuk mendaftar akun baru pada sistem Antrean RSSA, silakan ikuti langkah berikut:
</p>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-1-circle</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-numeric-1-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Hubungi Administrator IT Rumah Sakit
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-2-circle</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-numeric-2-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Siapkan dokumen identitas dan surat penugasan
</v-list-item-title>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-numeric-3-circle</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-numeric-3-circle</v-icon> </template>
<v-list-item-title class="text-grey-darken-2">
Tunggu proses verifikasi dan aktivasi akun
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@@ -267,34 +166,29 @@
</v-btn>
<v-btn
@click="showRegistrationDialog = false; showAdminContact = true"
color="#0053AD"
rounded
color="#FF9B1B" rounded
>
<v-icon left>mdi-phone</v-icon>
Hubungi Admin
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog> -->
<!-- Admin Contact Dialog -->
<v-dialog v-model="showAdminContact" max-width="500">
<!-- <v-dialog v-model="showAdminContact" max-width="500">
<v-card class="white-dialog rounded-xl">
<v-card-title class="text-h5 text-grey-darken-3 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#0053AD">mdi-account-tie</v-icon>
Kontak Administrator
<v-card-title class="text-h5 text-center pa-6 bg-grey-lighten-4">
<v-icon left color="#FF9B1B">mdi-account-tie</v-icon> Kontak Administrator
</v-card-title>
<v-card-text class="text-grey-darken-2 pa-6">
<div class="text-center mb-4">
<v-icon size="64" color="#0053AD" class="mb-4">mdi-phone-settings</v-icon>
</div>
<v-icon size="64" color="#FF9B1B" class="mb-4">mdi-phone-settings</v-icon> </div>
<v-list class="transparent">
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-office-building</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-office-building</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2 font-weight-bold">
IT Support RSSA
@@ -304,11 +198,10 @@
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-phone</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-phone</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
(0341) 343343 ext. 1234
@@ -318,11 +211,10 @@
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0 mb-2">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-email</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-email</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
it-support@rssa.malang.go.id
@@ -332,11 +224,10 @@
</v-list-item-subtitle>
</div>
</v-list-item>
<v-list-item class="text-grey-darken-2 px-0">
<template v-slot:prepend>
<v-icon color="#0053AD">mdi-clock</v-icon>
</template>
<v-icon color="#FF9B1B">mdi-clock</v-icon> </template>
<div>
<v-list-item-title class="text-grey-darken-2">
Senin - Jumat: 07:00 - 15:00
@@ -347,19 +238,18 @@
</div>
</v-list-item>
</v-list>
<v-alert
type="info"
variant="tonal"
class="mt-4"
color="blue"
>
color="orange" >
<div class="text-grey-darken-2">
<strong>Catatan:</strong> Pendaftaran akun memerlukan verifikasi dokumen dan dapat memakan waktu 1-2 hari kerja.
</div>
</v-alert>
</v-card-text>
<v-card-actions class="pa-6 bg-grey-lighten-5">
<v-spacer></v-spacer>
<v-btn
@@ -372,15 +262,14 @@
</v-btn>
<v-btn
@click="copyContactInfo"
color="#0053AD"
rounded
color="#FF9B1B" rounded
>
<v-icon left>mdi-content-copy</v-icon>
Salin Info
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog> -->
</v-container>
</template>
@@ -401,6 +290,14 @@ const successMessage = ref<string>('')
const showRegistrationDialog = ref<boolean>(false)
const showAdminContact = ref<boolean>(false)
// Hospital Profile URL
const HOSPITAL_PROFILE_URL = 'https://rsusaifulanwar.jatimprov.go.id/v2/'
// Function to navigate to external hospital profile site
const goToHospitalProfile = (): void => {
window.open(HOSPITAL_PROFILE_URL, '_blank')
}
// Check URL parameters for errors
const route = useRoute()
onMounted(() => {
@@ -409,7 +306,7 @@ onMounted(() => {
}
})
// Custom login handler
// Custom login handler (pointing to Keycloak SSO logic)
const handleLogin = async (): Promise<void> => {
isLoading.value = true
errorMessage.value = ''
@@ -417,7 +314,8 @@ const handleLogin = async (): Promise<void> => {
try {
console.log('Starting login process...')
// Call API route to initiate Keycloak login process
const response = await $fetch<LoginResponse>('/api/auth/keycloak-login', {
method: 'POST'
})
@@ -425,7 +323,8 @@ const handleLogin = async (): Promise<void> => {
if (response?.success && response?.data?.authUrl) {
console.log('Redirecting to Keycloak...')
successMessage.value = 'Redirecting to Keycloak...'
// Redirect the user to the Keycloak authorization URL
setTimeout(() => {
window.location.href = response.data!.authUrl
}, 500)
@@ -434,435 +333,46 @@ const handleLogin = async (): Promise<void> => {
}
} catch (error: any) {
console.error('Login error:', error)
// Display the error message
errorMessage.value = `Login failed: ${error.message || 'Please try again.'}`
} finally {
isLoading.value = false
// Only set isLoading back to false if no redirect is happening
if (!successMessage.value.includes('Redirecting')) {
isLoading.value = false
}
}
}
// Copy contact information to clipboard
const copyContactInfo = async (): Promise<void> => {
// Updated contact info block from user's provided code
const contactInfo = `
IT Support RSSA
Telepon: (0341) 343343 ext. 1234
Email: [email protected]
Jam Operasional: Senin - Jumat, 08:00 - 16:00
Jam Operasional: Senin - Jumat, 07:00 - 15:00
`.trim()
try {
// Use the Clipboard API
await navigator.clipboard.writeText(contactInfo)
successMessage.value = 'Informasi kontak berhasil disalin!'
showAdminContact.value = false
// Clear the success message after a short delay
setTimeout(() => {
successMessage.value = ''
}, 3000)
} catch (error) {
console.error('Failed to copy contact info:', error)
// Fallback or simple alert for error
errorMessage.value = 'Gagal menyalin informasi kontak. Silakan coba salin manual.'
setTimeout(() => {
errorMessage.value = ''
}, 3000)
}
}
</script>
<style scoped>
/* Main Background */
.login-background {
background: linear-gradient(135deg, #f1b464 0%, #faa22e 25%, #e49458 50%, #e46f30 75%, #e26450 100%);
min-height: 100vh;
position: relative;
overflow: hidden;
}
/* Navigation Bar */
.navbar {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.brand-logo {
color: white;
font-size: 1.2rem;
}
.nav-link {
color: white !important;
text-transform: none;
font-weight: 500;
}
/* Floating Medical Icons */
.floating-medical-icon {
position: absolute;
animation: dvdBounce 20s linear infinite;
opacity: 0.8;
transition: opacity 0.3s ease;
z-index: 1;
width: fit-content;
height: fit-content;
}
.floating-medical-icon:hover {
opacity: 1;
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important;
}
.icon-1 {
top: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 25s;
animation-name: dvdBounce1;
}
.icon-1 .v-icon {
color: rgba(6, 37, 83, 0.15) !important;
}
.icon-2 {
top: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 30s;
animation-name: dvdBounce2;
}
.icon-2 .v-icon {
color: rgba(15, 180, 70, 0.12) !important;
}
.icon-3 {
bottom: 0%;
left: 0%;
animation-delay: 0s;
animation-duration: 22s;
animation-name: dvdBounce3;
}
.icon-3 .v-icon {
color: rgba(23, 178, 206, 0.18) !important;
}
.icon-4 {
top: 50%;
left: 0%;
animation-delay: 0s;
animation-duration: 28s;
animation-name: dvdBounce4;
}
.icon-4 .v-icon {
color: rgba(223, 8, 8, 0.14) !important;
}
.icon-5 {
bottom: 0%;
right: 0%;
animation-delay: 0s;
animation-duration: 26s;
animation-name: dvdBounce5;
}
.icon-5 .v-icon {
color: rgba(148, 15, 236, 0.16) !important;
}
.icon-6 {
top: 25%;
left: 0%;
animation-delay: 0s;
animation-duration: 24s;
animation-name: dvdBounce6;
}
.icon-6 .v-icon {
color: rgba(87, 48, 5, 0.13) !important;
}
/* DVD Bounce Animation 1 - Top Left to Bottom Right */
@keyframes dvdBounce1 {
0% { transform: translate(0, 0); }
25% { transform: translate(80vw, 70vh); }
50% { transform: translate(20vw, 10vh); }
75% { transform: translate(70vw, 80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 2 - Top Right to Bottom Left */
@keyframes dvdBounce2 {
0% { transform: translate(0, 0); }
25% { transform: translate(-75vw, 60vh); }
50% { transform: translate(-30vw, 20vh); }
75% { transform: translate(-85vw, 75vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 3 - Bottom Left to Top Right */
@keyframes dvdBounce3 {
0% { transform: translate(0, 0); }
25% { transform: translate(70vw, -60vh); }
50% { transform: translate(40vw, -80vh); }
75% { transform: translate(90vw, -30vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 4 - Middle Left across screen */
@keyframes dvdBounce4 {
0% { transform: translate(0, 0); }
16.6% { transform: translate(60vw, -30vh); }
33.3% { transform: translate(90vw, 20vh); }
50% { transform: translate(50vw, 40vh); }
66.6% { transform: translate(10vw, -20vh); }
83.3% { transform: translate(80vw, -40vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 5 - Bottom Right to Top Left */
@keyframes dvdBounce5 {
0% { transform: translate(0, 0); }
25% { transform: translate(-60vw, -70vh); }
50% { transform: translate(-90vw, -20vh); }
75% { transform: translate(-40vw, -80vh); }
100% { transform: translate(0, 0); }
}
/* DVD Bounce Animation 6 - Complex zigzag pattern */
@keyframes dvdBounce6 {
0% { transform: translate(0, 0); }
14.3% { transform: translate(50vw, 30vh); }
28.6% { transform: translate(85vw, -20vh); }
42.9% { transform: translate(30vw, 60vh); }
57.1% { transform: translate(70vw, 10vh); }
71.4% { transform: translate(15vw, 70vh); }
85.7% { transform: translate(80vw, 40vh); }
100% { transform: translate(0, 0); }
}
/* Hero Section */
.text-section {
padding-left: 4rem;
z-index: 2;
}
.hero-content {
max-width: 600px;
}
.hero-title {
color: white;
font-size: 3rem;
font-weight: 900;
line-height: 1.1;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.hero-description {
color: rgba(255, 255, 255, 0.9);
font-size: 1.1rem;
line-height: 1.6;
margin-top: 2rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
/* Login Card - White Background */
.login-card {
z-index: 3;
max-width: 450px;
margin: 2rem;
}
.white-card {
background: white !important;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 20px 50px rgba(0, 0, 0, 0.15),
0 8px 25px rgba(0, 0, 0, 0.1);
}
/* White Dialog Styles */
.white-dialog {
background: white !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
/* Card Header - Dark Text */
.welcome-title-dark {
color: #37474F;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.login-instruction-dark {
color: #546E7A;
font-size: 0.8rem;
letter-spacing: 0.3px;
}
/* App Title - Dark */
.app-title-dark {
color: #0053AD;
text-shadow: none;
}
.hospital-logo {
height: 64px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
.sso-text-dark {
color: #546E7A;
font-size: 0.95rem;
font-weight: 500;
}
/* Buttons */
.login-btn {
background: linear-gradient(135deg, #0053AD 0%, #0663C7 50%, #0671E0 100%) !important;
color: white !important;
border: none;
box-shadow:
0 8px 25px rgba(0, 83, 173, 0.3),
0 4px 12px rgba(0, 83, 173, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
height: 56px;
}
.login-btn:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, #004A9B 0%, #0558B0 50%, #0661CA 100%) !important;
box-shadow:
0 12px 30px rgba(0, 83, 173, 0.4),
0 6px 15px rgba(0, 83, 173, 0.3);
}
.register-btn-dark {
color: #0053AD !important;
border: 2px solid #0053AD !important;
background: transparent !important;
transition: all 0.3s ease;
text-transform: none;
height: 48px;
}
.register-btn-dark:hover {
background: rgba(0, 83, 173, 0.05) !important;
border-color: #0663C7 !important;
transform: translateY(-1px);
}
/* Custom Divider - Dark */
.custom-divider-dark {
border-color: rgba(0, 0, 0, 0.12) !important;
opacity: 1 !important;
}
/* Help Text and Links - Dark */
.help-text-dark {
color: #546E7A;
font-size: 0.9rem;
}
.contact-link-dark {
color: #0053AD !important;
text-decoration: underline;
text-transform: none;
font-size: 0.9rem;
}
.help-link-dark {
color: #78909C;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.help-link-dark:hover {
color: #0053AD;
}
/* Transparent Background */
.transparent {
background: transparent !important;
}
/* Navigation Dropdown Styles */
.nav-dropdown {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
min-width: 220px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-item {
color: #FF9B1B;
transition: all 0.3s ease;
border-radius: 8px;
margin: 4px 8px;
padding: 8px 12px;
}
.dropdown-item:hover {
background: linear-gradient(135deg, #FF9B1B 0%, #FF8F00 100%);
color: white;
transform: translateX(4px);
}
.dropdown-item .v-icon {
transition: all 0.3s ease;
}
.dropdown-item:hover .v-icon {
transform: scale(1.1);
}
.nav-link {
text-transform: none;
font-weight: 500;
transition: all 0.3s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Responsive Design */
@media (max-width: 960px) {
.text-section {
padding-left: 2rem;
text-align: center;
margin-bottom: 2rem;
}
.hero-title {
font-size: 2.5rem;
}
.hero-description {
font-size: 1rem;
}
.login-card {
margin: 1rem;
}
}
@media (max-width: 600px) {
.hero-title {
font-size: 2rem;
}
.text-section {
padding-left: 1rem;
}
}
</style>
<style scoped src="~/assets/styles/loginpage/login.css"></style>
-526
View File
@@ -1,526 +0,0 @@
<template>
<!-- Main Content -->
<v-main class="bg-grey-lighten-3">
<v-container fluid class="pa-6 main-content-padding">
<!-- Header Banner & Stats -->
<v-card class="d-flex justify-space-between align-center pa-5 rounded-xl elevation-4 mb-6 header-banner">
<div class="d-flex align-center">
<v-icon size="40" class="mr-3 text-white">mdi-hospital-box-outline</v-icon>
<span class="text-h4 font-weight-bold text-white">Loket Admin </span>
</div>
<div class="d-flex align-center text-white text-end flex-wrap justify-end">
<span class="mr-4">Loket 24</span>
<span class="mr-4">{{ currentDateLongFormatted }}</span>
<span>{{ currentDateShortFormatted }} - Pelayanan</span>
</div>
</v-card>
<!-- Status Cards Section -->
<v-row class="mb-6">
<!-- Panggil 1 Antrian Card -->
<v-col cols="12" sm="6" md="3">
<v-card
class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(1)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">1</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text>
</v-card>
</v-col>
<!-- Panggil 5 Antrian Card -->
<v-col cols="12" sm="6" md="3">
<v-card
class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(5)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">5</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text>
</v-card>
</v-col>
<!-- Panggil 10 Antrian Card -->
<v-col cols="12" sm="6" md="3">
<v-card
class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(10)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">10</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text>
</v-card>
</v-col>
<!-- Panggil 20 Antrian Card -->
<v-col cols="12" sm="6" md="3">
<v-card
class="pa-4 rounded-xl elevation-2 text-center"
color="#4CAF50"
@click="handleStatusCardClick(20)"
>
<v-card-text class="text-white">
<div class="text-h4 font-weight-bold">20</div>
<div class="text-subtitle-1 mt-1">Panggil</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Next Patient Card -->
<v-card class="next-patient-card d-flex align-center justify-center pa-8 text-center rounded-xl elevation-6 mb-6">
<div class="text-center">
<div class="text-h4 text-white">NEXT PATIENT</div>
<div class="text-h2 font-weight-bold text-white mt-2">UM1001</div>
<v-btn
size="large"
color="#00A896"
class="mt-4 text-white"
@click="handleNextPatientClick"
>
<v-icon start>mdi-arrow-right-circle</v-icon>
Panggil Pasien Selanjutnya
</v-btn>
</div>
</v-card>
<!-- Main Data Table -->
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
Data Pasien
<div class="d-flex align-center">
<span class="mr-2 text-caption">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
variant="solo"
flat
hide-details
class="mx-2 select-items"
></v-select>
<span class="mr-2 text-caption">entries</span>
<v-text-field
v-model="search"
density="compact"
variant="solo"
flat
hide-details
append-inner-icon="mdi-magnify"
label="Search"
style="max-width: 200px"
></v-text-field>
</div>
</v-card-title>
<v-data-table
:headers="mainHeaders"
:items="mainPatients"
:search="search"
:items-per-page="itemsPerPage"
:row-class="getRowClass"
class="custom-table"
>
<!-- Custom template for the 'aksi' column -->
<template v-slot:item.aksi="{ item }">
<div class="d-flex ga-1">
<!-- Show different buttons based on the item's status -->
<template v-if="item.status === 'dipanggil'">
<v-btn size="small" color="primary" variant="flat" class="rounded-lg" @click="handleProsesClick(item)">
<v-icon start>mdi-cogs</v-icon>Proses
</v-btn>
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
</v-btn>
</template>
<template v-else-if="item.status === 'dalam_proses'">
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
</v-btn>
<v-btn size="small" color="warning" variant="flat" class="rounded-lg">
<v-icon start>mdi-pause-circle-outline</v-icon>Tunda
</v-btn>
<v-btn size="small" color="error" variant="flat" class="rounded-lg">
<v-icon start>mdi-close-circle-outline</v-icon>Batal
</v-btn>
</template>
<template v-else>
<v-btn size="small" color="primary" variant="flat" class="rounded-lg">
<v-icon start>mdi-cogs</v-icon>Proses
</v-btn>
<v-btn size="small" color="success" variant="flat" class="rounded-lg">
<v-icon start>mdi-check-circle-outline</v-icon>Selesai
</v-btn>
</template>
</div>
</template>
<!-- Custom template for the 'panggil' column -->
<template v-slot:item.panggil="{ item }">
<v-btn
size="small"
:color="item.status === 'dalam_proses' ? 'grey' : 'info'"
variant="flat"
class="rounded-lg"
@click="handlePanggilClick(item)"
:disabled="item.status === 'dalam_proses'"
>
<v-icon start>mdi-phone</v-icon>Panggil
</v-btn>
</template>
<!-- Custom template for the 'noAntrian' column -->
<template v-slot:item.noAntrian="{ item }">
<span :class="{'online-antrian': item.status === 'dipanggil'}">{{ item.noAntrian }}</span>
</template>
</v-data-table>
</v-card>
<!-- Late Patients Table -->
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
Info Pasien Lapor Terlambat
<div class="d-flex align-center">
<span class="mr-2 text-caption text-orange">KETERANGAN: PASIEN MASUK PADA TANGGAL</span>
<span class="mr-2 text-caption">Show</span>
<v-select
v-model="lateItemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
variant="solo"
flat
hide-details
class="mx-2 select-items"
></v-select>
<span class="mr-2 text-caption">entries</span>
<v-text-field
v-model="lateSearch"
density="compact"
variant="solo"
flat
hide-details
append-inner-icon="mdi-magnify"
label="Search"
style="max-width: 200px"
></v-text-field>
</div>
</v-card-title>
<v-data-table
:headers="lateHeaders"
:items="latePatients"
:search="lateSearch"
:items-per-page="lateItemsPerPage"
class="custom-table"
>
<template v-slot:no-data>
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
</template>
</v-data-table>
</v-card>
<!-- Clinic Entry Patients Table -->
<v-card class="mb-6 pa-6 rounded-xl elevation-2">
<v-card-title class="d-flex justify-space-between align-center text-h5 font-weight-bold pa-0 mb-4">
Info Pasien Masuk Klinik
</v-card-title>
<v-data-table
:headers="clinicHeaders"
:items="clinicPatients"
:search="clinicSearch"
:items-per-page="clinicItemsPerPage"
class="custom-table"
>
<template v-slot:no-data>
<div class="text-center pa-4">Tidak ada data yang tersedia</div>
</template>
</v-data-table>
</v-card>
<!-- Info Klinik Table -->
<v-card class="pa-6 rounded-xl elevation-2">
<v-card-title class="text-h5 font-weight-bold pa-0 mb-4">
Info Klinik
</v-card-title>
<v-data-table
:headers="infoKlinikHeaders"
:items="infoKlinikData"
class="custom-table"
hide-default-footer
disable-pagination
>
<template v-slot:bottom>
<v-card-text class="d-flex justify-end text-right">
<span class="mr-4 font-weight-bold text-h6">Total:</span>
<span class="mr-12 font-weight-bold text-h6 text-primary">{{ totalDapatDipanggil }}</span>
<span class="font-weight-bold text-h6 text-primary">{{ totalShiftBelumBuka }}</span>
</v-card-text>
</template>
</v-data-table>
</v-card>
</v-container>
</v-main>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
definePageMeta({
middleware:['auth']
})
// Reactive data
const search = ref("");
const lateSearch = ref("");
const clinicSearch = ref("");
const itemsPerPage = ref(10);
const lateItemsPerPage = ref(10);
const clinicItemsPerPage = ref(10);
const currentDateLongFormatted = ref("");
const currentDateShortFormatted = ref("");
// Table headers
const mainHeaders = ref([
{ title: "No", value: "no", sortable: false },
{ title: "Jam Panggil", value: "jamPanggil" },
{ title: "Barcode", value: "barcode" },
{ title: "No Antrian", value: "noAntrian" },
{ title: "Shift", value: "shift" },
{ title: "Klinik", value: "klinik" },
{ title: "Fast Track", value: "fastTrack" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Panggil", align: 'center', value: "panggil", sortable: false },
{ title: "Aksi", value: "aksi", sortable: false },
]);
const lateHeaders = ref([
{ title: "No", value: "no", sortable: false },
{ title: "Barcode", value: "barcode" },
{ title: "No Antrian", value: "noAntrian" },
{ title: "Shift", value: "shift" },
{ title: "Klinik", value: "klinik" },
{ title: "Aksi", value: "aksi", sortable: false },
]);
const clinicHeaders = ref([
{ title: "#", value: "no", sortable: false },
{ title: "Barcode", value: "barcode" },
{ title: "No Antrian", value: "noAntrian" },
{ title: "No RM", value: "noRM" },
{ title: "Shift", value: "shift" },
{ title: "Klinik", value: "klinik" },
{ title: "Fast Track", value: "fastTrack" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Aksi", value: "aksi", sortable: false },
]);
const infoKlinikHeaders = ref([
{ title: "#", value: "no" },
{ title: "Klinik", value: "klinik" },
{ title: "Jumlah Shift", value: "jumlahShift" },
{ title: "Quota Per Shift", value: "quotaPerShift" },
{ title: "Status", value: "status" },
{ title: "Dapat Di Panggil", value: "dapatDiPanggil" },
{ title: "Shift Belum Buka", value: "shiftBelumBuka" },
]);
// Sample data with new 'originalAntrian' and 'status' properties
const mainPatients = ref([
{ no: 1, jamPanggil: "11:46", barcode: "250826100362", noAntrian: "UM1002 | Online - 250826100362", originalAntrian: "UM1002", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "dipanggil" },
{ no: 2, jamPanggil: "06:47", barcode: "250826100140", noAntrian: "UM1003", originalAntrian: "UM1003", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 3, jamPanggil: "06:47", barcode: "250826100143", noAntrian: "UM1004", originalAntrian: "UM1004", shift: "Shift 1", klinik: "IPD", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 4, jamPanggil: "06:47", barcode: "250826100500", noAntrian: "UM1005", originalAntrian: "UM1005", shift: "Shift 1", klinik: "MATA", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 5, jamPanggil: "06:47", barcode: "250826100525", noAntrian: "UM1006", originalAntrian: "UM1006", shift: "Shift 1", klinik: "ONKOLOGI", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
{ no: 6, jamPanggil: "06:47", barcode: "250826100536", noAntrian: "UM1007", originalAntrian: "UM1007", shift: "Shift 1", klinik: "THT", fastTrack: "", pembayaran: "UMUM", panggil: "Panggil", status: "" },
]);
// Tambahkan lebih banyak data pasien untuk demonstrasi "Panggil 20"
for (let i = 7; i <= 25; i++) {
mainPatients.value.push({
no: i,
jamPanggil: "07:00",
barcode: `250826100${100 + i}`,
noAntrian: `UM100${i}`,
originalAntrian: `UM100${i}`,
shift: "Shift 1",
klinik: "UMUM",
fastTrack: "",
pembayaran: "UMUM",
panggil: "Panggil",
status: "",
});
}
const latePatients = ref([]);
const clinicPatients = ref([]);
const infoKlinikData = ref([
{ no: 1, klinik: "ANESTESI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 2, klinik: "GERIATRI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 3, klinik: "GIGI DAN MULUT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 4, klinik: "HOM", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 5, klinik: "IPD", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 6, klinik: "JANTUNG", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 7, klinik: "KANDUNGAN", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 8, klinik: "KOMPLEMENTER", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBukan: "-" },
{ no: 9, klinik: "KUL.KEL", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 10, klinik: "MATA", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 11, klinik: "ONKOLOGI", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 12, klinik: "PARU", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 13, klinik: "SARAF", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
{ no: 14, klinik: "THT", jumlahShift: "1 Shift", quotaPerShift: 1000, status: "Buka - Shift 1", dapatDiPanggil: "-", shiftBelumBuka: "-" },
]);
// Computed properties for totals
const totalDapatDipanggil = computed(() => {
return infoKlinikData.value.reduce((total, item) => {
return total + (item.dapatDiPanggil === "-" ? 0 : parseInt(item.dapatDiPanggil));
}, 0);
});
const totalShiftBelumBuka = computed(() => {
return infoKlinikData.value.reduce((total, item) => {
return total + (item.shiftBelumBuka === "-" ? 0 : parseInt(item.shiftBelumBuka));
}, 0);
});
// Methods
const getRowClass = (item) => {
if (item.status === 'dipanggil') {
return 'called-row';
}
return '';
};
const handleStatusCardClick = (count) => {
console.log(`Memanggil ${count} antrean pasien.`);
const updatedPatients = mainPatients.value.map((patient, index) => {
const isCalled = index < count;
return {
...patient,
status: isCalled ? 'dipanggil' : '',
noAntrian: isCalled ? `${patient.originalAntrian} | Online - ${patient.barcode}` : patient.originalAntrian
};
});
mainPatients.value = updatedPatients;
};
const handleNextPatientClick = () => {
console.log("Tombol Panggil Pasien Selanjutnya diklik! (Mengambil pasien pertama dari antrean)");
const updatedPatients = mainPatients.value.map(patient => {
return { ...patient, status: '', noAntrian: patient.originalAntrian };
});
mainPatients.value = updatedPatients;
};
const handlePanggilClick = (item) => {
console.log(`Tombol Panggil untuk pasien: ${item.noAntrian} diklik!`);
// Membuat salinan baru dari seluruh array untuk memicu reaktivitas
const updatedPatients = mainPatients.value.map(p => {
// Jika pasien cocok, buat salinan baru dengan status 'dipanggil' dan tambahkan "Online"
if (p.no === item.no) {
return {
...p,
status: 'dipanggil',
noAntrian: `${p.originalAntrian} | Online - ${p.barcode}`
};
}
// Jika tidak, kembalikan objek pasien aslinya
return p;
});
// Ganti seluruh array data dengan salinan yang baru.
mainPatients.value = updatedPatients;
};
const handleProsesClick = (item) => {
console.log(`Tombol Proses untuk pasien: ${item.noAntrian} diklik!`);
const updatedPatients = mainPatients.value.map(p => {
if (p.no === item.no) {
return {
...p,
status: 'dalam_proses'
};
}
return p;
});
mainPatients.value = updatedPatients;
};
// Mengatur tanggal saat komponen dimuat
onMounted(() => {
const today = new Date();
const optionsLong = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
currentDateLongFormatted.value = today.toLocaleDateString('id-ID', optionsLong);
const optionsShort = { year: 'numeric', month: 'long', day: 'numeric' };
currentDateShortFormatted.value = today.toLocaleDateString('id-ID', optionsShort);
});
</script>
<style scoped>
/* Scoped styles for a cleaner look */
.main-content-padding {
padding-left: 24px !important;
padding-right: 24px !important;
}
/* Header Banner */
.header-banner {
background: linear-gradient(90deg, #1565C0, #1976D2);
color: white;
min-height: 120px;
}
/* Next Patient Card */
.next-patient-card {
background: linear-gradient(45deg, #00A896, #00796B);
color: white;
}
/* Status Cards */
.v-card.text-center {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
}
.v-card.text-center:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
/* Table styling */
.custom-table :deep(thead th) {
background-color: #E8EAF6; /* Light gray-blue header */
font-weight: bold;
}
.custom-table :deep(tbody tr:nth-of-type(odd)) {
background-color: #F5F5F5; /* Light gray for odd rows */
}
/* Highlighted row for "dipanggil" status */
.custom-table :deep(tbody tr.called-row) {
background-color: #998479 !important; /* Light green background */
}
/* Field and Select styling */
.select-items .v-field--variant-solo,
.v-text-field .v-field--variant-solo {
background-color: #ECEFF1;
}
.text-blue {
color: #1976D2 !important;
}
.text-primary {
color: #1976D2 !important;
}
.online-antrian {
font-weight: bold;
color: #1976D2;
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<template>
<v-container fluid class="pa-0">
<div class="pa-4 pb-0">
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding"> <v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
<v-icon left class="mr-2" color="blue-darken-3">mdi-heart-pulse</v-icon>
Monitoring Pasien
</v-toolbar-title>
<v-spacer></v-spacer>
<v-chip color="blue-darken-1" text-color="white" class="font-weight-medium admin-chip"> <v-icon start>mdi-account-tie</v-icon>
Admin
</v-chip>
</v-toolbar>
</div>
<v-container class="mt-6">
<v-card flat class="mb-4">
<v-tabs
v-model="activeTab"
color="primary"
align-tabs="start"
show-arrows
class="mb-4"
>
<v-tab value="all" @click="filterByStatus('all')">
Semua Pasien <v-badge color="grey-lighten-1" :content="totalCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Menunggu Poli" @click="filterByStatus('Menunggu Poli')">
Menunggu Poli <v-badge color="orange-lighten-1" :content="waitingCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Diperiksa Dokter" @click="filterByStatus('Diperiksa Dokter')">
Diperiksa Dokter <v-badge color="green-lighten-1" :content="examiningCount" inline class="ml-2"></v-badge>
</v-tab>
<v-tab value="Selesai Pelayanan" @click="filterByStatus('Selesai Pelayanan')">
Selesai Pelayanan <v-badge color="blue-lighten-1" :content="doneCount" inline class="ml-2"></v-badge>
</v-tab>
</v-tabs>
</v-card>
<v-card class="mb-6 pa-4 elevation-1">
<v-row class="align-center">
<v-col cols="12" sm="3">
<v-text-field label="No. RM" v-model="filters.rm_number" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="3">
<v-text-field label="No. Kode QR" v-model="filters.qr_code" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-text-field label="No. Antrean" v-model="filters.queue_number" variant="outlined" density="compact" hide-details type="number"></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-select label="Layanan" v-model="filters.service" :items="['Klinik Jiwa', 'Radiologi', 'Fisioterapi', 'Klinik Umum']" variant="outlined" density="compact" hide-details clearable></v-select>
</v-col>
<v-col cols="12" sm="2">
<v-btn color="blue-lighten-1" block height="40" @click="applyFilters">Cari</v-btn>
</v-col>
</v-row>
</v-card>
<v-data-table
:headers="headers"
:items="paginatedPatients"
:items-per-page="10"
class="elevation-1"
:search="currentSearchTerm"
>
<template v-slot:item.status="{ item }">
<v-chip :color="getStatusColor(item.status)" size="small" class="font-weight-medium" label>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn size="small" color="orange-darken-1" dark @click="viewPatient(item.id)">
Lihat
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="info" icon="mdi-information-outline">
Tidak ada data pasien untuk ditampilkan.
</v-alert>
</template>
</v-data-table>
</v-container>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
definePageMeta({
middleware:['auth']
})
const router = useRouter();
// === STATE MANAGEMENT ===
const activeTab = ref('all');
// Filter Model: Menampung input dari form pencarian
const filters = ref({
rm_number: '',
qr_code: '',
queue_number: '', // Filter baru
service: null, // Filter Layanan
});
// State untuk menyimpan hasil filtering
const filteredPatientList = ref([]);
const currentSearchTerm = ref('');
// Table Headers
const headers = [
{ title: 'No. RM', key: 'rm_number' },
{ title: 'Nama', key: 'name' },
{ title: 'No. Antrean', key: 'queue_number' },
{ title: 'Layanan', key: 'service' },
{ title: 'Status', key: 'status' },
{ title: 'Aksi', key: 'aksi', sortable: false },
];
// Mock Patient Data
const ALL_PATIENTS_DATA = [
{ id: '11111111', rm_number: '11111111', name: 'Ragil Bayu N.', queue_number: 1, service: 'Klinik Jiwa', status: 'Diperiksa Dokter', qr_code: 'QR123' },
{ id: '22222222', rm_number: '22222222', name: 'Siti Aisyah', queue_number: 5, service: 'Radiologi', status: 'Menunggu Poli', qr_code: 'QR456' },
{ id: '33333333', rm_number: '33333333', name: 'Ahmad Dani', queue_number: 2, service: 'Klinik Jiwa', status: 'Selesai Pelayanan', qr_code: 'QR789' },
{ id: '44444444', rm_number: '44444444', name: 'Dewi Sartika', queue_number: 6, service: 'Fisioterapi', status: 'Menunggu Poli', qr_code: 'QR101' },
{ id: '55555555', rm_number: '55555555', name: 'Joko Susilo', queue_number: 3, service: 'Klinik Umum', status: 'Diperiksa Dokter', qr_code: 'QR112' },
{ id: '66666666', rm_number: '66666666', name: 'Budi Santoso', queue_number: 4, service: 'Radiologi', status: 'Menunggu Poli', qr_code: 'QR131' },
];
// Initialize the filtered list with all data
filteredPatientList.value = [...ALL_PATIENTS_DATA];
// === FILTERING LOGIC ===
// 1. Logic Pencarian Gabungan saat tombol "Cari" ditekan
const applyFilters = () => {
// 1. Filter berdasarkan teks di semua kolom yang relevan (RM, QR, Antrean, Nama)
let textSearch = '';
// Gabungkan semua nilai filter menjadi satu string pencarian
if (filters.value.rm_number) textSearch += filters.value.rm_number.toLowerCase() + ' ';
if (filters.value.qr_code) textSearch += filters.value.qr_code.toLowerCase() + ' ';
if (filters.value.queue_number) textSearch += filters.value.queue_number.toString().toLowerCase() + ' ';
// Vuetify v-data-table memiliki fitur pencarian bawaan (di properti `search`).
// Kita gunakan fitur ini untuk pencarian berbasis teks (No. RM, QR, Antrean).
currentSearchTerm.value = textSearch.trim();
// 2. Filter berdasarkan Layanan (Service), yang tidak didukung oleh `v-data-table` search
let results = ALL_PATIENTS_DATA;
if (filters.value.service) {
results = results.filter(p => p.service === filters.value.service);
}
// Terapkan hasil filter Layanan
filteredPatientList.value = results;
// Reset status tab (penting agar hasil pencarian tampil terlepas dari tab yang aktif)
activeTab.value = 'all';
};
// 2. Logic Filter Status (saat tab ditekan)
const filterByStatus = (status) => {
// Pastikan status tab diperbarui
activeTab.value = status;
// Clear the text search term when switching tabs
currentSearchTerm.value = '';
if (status === 'all') {
filteredPatientList.value = ALL_PATIENTS_DATA;
} else {
filteredPatientList.value = ALL_PATIENTS_DATA.filter(p => p.status === status);
}
// Clear form filters (opsional, tetapi membuat UX lebih jelas)
filters.value.rm_number = '';
filters.value.qr_code = '';
filters.value.queue_number = '';
filters.value.service = null;
};
// === COMPUTED PROPERTIES ===
// Hitungan untuk Badge Tab
const listForCounts = computed(() => {
// Gunakan ALL_PATIENTS_DATA untuk hitungan badge agar hitungan selalu stabil
return ALL_PATIENTS_DATA;
});
const totalCount = computed(() => listForCounts.value.length);
const waitingCount = computed(() => listForCounts.value.filter(p => p.status === 'Menunggu Poli').length);
const examiningCount = computed(() => listForCounts.value.filter(p => p.status === 'Diperiksa Dokter').length);
const doneCount = computed(() => listForCounts.value.filter(p => p.status === 'Selesai Pelayanan').length);
// Data yang ditampilkan di tabel adalah data yang sudah difilter
const paginatedPatients = computed(() => {
// Jika ada filter status yang aktif (setelah menekan tab)
if (activeTab.value !== 'all') {
return filteredPatientList.value.filter(p => p.status === activeTab.value);
}
// Jika tidak ada filter status yang aktif, tampilkan hasil dari applyFilters()
return filteredPatientList.value;
});
// === UTILITIES & NAVIGATION ===
// Utility function to set color based on status
const getStatusColor = (status) => {
if (status === 'Diperiksa Dokter') return 'green-darken-1';
if (status === 'Menunggu Poli') return 'orange-darken-1';
if (status === 'Selesai Pelayanan') return 'blue-darken-2';
return 'grey';
};
// Function to navigate to the detailed patient information page
const viewPatient = (patientId) => {
router.push(`/MonitoringPasien/Pasien/${patientId}`);
};
</script>
<style scoped>
.floating-toolbar {
border-radius: 9999px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
height: 64px !important;
}
/* Jarak PENTING: Tambahkan padding horizontal ke toolbar */
.floating-toolbar.custom-padding {
padding: 0 16px; /* Memberi jarak 16px di kiri dan kanan */
}
/* Penyesuaian agar teks dan ikon kontras dengan soft blue background */
.v-toolbar-title {
margin-left: 0 !important; /* Reset default margin */
}
/* Pastikan chip Admin tidak mepet */
.admin-chip {
margin-right: 0 !important; /* Reset default margin */
}
.v-tab.v-tab--selected {
font-weight: 600;
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<v-container fluid class="pa-0">
<div class="pa-4 pb-0">
<v-toolbar flat color="#B3E5FC" class="floating-toolbar custom-padding">
<v-toolbar-title class="text-h6 font-weight-bold text-blue-darken-3">
<v-icon left class="mr-2" color="blue-darken-3">mdi-account-details</v-icon>
Informasi Pasien
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="orange-darken-1"
dark
variant="flat"
size="small"
@click="$router.go(-1)"
prepend-icon="mdi-arrow-left"
class="admin-chip"
>
Kembali
</v-btn>
</v-toolbar>
</div>
<v-container class="mt-6">
<v-card class="mb-6 pa-6 elevation-4 rounded-lg" color="white">
<v-row class="align-center mb-4">
<v-col cols="12" md="8">
<div class="d-flex align-center mb-2">
<v-icon size="36" color="blue-darken-2" class="mr-3">mdi-account-circle</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Nama Pasien</div>
<div class="text-h5 font-weight-black text-blue-darken-4">{{ patient.name }}</div>
</div>
</div>
<div class="d-flex align-center">
<v-icon size="24" color="grey-darken-1" class="mr-3">mdi-numeric</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Nomor Rekam Medis</div>
<v-chip color="orange-darken-1" size="large" class="font-weight-bold">{{ patient.rm_number }}</v-chip>
</div>
</div>
</v-col>
<v-col cols="12" md="4" class="d-flex justify-md-end">
<v-chip :color="patient.status === 'Aktif' ? 'green-darken-1' : 'grey-darken-1'" size="x-large" class="font-weight-bold px-6 py-2">
<v-icon start>mdi-progress-check</v-icon>
Status: {{ patient.status }}
</v-chip>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<v-row>
<v-col cols="12" sm="6" md="4" class="py-1">
<div class="d-flex align-center">
<v-icon class="mr-3" color="light-blue-darken-2">mdi-calendar</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Tanggal Lahir</div>
<div class="font-weight-medium text-subtitle-1">{{ patient.dob }}</div>
</div>
</div>
</v-col>
<v-col cols="12" sm="6" md="4" class="py-1">
<div class="d-flex align-center">
<v-icon class="mr-3" color="light-blue-darken-2">mdi-phone</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">No Telepon</div>
<div class="font-weight-medium text-subtitle-1">{{ patient.phone }}</div>
</div>
</div>
</v-col>
<v-col cols="12" sm="12" md="4" class="py-1">
<div class="d-flex align-center">
<v-icon class="mr-3" color="light-blue-darken-2">mdi-map-marker</v-icon>
<div>
<div class="text-caption text-uppercase text-grey-darken-1">Alamat</div>
<div class="font-weight-medium text-subtitle-1">{{ patient.address }}</div>
</div>
</div>
</v-col>
</v-row>
</v-card>
<h2 class="text-h5 mb-4 font-weight-bold text-blue-darken-3">Tiket Pelayanan Aktif</h2>
<v-row class="ticket-row">
<v-col
cols="12"
sm="6"
md="4"
lg="3"
v-for="ticket in patient.activeTickets"
:key="ticket.title"
>
<MonitorPasienTicketCard
:title="ticket.title"
:color="ticket.color"
:steps="ticket.steps"
:current-step-label="ticket.currentStepLabel"
/>
</v-col>
<v-col v-if="!patient.activeTickets || patient.activeTickets.length === 0" cols="12">
<v-card class="pa-6 text-center" variant="tonal" color="grey-lighten-2">
<v-icon size="48" color="grey">mdi-ticket-off-outline</v-icon>
<div class="text-subtitle-1 text-grey-darken-1 mt-2">Pasien ini tidak memiliki tiket pelayanan aktif saat ini.</div>
</v-card>
</v-col>
</v-row>
</v-container>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
definePageMeta({
middleware:['auth']
})
// ===============================================
// JSON MOCK DATA STRUCTURE (Tetap sama)
// ===============================================
const mockPatientData = [
{
id: '11111111',
name: 'Ragil Bayu N. Wibowo',
rm_number: '11111111',
dob: '1 Juni 1967',
phone: '0851-4563-2145',
address: 'Jalan Sumpah Pemuda No. 12, Jakarta',
status: 'Aktif',
activeTickets: [
// 1. TIKET JIWA
{
title: 'Klinik JIWA (Antrean: 1)',
color: 'orange-darken-1',
currentStepLabel: 'Klinik Jiwa',
steps: [
{ label: 'Daftar', date: '15-8-2023', time: '15.01' },
{ label: 'Check in', date: '15-8-2023', time: '07.58' },
{ label: 'Loket 1/3', date: '15-8-2023', time: '08.51' },
{ label: 'Klinik Jiwa', date: '15-8-2023', time: '09.45' },
{ label: 'Apotek', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 2. TIKET RADIOLOGI
{
title: 'Penunjang RADIOLOGI (Antrean: 5)',
color: 'blue-darken-2',
currentStepLabel: 'Loket 5/6/7',
steps: [
{ label: 'Daftar', date: '15-8-2023', time: '15.01' },
{ label: 'Check in', date: '15-8-2023', time: '07.58' },
{ label: 'Loket 5/6/7', date: '15-8-2023', time: '08.51' },
{ label: 'Tunggu Radiologi', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 3. TIKET GIZI
{
title: 'Klinik GIZI (Antrean: 12)',
color: 'purple-darken-1',
currentStepLabel: 'Konsultasi Gizi',
steps: [
{ label: 'Daftar', date: '16-8-2023', time: '07.00' },
{ label: 'Tunggu Panggilan', date: '16-8-2023', time: '07.20' },
{ label: 'Konsultasi Gizi', date: '16-8-2023', time: '07.45' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 4. TIKET FISIOTERAPI
{
title: 'Fisioterapi (Antrean: 2)',
color: 'teal-darken-1',
currentStepLabel: 'Daftar',
steps: [
{ label: 'Daftar', date: '16-8-2023', time: '10.00' },
{ label: 'Fisioterapi', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
// 5. TIKET LABORATORIUM
{
title: 'Penunjang LABORATORIUM (Antrean: 7)',
color: 'red-darken-2',
currentStepLabel: 'Pemeriksaan Sampel',
steps: [
{ label: 'Daftar', date: '17-8-2023', time: '06.30' },
{ label: 'Administrasi', date: '17-8-2023', time: '06.40' },
{ label: 'Tunggu Panggilan', date: '17-8-2023', time: '06.50' },
{ label: 'Pengambilan Sampel', date: '17-8-2023', time: '07.10' },
{ label: 'Pemeriksaan Sampel', date: '17-8-2023', time: '07.30' },
{ label: 'Input Hasil', date: '-', time: '-' },
{ label: 'Verifikasi Dokter', date: '-', time: '-' },
{ label: 'Ambil Hasil', date: '-', time: '-' },
{ label: 'Selesai', date: '-', time: '-' },
]
},
]
},
{
id: '33333333',
name: 'Joko Susilo',
rm_number: '33333333',
dob: '20 Desember 1995',
phone: '0878-1122-3344',
address: 'Jl. Pahlawan No. 5, Surabaya',
status: 'Tidak Aktif',
activeTickets: []
},
];
// ===============================================
// MAIN SCRIPT LOGIC
// ===============================================
const route = useRoute();
const patientId = route.params.id;
const patient = ref({
name: 'Memuat...',
address: 'Memuat...',
phone: 'Memuat...',
rm_number: patientId,
dob: 'Memuat...',
status: 'Memuat',
activeTickets: []
});
const fetchPatientData = () => {
const data = mockPatientData.find(p => p.id === patientId);
if (data) {
patient.value = data;
} else {
patient.value = {
name: 'Data Pasien Tidak Ditemukan',
address: '-',
phone: '-',
rm_number: patientId,
dob: '-',
status: 'Error',
activeTickets: []
};
}
};
onMounted(fetchPatientData);
</script>
<style scoped>
/* =============================================== */
/* FLOATING TOOLBAR STYLES */
/* =============================================== */
.floating-toolbar {
border-radius: 9999px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15);
height: 64px !important;
}
.floating-toolbar.custom-padding {
padding: 0 16px;
}
.admin-chip {
margin-right: 0 !important;
}
/* =============================================== */
/* TICKET CARD ALIGNMENT STYLES (Fix Tinggi Card) */
/* =============================================== */
.ticket-row {
/* Mengaktifkan flex container pada row */
display: flex;
/* Agar item (v-col) menyesuaikan tinggi item tertinggi */
align-items: stretch;
}
/* Pastikan v-col children juga menggunakan full height */
.ticket-row > .v-col {
display: flex;
flex-direction: column;
}
/* =============================================== */
/* TIMELINE STYLES (Jika ada) */
/* =============================================== */
.v-timeline-item {
padding-bottom: 8px !important;
}
</style>
File diff suppressed because it is too large Load diff
+800
View File
@@ -0,0 +1,800 @@
<template>
<v-container fluid class="pa-0">
<!-- Hero Header Section -->
<div class="hero-header">
<v-container class="py-8">
<div class="d-flex align-center">
<v-icon color="white" size="40" class="mr-3">mdi-account-circle</v-icon>
<div>
<h1 class="text-h4 font-weight-bold text-white mb-1">Profil Saya</h1>
<p class="text-body-1 text-white opacity-90">Kelola informasi profil dan pengaturan akun Anda</p>
</div>
</div>
</v-container>
</div>
<v-container class="content-container pb-12">
<v-row>
<!-- Left Sidebar - Profile Card -->
<v-col cols="12" lg="4">
<v-card class="rounded-xl elevation-8 sticky-card profile-card">
<div class="text-center profile-content">
<div class="profile-avatar-container">
<v-avatar size="140" class="profile-avatar elevation-8">
<v-img
:src="profileData.picture || 'https://i.pravatar.cc/300?img=68'"
alt="Profile Picture"
></v-img>
</v-avatar>
<v-btn
icon
size="small"
color="orange-darken-2"
class="avatar-edit-btn elevation-4"
@click="openPhotoDialog"
>
<v-icon size="18">mdi-camera</v-icon>
</v-btn>
</div>
<h2 class="text-h5 font-weight-bold mb-2">{{ profileData.name }}</h2>
<p class="text-body-2 text-grey-darken-1 mb-1">@{{ profileData.username }}</p>
<p class="text-body-2 text-grey mb-4">{{ profileData.email }}</p>
<v-chip
color="success"
variant="flat"
size="small"
prepend-icon="mdi-check-circle"
class="mb-4"
>
Akun Terverifikasi
</v-chip>
<v-divider class="my-4"></v-divider>
<!-- Quick Info -->
<div class="text-left px-6">
<div class="info-item mb-3">
<v-icon size="20" color="blue-darken-2" class="mr-2">mdi-identifier</v-icon>
<span class="text-body-2 text-grey-darken-1">ID: {{ profileData.id }}</span>
</div>
<div class="info-item mb-3">
<v-icon size="20" color="orange-darken-2" class="mr-2">mdi-calendar-check</v-icon>
<span class="text-body-2 text-grey-darken-1">Bergabung: {{ profileData.joinDate }}</span>
</div>
<div class="info-item">
<v-icon size="20" color="green-darken-2" class="mr-2">mdi-clock-outline</v-icon>
<span class="text-body-2 text-grey-darken-1">Login: {{ profileData.lastLogin }}</span>
</div>
</div>
</div>
<v-card-actions class="pa-4">
<v-btn
color="blue-darken-2"
variant="outlined"
block
class="rounded-lg"
prepend-icon="mdi-cog"
@click="navigateTo('/Profile/Pengaturan')"
>
Pengaturan Akun
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<!-- Right Content - Profile Details -->
<v-col cols="12" lg="8">
<!-- Personal Information -->
<v-card class="rounded-xl elevation-4 mb-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="blue-darken-2" class="mr-2">mdi-account-details</v-icon>
<span class="font-weight-bold">Informasi Pribadi</span>
<v-spacer></v-spacer>
<v-btn
v-if="!isEditing"
color="orange-darken-2"
variant="flat"
size="small"
class="rounded-lg"
prepend-icon="mdi-pencil"
@click="startEdit"
>
Edit
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-form ref="profileForm">
<v-row>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">NAMA LENGKAP</label>
<v-text-field
v-model="profileData.name"
placeholder="Masukkan nama lengkap"
prepend-inner-icon="mdi-account"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">USERNAME</label>
<v-text-field
v-model="profileData.username"
placeholder="Masukkan username"
prepend-inner-icon="mdi-at"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">EMAIL</label>
<v-text-field
v-model="profileData.email"
placeholder="Masukkan email"
prepend-inner-icon="mdi-email"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">NOMOR TELEPON</label>
<v-text-field
v-model="profileData.phone"
placeholder="Masukkan nomor telepon"
prepend-inner-icon="mdi-phone"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">BIO</label>
<v-textarea
v-model="profileData.bio"
placeholder="Ceritakan tentang diri Anda"
prepend-inner-icon="mdi-text"
variant="outlined"
color="blue-darken-2"
rows="3"
:readonly="!isEditing"
hide-details="auto"
></v-textarea>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider v-if="isEditing"></v-divider>
<v-card-actions v-if="isEditing" class="pa-6 pt-4">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
color="grey-darken-1"
class="rounded-lg px-6"
@click="cancelEdit"
>
Batal
</v-btn>
<v-btn
color="blue-darken-2"
variant="flat"
class="rounded-lg px-8"
prepend-icon="mdi-check"
@click="saveProfile"
:loading="isSaving"
>
Simpan Perubahan
</v-btn>
</v-card-actions>
</v-card>
<!-- Security Section -->
<v-card class="rounded-xl elevation-4 mb-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="orange-darken-2" class="mr-2">mdi-shield-check</v-icon>
<span class="font-weight-bold">Keamanan</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-list class="transparent">
<v-list-item
class="rounded-lg mb-2 px-4"
prepend-icon="mdi-lock-reset"
@click="openPasswordDialog"
>
<v-list-item-title class="font-weight-medium">Ubah Password</v-list-item-title>
<v-list-item-subtitle class="text-caption">Perbarui password akun Anda</v-list-item-subtitle>
<template v-slot:append>
<v-icon color="grey">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<v-list-item
class="rounded-lg mb-2 px-4"
prepend-icon="mdi-two-factor-authentication"
>
<v-list-item-title class="font-weight-medium">Autentikasi Dua Faktor</v-list-item-title>
<v-list-item-subtitle class="text-caption">Tingkatkan keamanan akun</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="twoFactorEnabled"
color="blue-darken-2"
hide-details
inset
@change="toggleTwoFactor"
></v-switch>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-4"
prepend-icon="mdi-devices"
@click="openDevicesDialog"
>
<v-list-item-title class="font-weight-medium">Perangkat Aktif</v-list-item-title>
<v-list-item-subtitle class="text-caption">Kelola perangkat yang terhubung</v-list-item-subtitle>
<template v-slot:append>
<v-chip size="small" color="success" variant="flat">{{ activeSessions.length }} Perangkat</v-chip>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<!-- Preferences Section -->
<v-card class="rounded-xl elevation-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="blue-darken-2" class="mr-2">mdi-tune</v-icon>
<span class="font-weight-bold">Preferensi</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-list class="transparent">
<v-list-item class="rounded-lg mb-2 px-4" prepend-icon="mdi-bell">
<v-list-item-title class="font-weight-medium">Notifikasi Email</v-list-item-title>
<v-list-item-subtitle class="text-caption">Terima update via email</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="emailNotifications"
color="orange-darken-2"
hide-details
inset
></v-switch>
</template>
</v-list-item>
<v-list-item class="rounded-lg mb-2 px-4" prepend-icon="mdi-theme-light-dark">
<v-list-item-title class="font-weight-medium">Tema Gelap</v-list-item-title>
<v-list-item-subtitle class="text-caption">Aktifkan mode gelap</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="darkMode"
color="blue-darken-2"
hide-details
inset
></v-switch>
</template>
</v-list-item>
<v-list-item class="rounded-lg px-4" prepend-icon="mdi-web">
<v-list-item-title class="font-weight-medium">Bahasa</v-list-item-title>
<v-list-item-subtitle class="text-caption">Indonesia</v-list-item-subtitle>
<template v-slot:append>
<v-icon color="grey">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
Change Password Dialog
<v-dialog v-model="passwordDialog" max-width="500" persistent>
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
<div class="d-flex align-center">
<v-icon color="orange-darken-2" class="mr-2">mdi-lock-reset</v-icon>
<span class="font-weight-bold">Ubah Password</span>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-form ref="passwordForm">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">PASSWORD SAAT INI</label>
<v-text-field
v-model="passwordData.current"
type="password"
prepend-inner-icon="mdi-lock"
variant="outlined"
density="comfortable"
color="blue-darken-2"
hide-details="auto"
class="mb-4"
></v-text-field>
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">PASSWORD BARU</label>
<v-text-field
v-model="passwordData.new"
type="password"
prepend-inner-icon="mdi-lock-plus"
variant="outlined"
density="comfortable"
color="blue-darken-2"
hide-details="auto"
class="mb-4"
></v-text-field>
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">KONFIRMASI PASSWORD</label>
<v-text-field
v-model="passwordData.confirm"
type="password"
prepend-inner-icon="mdi-lock-check"
variant="outlined"
density="comfortable"
color="blue-darken-2"
hide-details="auto"
></v-text-field>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 pt-4">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
color="grey-darken-1"
class="rounded-lg"
@click="passwordDialog = false"
>
Batal
</v-btn>
<v-btn
color="orange-darken-2"
variant="flat"
class="rounded-lg px-6"
@click="changePassword"
:loading="isChangingPassword"
>
Ubah Password
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Active Devices Dialog -->
<v-dialog v-model="devicesDialog" max-width="700" scrollable>
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
<div class="d-flex align-center">
<v-icon color="blue-darken-2" class="mr-2">mdi-devices</v-icon>
<span class="font-weight-bold">Perangkat Aktif</span>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-0" style="max-height: 500px;">
<v-list class="py-0">
<v-list-item
v-for="(session, index) in activeSessions"
:key="index"
class="py-4 px-6"
>
<template v-slot:prepend>
<v-avatar :color="session.current ? 'success' : 'blue-grey-lighten-4'" size="48">
<v-icon :color="session.current ? 'white' : 'blue-grey-darken-2'">
{{ session.icon }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold mb-1">
{{ session.device }}
<v-chip v-if="session.current" size="x-small" color="success" class="ml-2">
Sesi Ini
</v-chip>
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
<div>{{ session.location }}</div>
<div class="text-grey-darken-1 mt-1">
<v-icon size="12">mdi-clock-outline</v-icon>
{{ session.lastActive }}
</div>
</v-list-item-subtitle>
<template v-slot:append v-if="!session.current">
<v-btn
icon="mdi-close-circle"
size="small"
variant="text"
color="error"
@click="removeSession(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 pt-4">
<v-btn
color="error"
variant="outlined"
class="rounded-lg"
prepend-icon="mdi-logout-variant"
@click="logoutAllDevices"
>
Keluar dari Semua Perangkat
</v-btn>
<v-spacer></v-spacer>
<v-btn
variant="text"
class="rounded-lg"
@click="devicesDialog = false"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Photo Upload Dialog -->
<v-dialog v-model="photoDialog" max-width="400">
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
<div class="d-flex align-center">
<v-icon color="blue-darken-2" class="mr-2">mdi-camera</v-icon>
<span class="font-weight-bold">Ubah Foto Profil</span>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6 text-center">
<v-avatar size="150" class="mb-4">
<v-img :src="profileData.picture"></v-img>
</v-avatar>
<v-file-input
label="Pilih foto baru"
variant="outlined"
prepend-icon=""
prepend-inner-icon="mdi-image"
accept="image/*"
hide-details
></v-file-input>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-6 pt-4">
<v-btn
color="error"
variant="text"
class="rounded-lg"
>
Hapus Foto
</v-btn>
<v-spacer></v-spacer>
<v-btn
variant="outlined"
color="grey-darken-1"
class="rounded-lg"
@click="photoDialog = false"
>
Batal
</v-btn>
<v-btn
color="blue-darken-2"
variant="flat"
class="rounded-lg px-6"
>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Success Snackbar -->
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
location="top"
timeout="3000"
class="snackbar-custom"
>
<div class="d-flex align-center">
<v-icon class="mr-2">{{ snackbarIcon }}</v-icon>
<span>{{ snackbarMessage }}</span>
</div>
<template v-slot:actions>
<v-btn
variant="text"
size="small"
icon="mdi-close"
@click="snackbar = false"
></v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import { navigateTo } from '#app';
definePageMeta({
middleware: 'auth'
});
const isEditing = ref(false);
const isSaving = ref(false);
const isChangingPassword = ref(false);
const snackbar = ref(false);
const snackbarMessage = ref('');
const snackbarColor = ref('success');
const passwordDialog = ref(false);
const devicesDialog = ref(false);
const photoDialog = ref(false);
const twoFactorEnabled = ref(false);
const emailNotifications = ref(true);
const darkMode = ref(false);
const snackbarIcon = computed(() => {
return snackbarColor.value === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle';
});
const profileData = reactive({
id: 'USR-12345678',
name: 'John Doe',
username: 'johndoe',
email: '[email protected]',
phone: '+62 812 3456 7890',
bio: 'Passionate developer and tech enthusiast. Love building beautiful and functional web applications.',
picture: 'https://i.pravatar.cc/300?img=68',
joinDate: '15 Januari 2024',
lastLogin: '16 Oktober 2025, 10:30 AM'
});
const passwordData = reactive({
current: '',
new: '',
confirm: ''
});
const activeSessions = ref([
{
device: 'Chrome on Windows 11',
location: 'Sidoarjo, Indonesia',
lastActive: 'Sekarang',
icon: 'mdi-laptop',
current: true
},
{
device: 'Mobile App on Android',
location: 'Surabaya, Indonesia',
lastActive: '2 jam yang lalu',
icon: 'mdi-cellphone',
current: false
},
{
device: 'Safari on macOS',
location: 'Jakarta, Indonesia',
lastActive: '1 hari yang lalu',
icon: 'mdi-laptop',
current: false
}
]);
const originalData = ref({});
const startEdit = () => {
originalData.value = { ...profileData };
isEditing.value = true;
};
const cancelEdit = () => {
Object.assign(profileData, originalData.value);
isEditing.value = false;
};
const saveProfile = async () => {
isSaving.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 1000));
// TODO: Replace with actual API call
// await $fetch('/api/profile/update', {
// method: 'PUT',
// body: profileData
// });
originalData.value = { ...profileData };
isEditing.value = false;
snackbarMessage.value = 'Profil berhasil diperbarui!';
snackbarColor.value = 'success';
snackbar.value = true;
} catch (error) {
snackbarMessage.value = 'Gagal memperbarui profil!';
snackbarColor.value = 'error';
snackbar.value = true;
} finally {
isSaving.value = false;
}
};
const openPhotoDialog = () => {
photoDialog.value = true;
};
const openPasswordDialog = () => {
passwordDialog.value = true;
passwordData.current = '';
passwordData.new = '';
passwordData.confirm = '';
};
const changePassword = async () => {
if (passwordData.new !== passwordData.confirm) {
snackbarMessage.value = 'Password baru tidak cocok!';
snackbarColor.value = 'error';
snackbar.value = true;
return;
}
isChangingPassword.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 1000));
// TODO: Replace with actual API call
passwordDialog.value = false;
snackbarMessage.value = 'Password berhasil diubah!';
snackbarColor.value = 'success';
snackbar.value = true;
} catch (error) {
snackbarMessage.value = 'Gagal mengubah password!';
snackbarColor.value = 'error';
snackbar.value = true;
} finally {
isChangingPassword.value = false;
}
};
const openDevicesDialog = () => {
devicesDialog.value = true;
};
const removeSession = (index) => {
activeSessions.value.splice(index, 1);
snackbarMessage.value = 'Perangkat berhasil dihapus dari sesi aktif';
snackbarColor.value = 'success';
snackbar.value = true;
};
const logoutAllDevices = () => {
// Keep only current session
activeSessions.value = activeSessions.value.filter(s => s.current);
devicesDialog.value = false;
snackbarMessage.value = 'Berhasil keluar dari semua perangkat lain';
snackbarColor.value = 'success';
snackbar.value = true;
};
const toggleTwoFactor = () => {
const status = twoFactorEnabled.value ? 'diaktifkan' : 'dinonaktifkan';
snackbarMessage.value = `Autentikasi dua faktor ${status}`;
snackbarColor.value = 'info';
snackbar.value = true;
};
// Initialize original data
originalData.value = { ...profileData };
</script>
<style scoped>
.hero-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 50%, #f57c00 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 1;
}
.content-container {
margin-top: 40px;
}
.sticky-card {
position: sticky;
top: 80px;
}
.profile-card {
overflow: visible;
}
.profile-content {
padding-top: 0;
padding-bottom: 20px;
}
.profile-avatar-container {
position: relative;
display: inline-block;
margin-top: -70px;
margin-bottom: 20px;
}
.profile-avatar {
border: 6px solid white;
background: white;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.avatar-edit-btn {
position: absolute;
bottom: 8px;
right: 8px;
width: 32px;
height: 32px;
}
.stat-item {
text-align: center;
}
.info-item {
display: flex;
align-items: center;
}
label {
letter-spacing: 0.5px;
}
.v-list-item {
transition: background-color 0.2s;
}
.v-list-item:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.snackbar-custom {
font-weight: 500;
}
@media (max-width: 1280px) {
.sticky-card {
position: relative;
top: 0;
}
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<template>
<v-container>
<v-card>
<v-card-title>Edit Loket</v-card-title>
<v-card-text>
<v-form @submit.prevent="simpanLoket">
<v-text-field label="Nama Loket" v-model="loket.namaLoket"></v-text-field>
<v-text-field label="Kuota Bangku" v-model="loket.kuota" type="number"></v-text-field>
<v-select
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
v-model="loket.statusPelayanan"
></v-select>
<v-select
label="Pembayaran"
:items="['JKN', 'UMUM']"
v-model="loket.pembayaran"
></v-select>
<v-select
label="Keterangan"
:items="['ONLINE', 'MANUAL']"
v-model="loket.keterangan"
></v-select>
<div class="my-4">
<h3 class="text-h6">Pelayanan</h3>
<TabelLayanan
:headers="serviceHeaders"
:items="availableServices"
v-model:selected-items="loket.pelayanan"
/>
</div>
<v-btn color="success" type="submit" class="mr-2">Simpan</v-btn>
<v-btn color="secondary" @click="cancelEdit">Batal</v-btn>
</v-form>
</v-card-text>
</v-card>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import TabelLayanan from '../../components/TabelLayanan.vue';
const route = useRoute();
const router = useRouter();
// Data dummy loket yang sama seperti di Master-Loket.vue
const loketData = ref([
{ id: 1, no: 1, namaLoket: 'Loket 1', kuota: 500, pelayanan: ['RADIOTERAPI', 'REHAB MEDIK', 'TINDAKAN'], pembayaran: 'JKN', keterangan: 'ONLINE' },
{ id: 2, no: 2, namaLoket: 'Loket 2', kuota: 666, pelayanan: ['JIWA', 'SARAF'], pembayaran: 'JKN', keterangan: 'ONLINE' },
{ id: 3, no: 3, namaLoket: 'Loket 3', kuota: 666, pelayanan: ['ANESTESI', 'JANTUNG'], pembayaran: 'JKN', keterangan: 'ONLINE' },
{ id: 4, no: 4, namaLoket: 'Loket 4', kuota: 3676, pelayanan: ['KULIT KELAMIN', 'PARU'], pembayaran: 'JKN', keterangan: 'ONLINE' },
]);
const loket = ref({
id: null,
namaLoket: '',
kuota: 0,
statusPelayanan: '',
pembayaran: '',
keterangan: '',
pelayanan: [],
});
const serviceHeaders = ref([
{ title: '#', value: 'no', sortable: false },
{ title: 'Kode', value: 'id' },
{ title: 'Klinik', value: 'nama' },
{ title: 'Pilih', value: 'pilih', sortable: false },
]);
const availableServices = ref([
{ no: 1, id: 'AN', nama: 'ANAK' },
{ no: 2, id: 'AS', nama: 'ANESTESI' },
{ no: 3, id: 'BD', nama: 'BEDAH' },
{ no: 4, id: 'GR', nama: 'GERIATRI' },
{ no: 5, id: 'GI', nama: 'GIGI DAN MULUT' },
{ no: 6, id: 'GZ', nama: 'GIZI' },
{ no: 7, id: 'HO', nama: 'HOM' },
{ no: 8, id: 'IP', nama: 'IPD' },
]);
onMounted(() => {
// Cari loket yang sesuai dengan ID di URL
const selectedLoket = loketData.value.find(loket => loket.id === parseInt(route.params.id));
if (selectedLoket) {
// Jika data ditemukan, salin ke objek loket
loket.value = { ...selectedLoket };
// Konversi string pelayanan menjadi array untuk checkbox
if (typeof loket.value.pelayanan === 'string') {
loket.value.pelayanan = loket.value.pelayanan.split(', ').map(s => s.trim());
}
}
});
const simpanLoket = () => {
// Dalam aplikasi nyata, ini adalah tempat untuk memanggil API update data
// Untuk simulasi, kita akan kembali ke halaman master
router.back();
};
const cancelEdit = () => {
router.back();
};
</script>
+1 -1
View File
@@ -289,7 +289,7 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useLocalStorage, useSessionStorage } from '@vueuse/core';
import { VueDraggableNext } from 'vue-draggable-next';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import { useNavItemsStore } from '@/stores/navItems';
import { useNavItemsStore } from '~/stores/navItems1';
definePageMeta({
middleware: ['auth']
File diff suppressed because it is too large Load diff
+622 -333
View File
@@ -1,376 +1,665 @@
<template>
<div class="master-klinik-ruang-page pa-4">
<!-- Breadcrumbs -->
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<!-- Tampilan Formulir Tambah/Edit/View -->
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Ruang Klinik' : readOnly ? 'Detail Ruang Klinik' : 'Tambah Ruang Klinik' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama Klinik</v-label>
<v-select
v-model="editedItem.namaKlinik"
:items="['ANAK', 'ANESTESI', 'BEDAH', 'GERIATRI', 'GIGI DAN MULUT', 'GIZI', 'HOM', 'IPD', 'JANTUNG', 'JIWA', 'KANDUNGAN', 'KEMOTERAPI', 'KOMPLEMENTER', 'KUL KEL', 'MATA', 'MCU', 'ONKOLOGI', 'PARU', 'R. TINDAKAN', 'RADIOTERAPI', 'REHAB MEDIK', 'SARAF', 'THT']"
placeholder="Pilih Nama Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kode Ruang</v-label>
<v-text-field
v-model="editedItem.kode"
placeholder="Masukkan Kode Ruang"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Nama Ruang</v-label>
<v-text-field
v-model="editedItem.namaRuang"
placeholder="Masukkan Nama Ruang"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<!-- Tampilan Tabel Data -->
<div v-else>
<!-- Banner biru sebagai pengganti header h1 -->
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-hospital-box-outline" class="mr-2" size="40"></v-icon>
<span>Master Klinik Ruang</span>
</v-card-title>
<v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showForm = true"
>
Tambah Baru
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-text>
<!-- Table controls -->
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
<div class="d-flex align-center">
<span class="mr-2 text-subtitle-1">Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50]"
variant="outlined"
density="compact"
hide-details
style="max-width: 80px;"
rounded
class="mr-2"
></v-select>
<span class="text-subtitle-1">entries</span>
<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-door-open</v-icon>
</div>
<div class="d-flex align-center">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search"
variant="outlined"
density="compact"
hide-details
rounded
clearable
></v-text-field>
<div class="header-text">
<h2 class="page-title">Master Klinik Ruang</h2>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Manajemen Ruangan</p>
</div>
</div>
<div class="header-stats">
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-hospital-building</v-icon>
{{ masterStore.totalKlinikRuang }} Klinik
</v-chip>
<v-chip color="white" variant="flat" class="stat-chip mr-2">
<v-icon start size="16">mdi-door</v-icon>
{{ masterStore.totalRuangan }} Ruang
</v-chip>
<v-btn
color="white"
@click="openTambahDialog"
elevation="0"
class="add-btn"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Ruang
</v-btn>
</div>
</div>
</div>
<!-- Data Table dengan paginasi kustom -->
<v-data-table
:headers="headers"
:items="allRuangData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
class="rounded-lg elevation-0 custom-table"
>
<!-- Slot untuk aksi di setiap baris -->
<template v-slot:[`item.actions`]="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<!-- Slot kustom untuk footer tabel (paginasi) -->
<template v-slot:bottom>
<v-row class="ma-2">
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
{{ showingEntriesText }}
</v-col>
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
<v-pagination
v-model="page"
:length="pageCount"
rounded="circle"
:total-visible="5"
></v-pagination>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<!-- Delete Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card class="pa-4 rounded-lg">
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data ini?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
</v-card-actions>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="headers"
:items="masterStore.ruangData"
:items-per-page="10"
class="elevation-0 data-table"
>
<template v-slot:item.namaKlinik="{ item }">
<v-chip size="small" class="chip-success-outline">
{{ item.kodeKlinik }}
</v-chip>
<span class="ml-2 body-3 text-medium">{{ item.namaKlinik }}</span>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
@click="openEditDialog(item)"
class="btn-edit mr-2"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
@click="handleDelete(item)"
class="btn-delete"
variant="outlined"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-dialog>
<!-- Dialog Tambah/Edit -->
<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 Klinik Ruang' : 'Tambah Klinik Ruang' }}</span>
<v-btn icon variant="text" @click="closeDialog" size="small" class="btn-close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Pilih Klinik -->
<div class="field-group">
<div class="group-label">
<v-icon size="18" class="icon-label">mdi-hospital-building</v-icon>
<span>Pilih Klinik</span>
</div>
<v-autocomplete
label="Kode Klinik"
v-model="formData.kodeKlinik"
:items="masterStore.klinikList"
item-title="nama"
item-value="kode"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kode klinik harus dipilih']"
hide-details="auto"
placeholder="Pilih Klinik"
@update:model-value="onKlinikChange"
class="mb-3 input-field"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-chip size="x-small" class="chip-success-small">{{ item.raw.kode }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-autocomplete>
</div>
<v-divider class="my-4 divider-section"></v-divider>
<!-- Daftar Ruangan -->
<div class="field-group">
<div class="group-label">
<v-icon size="18" class="icon-label">mdi-door</v-icon>
<span>Daftar Ruangan</span>
</div>
<div v-for="(ruang, index) in formData.ruangList" :key="index" class="ruang-item">
<v-row dense align="center">
<v-col cols="1">
<div class="ruang-badge body-3">{{ index + 1 }}</div>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Ruang"
v-model="ruang.nomorRuang"
variant="outlined"
density="compact"
hide-details
placeholder="1"
class="input-field"
></v-text-field>
</v-col>
<v-col cols="4">
<v-text-field
label="Nama Ruang"
v-model="ruang.namaRuang"
variant="outlined"
density="compact"
hide-details
placeholder="Ruang Konsultasi"
class="input-field"
></v-text-field>
</v-col>
<v-col cols="3">
<v-text-field
label="No. Screen"
v-model="ruang.nomorScreen"
variant="outlined"
density="compact"
hide-details
placeholder="101"
class="input-field"
></v-text-field>
</v-col>
<v-col cols="1">
<v-btn
v-if="formData.ruangList.length > 1"
icon
size="small"
variant="text"
@click="removeRuang(index)"
class="btn-delete-ruang"
>
<v-icon size="18">mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
<v-btn
variant="outlined"
size="small"
@click="addRuang"
class="btn-add-ruang mt-2"
>
<v-icon left size="18">mdi-plus</v-icon>
Tambah Ruang
</v-btn>
</div>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="dialog-actions">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
@click="closeDialog"
class="btn-cancel"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
@click="submitForm"
class="btn-submit"
>
<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 v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false" size="small">Tutup</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
definePageMeta({
middleware:['auth']
})
const masterStore = useMasterStore();
const dialog = ref(false);
const isEdit = ref(false);
const formRef = ref(null);
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
// Data dummy untuk Master Klinik Ruang
const allRuangData = ref([
{ id: 1, namaKlinik: 'ANAK', kode: 'AN-01', namaRuang: 'RUANG ANAK 1' },
{ id: 2, namaKlinik: 'ANAK', kode: 'AN-02', namaRuang: 'RUANG ANAK 2' },
{ id: 3, namaKlinik: 'ANESTESI', kode: 'AS-01', namaRuang: 'RUANG ANESTESI 1' },
{ id: 4, namaKlinik: 'BEDAH', kode: 'BD-01', namaRuang: 'RUANG BEDAH 1' },
{ id: 5, namaKlinik: 'GIGI DAN MULUT', kode: 'GI-01', namaRuang: 'RUANG GIGI DAN MULUT 1' },
{ id: 6, namaKlinik: 'GERIATRI', kode: 'GR-01', namaRuang: 'RUANG GERIATRI 1' },
{ id: 7, namaKlinik: 'GIZI', kode: 'GZ-01', namaRuang: 'RUANG GIZI 1' },
{ id: 8, namaKlinik: 'HOM', kode: 'HO-01', namaRuang: 'RUANG HOM 1' },
{ id: 9, namaKlinik: 'IPD', kode: 'IP-01', namaRuang: 'RUANG IPD 1' },
{ id: 10, namaKlinik: 'JANTUNG', kode: 'JT-01', namaRuang: 'RUANG JANTUNG 1' },
{ id: 11, namaKlinik: 'JIWA', kode: 'JW-01', namaRuang: 'RUANG JIWA 1' },
{ id: 12, namaKlinik: 'KANDUNGAN', kode: 'OB-01', namaRuang: 'RUANG KANDUNGAN 1' },
{ id: 13, namaKlinik: 'KANDUNGAN', kode: 'OB-02', namaRuang: 'RUANG KANDUNGAN 2' },
{ id: 14, namaKlinik: 'KEMOTERAPI', kode: 'KT-01', namaRuang: 'RUANG KEMOTERAPI 1' },
{ id: 15, namaKlinik: 'KOMPLEMENTER', kode: 'KN-01', namaRuang: 'RUANG KOMPLEMENTER 1' },
{ id: 16, namaKlinik: 'KUL KEL', kode: 'KL-01', namaRuang: 'RUANG KUL KEL 1' },
{ id: 17, namaKlinik: 'MATA', kode: 'MT-01', namaRuang: 'RUANG MATA 1' },
{ id: 18, namaKlinik: 'MCU', kode: 'MC-01', namaRuang: 'RUANG MCU 1' },
{ id: 19, namaKlinik: 'ONKOLOGI', kode: 'ON-01', namaRuang: 'RUANG ONKOLOGI 1' },
{ id: 20, namaKlinik: 'PARU', kode: 'PR-01', namaRuang: 'RUANG PARU 1' },
{ id: 21, namaKlinik: 'R. TINDAKAN', kode: 'RT-01', namaRuang: 'RUANG R. TINDAKAN 1' },
{ id: 22, namaKlinik: 'RADIOTERAPI', kode: 'RD-01', namaRuang: 'RUANG RADIOTERAPI 1' },
{ id: 23, namaKlinik: 'REHAB MEDIK', kode: 'RM-01', namaRuang: 'RUANG REHAB MEDIK 1' },
{ id: 24, namaKlinik: 'SARAF', kode: 'NU-01', namaRuang: 'RUANG SARAF 1' },
{ id: 25, namaKlinik: 'THT', kode: 'TH-01', namaRuang: 'RUANG THT 1' },
]);
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
{ title: 'Kode Ruang', key: 'kode', sortable: true },
{ title: 'Nama Ruang', key: 'namaRuang', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
{ title: 'No', value: 'no', sortable: true },
{ title: 'Nama Klinik', value: 'namaKlinik', sortable: true },
{ title: 'Kode', value: 'kodeKlinik', sortable: true },
{ title: 'Nama Ruang', value: 'namaRuang', sortable: true },
{ title: 'Aksi', value: 'aksi', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
]);
// State untuk tabel dan paginasi
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allRuangData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allRuangData.value.length);
const total = allRuangData.value.length;
return `Showing ${start} to ${end} of ${total} entries`;
});
// State untuk dialog
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const editedItem = ref({
id: 0,
const formData = ref({
id: null,
kodeKlinik: '',
namaKlinik: '',
kode: '',
namaRuang: '',
ruangList: [
{ nomorRuang: '', namaRuang: '', nomorScreen: '' }
],
});
// Fungsi untuk tombol aksi
const viewItem = (item) => {
editedItem.value = { ...item };
readOnly.value = true;
isEditMode.value = false;
showForm.value = true;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
{ title: 'Detail Ruang Klinik', disabled: true, href: '/setting/viewruang' },
];
};
const editItem = (item) => {
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
{ title: 'Edit Ruang Klinik', disabled: true, href: '/setting/editruang' },
];
};
const deleteItem = (item) => {
editedIndex.value = allRuangData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allRuangData.value.splice(editedIndex.value, 1);
const onKlinikChange = (kode) => {
const selectedKlinik = masterStore.getKlinikByKode(kode);
if (selectedKlinik) {
formData.value.namaKlinik = selectedKlinik.nama;
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
namaKlinik: '',
kode: '',
const addRuang = () => {
formData.value.ruangList.push({
nomorRuang: '',
namaRuang: '',
};
editedIndex.value = -1;
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik Ruang', disabled: false, href: '/setting/masterklinikruang' },
];
nomorScreen: ''
});
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
const removeRuang = (index) => {
if (formData.value.ruangList.length > 1) {
formData.value.ruangList.splice(index, 1);
}
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allRuangData.value[editedIndex.value], editedItem.value);
const openTambahDialog = () => {
isEdit.value = false;
resetForm();
dialog.value = true;
};
const openEditDialog = (item) => {
isEdit.value = true;
formData.value = {
id: item.id,
kodeKlinik: item.kodeKlinik,
namaKlinik: item.namaKlinik,
ruangList: JSON.parse(JSON.stringify(item.ruangList)),
};
dialog.value = true;
};
const closeDialog = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
formData.value = {
id: null,
kodeKlinik: '',
namaKlinik: '',
ruangList: [
{ nomorRuang: '', namaRuang: '', nomorScreen: '' }
],
};
if (formRef.value) {
formRef.value.reset();
}
};
const submitForm = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
if (formData.value.ruangList.length === 0) {
snackbar.value = {
show: true,
message: 'Tambahkan minimal 1 ruangan',
color: 'warning'
};
return;
}
let result;
if (isEdit.value) {
result = masterStore.updateRuang(formData.value);
} else {
const newId = allRuangData.value.length > 0 ? Math.max(...allRuangData.value.map(item => item.id)) + 1 : 1;
editedItem.value.id = newId;
allRuangData.value.push(editedItem.value);
result = masterStore.addRuang(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 ruangan ${item.namaRuang} dari klinik ${item.namaKlinik}?`)) {
const result = masterStore.deleteRuang(item.id);
snackbar.value = {
show: true,
message: result.message,
color: result.success ? 'success' : 'error'
};
}
cancelForm();
};
</script>
<style scoped>
.master-klinik-ruang-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
<style scoped lang="scss">
// Colors from Design System
$neutral-900: #212121;
$neutral-800: #4D4D4D;
$neutral-700: #717171;
$neutral-600: #89939E;
$neutral-500: #ABBED1;
$neutral-400: #E5F7FA;
$neutral-300: #F5F7FA;
$neutral-100: #FFFFFF;
$success-700: #1B6E53;
$success-600: #009262;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
.custom-table {
border: none;
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(0, 146, 98, 0.2);
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: $neutral-100;
}
.v-data-table :deep(td) {
vertical-align: middle;
.header-left {
display: flex;
align-items: center;
}
.v-btn--icon {
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 16px;
margin-right: 20px;
backdrop-filter: blur(10px);
}
.page-title {
font-size: 36px;
line-height: 44px;
font-weight: $font-weight-semibold;
margin: 0;
color: $neutral-100;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
color: $neutral-100;
}
.header-stats {
display: flex;
align-items: center;
gap: 8px;
}
.stat-chip {
color: $success-600 !important;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.add-btn {
font-weight: $font-weight-semibold;
text-transform: none;
letter-spacing: 0.5px;
font-size: 16px;
line-height: 24px;
color: $success-600 !important;
}
// ============================================
// DATA TABLE
// ============================================
.data-table {
font-family: $font-family-base;
}
.chip-success-outline {
border: 1px solid $success-600;
background-color: transparent !important;
color: $success-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// DIALOG
// ============================================
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
// ============================================
// FORM ELEMENTS
// ============================================
.field-group {
background: $neutral-100;
padding: 20px;
border-radius: 12px;
margin-bottom: 0;
border: 1px solid $neutral-400;
}
.group-label {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $success-600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.icon-label {
color: $success-600 !important;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
// ============================================
// CHIPS
// ============================================
.chip-success-small {
background-color: $success-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
// ============================================
// RUANG ITEMS
// ============================================
.ruang-item {
background: $neutral-300;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid $neutral-500;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
.ruang-badge {
background: linear-gradient(135deg, $success-600 0%, $success-700 100%);
color: $neutral-100;
padding: 8px;
border-radius: 6px;
text-align: center;
font-weight: $font-weight-semibold;
}
</style>
.btn-delete-ruang {
color: $danger-600 !important;
}
.btn-add-ruang {
border-color: $success-600 !important;
color: $success-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// 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: $success-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.header-stats {
width: 100%;
flex-wrap: wrap;
}
}
</style>
+637
View File
@@ -0,0 +1,637 @@
<!-- eslint-disable vue/valid-v-slot -->
<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-view-dashboard</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Master Loket</h2>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</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 Loket
</v-btn>
</div>
</div>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="loketHeaders"
:items="masterStore.loketData"
:items-per-page="10"
class="elevation-0 data-table"
>
<template #item.pelayanan="{ item }">
<v-chip
v-for="(serviceKode, idx) in item.pelayanan.slice(0, 2)"
:key="idx"
size="small"
class="mr-1 mb-1 chip-primary-outline"
>
{{ masterStore.getKlinikNameByKode(serviceKode) }}
</v-chip>
<v-chip
v-if="item.pelayanan.length > 2"
size="small"
class="chip-neutral"
>
+{{ item.pelayanan.length - 2 }}
</v-chip>
</template>
<template #item.aksi="{ item }">
<v-btn
size="small"
variant="flat"
class="btn-edit mr-2"
@click="openEditDialog(item)"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
variant="outlined"
class="btn-delete"
@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 -->
<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 Loket' : 'Tambah Loket Baru' }}</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">
<v-form ref="formRef">
<!-- Informasi Loket -->
<div class="field-group">
<div class="group-label">Informasi Loket</div>
<v-text-field
v-model="formData.namaLoket"
label="Nama Loket"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama loket harus diisi']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Loket 1"
/>
<v-text-field
v-model.number="formData.kuota"
label="Kuota Bangku"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota minimal 1']"
hide-details="auto"
class="mb-3 input-field"
placeholder="500"
/>
</div>
<v-divider class="my-4 divider-section"/>
<!-- Konfigurasi -->
<div class="field-group">
<div class="group-label">Konfigurasi</div>
<v-select
v-model="formData.statusPelayanan"
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
hide-details="auto"
class="mb-3 input-field"
/>
<v-row dense>
<v-col cols="6">
<v-select
v-model="formData.pembayaran"
label="Pembayaran"
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Pembayaran harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="formData.keterangan"
label="Keterangan"
:items="['ONLINE', 'OFFLINE']"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Keterangan harus dipilih']"
hide-details="auto"
class="input-field"
/>
</v-col>
</v-row>
</div>
<v-divider class="my-4 divider-section"/>
<!-- Pelayanan -->
<div class="field-group">
<div class="group-label">
<v-icon size="18" class="icon-label">mdi-hospital-box</v-icon>
<span>Pelayanan</span>
</div>
<v-select
v-model="formData.pelayanan"
label="Pilih Pelayanan"
:items="masterStore.availableServices"
item-title="nama"
item-value="id"
variant="outlined"
density="compact"
multiple
chips
closable-chips
:rules="[v => v.length > 0 || 'Pilih minimal 1 pelayanan']"
hide-details="auto"
class="input-field"
>
<template #chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
class="chip-primary"
>
{{ item.raw.nama }}
</v-chip>
</template>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<v-chip size="x-small" class="chip-primary-small">{{ item.raw.id }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<!-- Selected Services Preview -->
<div v-if="formData.pelayanan.length > 0" class="selected-preview">
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
<small class="caption-2">{{ formData.pelayanan.length }} pelayanan dipilih</small>
</div>
</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 loketHeaders = ref([
{ title: "No", value: "no" },
{ title: "Nama Loket", value: "namaLoket" },
{ title: "Kuota", value: "kuota" },
{ title: "Pelayanan", value: "pelayanan" },
{ title: "Pembayaran", value: "pembayaran" },
{ title: "Keterangan", value: "keterangan" },
{ title: "Aksi", value: "aksi", sortable: false },
]);
const formData = ref({
id: null,
namaLoket: '',
kuota: null,
statusPelayanan: '',
pembayaran: '',
keterangan: '',
pelayanan: [],
});
const openTambahDialog = () => {
isEdit.value = false;
resetForm();
dialog.value = true;
};
const openEditDialog = (item) => {
isEdit.value = true;
formData.value = {
id: item.id,
namaLoket: item.namaLoket,
kuota: item.kuota,
statusPelayanan: item.statusPelayanan || 'RAWAT JALAN',
pembayaran: item.pembayaran,
keterangan: item.keterangan,
pelayanan: [...item.pelayanan],
};
dialog.value = true;
};
const closeDialog = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
formData.value = {
id: null,
namaLoket: '',
kuota: null,
statusPelayanan: '',
pembayaran: '',
keterangan: '',
pelayanan: [],
};
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.updateLoket(formData.value);
} else {
result = masterStore.addLoket(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 loket ${item.namaLoket}?`)) {
const result = masterStore.deleteLoket(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: #FF9B1B;
$primary-600: #FFA532;
$primary-300: #FFDCAF;
$secondary-600: #0671E0;
$success-600: #009262;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(255, 165, 50, 0.2);
}
.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-primary-outline {
border: 1px solid $primary-600;
background-color: transparent !important;
color: $primary-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.chip-neutral {
background-color: $neutral-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $primary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
.btn-delete {
border-color: $danger-600 !important;
color: $danger-600 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 14px;
line-height: 20px;
}
// ============================================
// DIALOG
// ============================================
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
// ============================================
// FORM ELEMENTS
// ============================================
.field-group {
background: $neutral-100;
padding: 20px;
border-radius: 12px;
margin-bottom: 0;
border: 1px solid $neutral-400;
}
.group-label {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $primary-600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.icon-label {
color: $primary-600 !important;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
color: $neutral-700;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
// ============================================
// CHIPS
// ============================================
.chip-primary {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-primary-small {
background-color: $primary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
// ============================================
// SELECTED PREVIEW
// ============================================
.selected-preview {
margin-top: 8px;
padding: 8px 12px;
background: $success-200;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
// ============================================
// 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;
}
</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>
+667
View File
@@ -0,0 +1,667 @@
<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-monitor</v-icon>
</div>
<div class="header-text">
<h2 class="page-title">Screen Configuration</h2>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Konfigurasi Layar Antrian</p>
</div>
</div>
<v-btn
color="white"
@click="openTambahDialog"
elevation="0"
class="add-btn"
>
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Screen
</v-btn>
</div>
</div>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="headers"
:items="screenItems"
:items-per-page="10"
class="elevation-0 data-table"
>
<template v-slot:item.no="{ index }">
{{ index + 1 }}
</template>
<template v-slot:item.klinik="{ item }">
<div class="klinik-tags">
<v-chip
v-for="(klinikKode, idx) in item.klinik.slice(0, 3)"
:key="idx"
size="small"
class="ma-1 chip-secondary-outline"
>
{{ masterStore.getKlinikNameByKode(klinikKode) }}
</v-chip>
<v-chip
v-if="item.klinik.length > 3"
size="small"
class="ma-1 chip-neutral"
>
+{{ item.klinik.length - 3 }}
</v-chip>
</div>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
size="small"
@click="openEditDialog(item)"
variant="flat"
class="btn-edit mr-2"
>
<v-icon size="16" left>mdi-cog</v-icon>
Konfigurasi
</v-btn>
<v-btn
size="small"
@click="handleDelete(item)"
variant="outlined"
class="btn-delete"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- Dialog Tambah/Edit -->
<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 ? 'Konfigurasi Screen' : 'Tambah Screen Baru' }}</span>
<v-btn icon variant="text" @click="closeDialog" size="small" class="btn-close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="dialog-content">
<v-form ref="formRef">
<!-- Informasi Screen -->
<div class="field-group">
<div class="group-label">Informasi Screen</div>
<v-text-field
label="Nama Screen"
v-model="formData.namaScreen"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama screen harus diisi']"
hide-details="auto"
class="mb-3 input-field"
placeholder="Layar Screen 1"
></v-text-field>
<v-text-field
label="Nomor Screen"
v-model="formData.nomorScreen"
variant="outlined"
density="compact"
hide-details="auto"
class="mb-3 input-field"
placeholder="Screen-001"
></v-text-field>
<small class="caption-2">Nomor identifikasi unik untuk screen</small>
</div>
<v-divider class="my-4 divider-section"></v-divider>
<!-- Klinik Selection -->
<div class="field-group">
<div class="group-label">
<v-icon size="18" class="icon-label">mdi-hospital-box</v-icon>
<span>Klinik yang Ditampilkan</span>
</div>
<v-select
label="Pilih Klinik"
:items="masterStore.klinikList"
v-model="formData.klinik"
item-title="nama"
item-value="kode"
variant="outlined"
density="compact"
multiple
chips
closable-chips
:rules="[v => v.length > 0 || 'Pilih minimal 1 klinik']"
hide-details="auto"
class="input-field"
>
<template v-slot:chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
class="chip-secondary"
>
{{ item.raw.nama }}
</v-chip>
</template>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-chip size="x-small" class="chip-secondary-small">{{ item.raw.kode }}</v-chip>
</template>
<v-list-item-title class="body-3">{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<!-- Selected Preview -->
<div v-if="formData.klinik.length > 0" class="selected-preview">
<v-icon size="14" class="icon-success">mdi-check-circle</v-icon>
<small class="caption-2">{{ formData.klinik.length }} klinik dipilih</small>
</div>
<!-- Grid Preview -->
<div v-if="formData.klinik.length > 0" class="klinik-grid mt-4">
<div class="grid-label">Preview Tampilan:</div>
<div class="klinik-preview-grid">
<v-chip
v-for="kode in formData.klinik"
:key="kode"
size="small"
class="ma-1 chip-secondary-outline"
>
{{ masterStore.getKlinikNameByKode(kode) }}
</v-chip>
</div>
</div>
</div>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="dialog-actions">
<v-spacer></v-spacer>
<v-btn
variant="outlined"
@click="closeDialog"
class="btn-cancel"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
variant="flat"
@click="submitForm"
class="btn-submit"
>
<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 v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false" size="small">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: false, width: "80px" },
{ 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: "auto" },
]);
const screenItems = ref([
{
id: 1,
namaScreen: "Layar Screen 1",
nomorScreen: "SCR-001",
klinik: ["AN", "AS", "BD", "GI", "GR", "GZ", "IP", "JT"],
},
{
id: 2,
namaScreen: "Layar Screen 2",
nomorScreen: "SCR-002",
klinik: ["JW", "KK", "MT", "SR", "OB", "PR"],
},
{
id: 3,
namaScreen: "Layar Screen 3",
nomorScreen: "SCR-003",
klinik: ["RT", "RM", "HO"],
},
]);
const formData = ref({
id: null,
namaScreen: '',
nomorScreen: '',
klinik: [],
});
const openTambahDialog = () => {
isEdit.value = false;
resetForm();
dialog.value = true;
};
const openEditDialog = (item) => {
isEdit.value = true;
formData.value = {
id: item.id,
namaScreen: item.namaScreen,
nomorScreen: item.nomorScreen,
klinik: [...item.klinik],
};
dialog.value = true;
};
const closeDialog = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
formData.value = {
id: null,
namaScreen: '',
nomorScreen: '',
klinik: [],
};
if (formRef.value) {
formRef.value.reset();
}
};
const submitForm = async () => {
const { valid } = await formRef.value.validate();
if (!valid) return;
if (isEdit.value) {
// Update screen
const index = screenItems.value.findIndex(s => s.id === formData.value.id);
if (index !== -1) {
screenItems.value[index] = {
...screenItems.value[index],
...formData.value,
};
snackbar.value = {
show: true,
message: `Konfigurasi ${formData.value.namaScreen} berhasil disimpan`,
color: 'success'
};
}
} else {
// Add new screen
const newId = Math.max(...screenItems.value.map(s => s.id), 0) + 1;
screenItems.value.push({
id: newId,
...formData.value,
});
snackbar.value = {
show: true,
message: `Screen ${formData.value.namaScreen} berhasil ditambahkan`,
color: 'success'
};
}
closeDialog();
};
const handleDelete = (item) => {
if (confirm(`Hapus screen ${item.namaScreen}?`)) {
const index = screenItems.value.findIndex(s => s.id === item.id);
if (index !== -1) {
screenItems.value.splice(index, 1);
snackbar.value = {
show: true,
message: `Screen ${item.namaScreen} berhasil dihapus`,
color: 'success'
};
}
}
};
</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;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$secondary-300: #DBEDFF;
$success-600: #009262;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-600: #E02B1D;
// Font Family & Weights
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Apply font family
* {
font-family: $font-family-base;
}
// ============================================
// PAGE HEADER
// ============================================
.page-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(6, 113, 224, 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: $secondary-600 !important;
}
// ============================================
// DATA TABLE
// ============================================
.data-table {
font-family: $font-family-base;
}
.klinik-tags {
display: flex;
flex-wrap: wrap;
max-width: 600px;
}
.chip-secondary-outline {
border: 1px solid $secondary-600;
background-color: transparent !important;
color: $secondary-600 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.chip-neutral {
background-color: $neutral-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
font-size: 12px;
line-height: 16px;
}
.btn-edit {
background-color: $secondary-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;
}
// ============================================
// DIALOG
// ============================================
.dialog-card {
font-family: $font-family-base;
}
.dialog-header {
background: linear-gradient(135deg, $secondary-600 0%, $secondary-700 100%);
color: $neutral-100;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: $font-weight-semibold;
margin: 0;
}
.btn-close {
color: $neutral-100 !important;
}
.dialog-content {
padding: 24px !important;
background: $neutral-300;
}
.dialog-actions {
padding: 16px 24px;
background: $neutral-300;
}
// ============================================
// FORM ELEMENTS
// ============================================
.field-group {
background: $neutral-100;
padding: 20px;
border-radius: 12px;
margin-bottom: 0;
border: 1px solid $neutral-400;
}
.group-label {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-semibold;
color: $secondary-600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.icon-label {
color: $secondary-600 !important;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-regular;
color: $neutral-700;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: $font-weight-regular;
}
.input-field {
font-size: 14px;
line-height: 20px;
}
.divider-section {
border-color: $neutral-400 !important;
}
// ============================================
// CHIPS
// ============================================
.chip-secondary {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-medium;
}
.chip-secondary-small {
background-color: $secondary-600 !important;
color: $neutral-100 !important;
font-weight: $font-weight-semibold;
font-size: 12px;
line-height: 16px;
}
// ============================================
// SELECTED PREVIEW
// ============================================
.selected-preview {
margin-top: 8px;
padding: 8px 12px;
background: $success-200;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid $success-300;
}
.icon-success {
color: $success-600 !important;
}
.klinik-grid {
background: $neutral-300;
padding: 16px;
border-radius: 8px;
border: 1px solid $neutral-400;
}
.grid-label {
font-size: 12px;
line-height: 16px;
font-weight: $font-weight-semibold;
color: $neutral-700;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.klinik-preview-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
// ============================================
// 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: $secondary-600 !important;
color: $neutral-100 !important;
text-transform: none;
font-weight: $font-weight-semibold;
font-size: 16px;
line-height: 24px;
min-width: 100px;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="screen-edit">
<!-- Header -->
<div class="d-flex align-center mb-4">
<v-btn icon="mdi-arrow-left" @click="goBack" class="mr-2"></v-btn>
<h2>Edit Screen</h2>
</div>
<!-- Screen Selection Cards -->
<div class="screen-cards mb-6">
<v-row>
<v-col cols="12" md="4" v-for="screen in screens" :key="screen.id">
<v-card
:class="['screen-card', { active: selectedScreen?.id === screen.id }]"
@click="selectScreen(screen)"
elevation="2"
>
<v-card-title class="text-center">
{{ screen.nama }}
</v-card-title>
</v-card>
</v-col>
</v-row>
</div>
<!-- Selected Screen Display -->
<div v-if="selectedScreen" class="mb-4">
<v-chip color="primary" size="large">
Selected: {{ selectedScreen.nama }}
</v-chip>
</div>
<!-- Clinic Selection Table -->
<TabelLayanan
v-if="selectedScreen"
:headers="computedHeaders"
:items="klinikItems"
:selectedItems="selectedKlinik"
@update:selectedItems="updateSelectedKlinik"
/>
<!-- Action Buttons -->
<div class="d-flex justify-end gap-4 mt-6">
<v-btn variant="outlined" @click="cancel">
Cancel
</v-btn>
<v-btn color="warning" @click="submit" :disabled="!selectedScreen">
Submit
</v-btn>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import TabelLayanan from '~/components/TabelLayanan.vue';
// Get route parameter
const route = useRoute();
const screenId = route.params.id;
// Data
const selectedScreen = ref(null);
const selectedKlinik = ref([]);
const screens = ref([
{ id: 1, nama: 'Screen 1' },
{ id: 2, nama: 'Screen 2' },
{ id: 3, nama: 'Screen 3' }
]);
const klinikItems = ref([
{ id: 1, no: 1, nama_klinik: 'ANAK' },
{ id: 2, no: 2, nama_klinik: 'ANESTESI' },
{ id: 3, no: 3, nama_klinik: 'BEDAH' },
{ id: 4, no: 4, nama_klinik: 'GIGI DAN MULUT' },
{ id: 5, no: 5, nama_klinik: 'GERIATRI' },
{ id: 6, no: 6, nama_klinik: 'GIZI' },
{ id: 7, no: 7, nama_klinik: 'IPD' },
{ id: 8, no: 8, nama_klinik: 'JANTUNG' },
{ id: 9, no: 9, nama_klinik: 'JIWA' },
{ id: 10, no: 10, nama_klinik: 'KUL KEL' },
{ id: 11, no: 11, nama_klinik: 'KOMPLEMENTER' },
{ id: 12, no: 12, nama_klinik: 'MATA' },
{ id: 13, no: 13, nama_klinik: 'SARAF' },
{ id: 14, no: 14, nama_klinik: 'KANDUNGAN' },
{ id: 15, no: 15, nama_klinik: 'ONKOLOGI' },
{ id: 16, no: 16, nama_klinik: 'PARU' },
{ id: 17, no: 17, nama_klinik: 'RADIOTERAPI' },
{ id: 18, no: 18, nama_klinik: 'REHAB MEDIK' },
{ id: 19, no: 19, nama_klinik: 'THT' },
{ id: 20, no: 20, nama_klinik: 'MCU' },
{ id: 21, no: 21, nama_klinik: 'KEMOTERAPI' },
{ id: 22, no: 22, nama_klinik: 'R. TINDAKAN' },
{ id: 23, no: 23, nama_klinik: 'HOM' }
]);
// Computed headers based on selected screen
const computedHeaders = computed(() => {
return [
{ title: 'No', key: 'no', sortable: false, width: '80px' },
{ title: 'Nama Klinik', key: 'nama_klinik', sortable: true },
{ title: 'Pilih', key: 'pilih', sortable: false, width: '100px' }
];
});
// Methods
const selectScreen = (screen) => {
selectedScreen.value = screen;
loadScreenData(screen.id);
};
const loadScreenData = (screenId) => {
// Simulate loading existing selections for the screen
switch(parseInt(screenId)) {
case 1:
selectedKlinik.value = [1, 2, 3, 4, 5, 6, 7, 8]; // ANAK, ANESTESI, etc.
break;
case 2:
selectedKlinik.value = [9, 10, 11, 12, 13, 14, 15, 16]; // JIWA, KUL KEL, etc.
break;
case 3:
selectedKlinik.value = [17, 18, 19, 20, 21, 22, 23]; // RADIOTERAPI, REHAB MEDIK, etc.
break;
default:
selectedKlinik.value = [];
}
};
const updateSelectedKlinik = (newSelection) => {
selectedKlinik.value = newSelection;
};
const submit = () => {
if (!selectedScreen.value) {
alert('Please select a screen first');
return;
}
// Simulate API call to save the configuration
const data = {
screenId: selectedScreen.value.id,
selectedKlinik: selectedKlinik.value
};
console.log('Saving configuration:', data);
alert('Configuration saved successfully!');
goBack();
};
const cancel = () => {
goBack();
};
const goBack = () => {
navigateTo('/setting/screen');
};
// Lifecycle
onMounted(() => {
// Auto-select screen based on route parameter
if (screenId) {
const screen = screens.value.find(s => s.id == screenId);
if (screen) {
selectScreen(screen);
}
}
});
</script>
<style scoped>
.screen-edit {
padding: 20px;
}
.screen-cards {
margin-bottom: 2rem;
}
.screen-card {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.screen-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.screen-card.active {
border-color: #1976d2;
background-color: #d4d4d4;
}
.screen-card .v-card-title {
font-weight: 600;
padding: 20px;
}
.gap-4 {
gap: 16px;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="screen-list">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-monitor</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Screen</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
</div>
</div>
<div class="header-right">
<!-- <v-chip color="success" variant="flat" class="mr-2">
Total {{ totalPasien }} Pasien
</v-chip> -->
<v-chip color="white" variant="flat" class="text-primary" to="/Setting/screen/edit/1">
Edit Screen
</v-chip>
</div>
</div>
</div>
<!-- <div class="d-flex justify-space-between align-center mb-4">
<h2>Screen</h2>
<div class="d-flex gap-2">
<v-btn color="primary" variant="outlined" prepend-icon="mdi-eye">
View
</v-btn>
<v-btn color="warning" prepend-icon="mdi-pencil">
Edit
</v-btn>
</div>
</div> -->
<!-- Controls -->
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex align-center gap-2">
<span>Show</span>
<v-select
v-model="itemsPerPage"
:items="[10, 25, 50, 100]"
density="compact"
variant="outlined"
style="width: 80px"
></v-select>
<span>entries</span>
</div>
<div class="d-flex align-center gap-2">
<span>Search:</span>
<v-text-field
v-model="search"
density="compact"
variant="outlined"
hide-details
style="width: 200px"
></v-text-field>
</div>
</div>
<!-- Table -->
<v-data-table
:headers="headers"
:items="screenItems"
:items-per-page="itemsPerPage"
:search="search"
class="elevation-1"
>
<template v-slot:item.no="{ index }">
{{ index + 1 }}
</template>
<template v-slot:item.klinik="{ item }">
<div class="klinik-tags">
<v-chip
v-for="klinik in item.klinik"
:key="klinik"
size="small"
class="ma-1"
color="red"
text-color="white"
>
{{ klinik }}
</v-chip>
</div>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
size="small"
color="#FF9B1B"
@click="editScreen(item)"
variant="flat"
class="mr-2 text-white"
><v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
</template>
</v-data-table>
<!-- Footer -->
<div class="d-flex justify-space-between align-center mt-4">
<div>
Showing {{ currentPageStart }} to {{ currentPageEnd }} of
{{ totalItems }} entries
</div>
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="5"
></v-pagination>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
// Data
const search = ref("");
const itemsPerPage = ref(10);
const currentPage = ref(1);
const headers = [
{ title: "No", key: "no", sortable: false, width: "60px" },
{ title: "Nama Screen", key: "nama_screen", sortable: true },
{ title: "Klinik", key: "klinik", sortable: false },
{ title: "Actions", key: "actions", sortable: false, width: "100px" },
];
const screenItems = ref([
{
id: 1,
nama_screen: "Layar Screen 1",
klinik: [
"ANAK",
"ANESTESI",
"BEDAH",
"GIGI DAN MULUT",
"GERIATRI",
"GIZI",
"IPD",
"JANTUNG",
],
},
{
id: 2,
nama_screen: "Layar Screen 2",
klinik: [
"JIWA",
"KUL KEL",
"KOMPLEMENTER",
"MATA",
"SARAF",
"KANDUNGAN",
"ONKOLOGI",
"PARU",
],
},
{
id: 3,
nama_screen: "Layar Screen 3",
klinik: [
"RADIOTERAPI",
"REHAB MEDIK",
"THT",
"MCU",
"KEMOTERAPI",
"R. TINDAKAN",
"HOM",
],
},
]);
// Computed
const totalItems = computed(() => screenItems.value.length);
const totalPages = computed(() =>
Math.ceil(totalItems.value / itemsPerPage.value)
);
const currentPageStart = computed(
() => (currentPage.value - 1) * itemsPerPage.value + 1
);
const currentPageEnd = computed(() =>
Math.min(currentPage.value * itemsPerPage.value, totalItems.value)
);
// Methods
const editScreen = (item) => {
navigateTo(`/setting/screen/edit/${item.id}`);
};
</script>
<style scoped>
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: white;
}
.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: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
}
.screen-list {
padding: 20px;
}
.klinik-tags {
max-width: 600px;
}
.gap-2 {
gap: 8px;
}
</style>
+310
View File
@@ -0,0 +1,310 @@
<template>
<v-container>
<v-card class="form-card" elevation="0">
<!-- Header -->
<div class="form-header">
<div class="header-content">
<div class="header-left">
<v-btn
icon
variant="text"
color="white"
@click="cancelAdd"
class="back-btn"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<div class="header-text">
<h1 class="page-title">Tambah Loket Baru</h1>
<p class="page-subtitle">Isi form untuk menambahkan loket</p>
</div>
</div>
</div>
</div>
<!-- Form Content -->
<v-card-text class="form-content">
<v-form @submit.prevent="simpanLoket" ref="formRef">
<!-- Section: Informasi Loket -->
<div class="form-section">
<h3 class="section-title">Informasi Loket</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field
label="Nama Loket"
v-model="loket.namaLoket"
variant="outlined"
density="comfortable"
:rules="[v => !!v || 'Nama loket harus diisi']"
required
hide-details="auto"
class="form-field"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="Kuota Bangku"
v-model="loket.kuota"
type="number"
variant="outlined"
density="comfortable"
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota harus lebih dari 0']"
required
hide-details="auto"
class="form-field"
></v-text-field>
</v-col>
</v-row>
</div>
<!-- Section: Konfigurasi -->
<div class="form-section">
<h3 class="section-title">Konfigurasi</h3>
<v-row>
<v-col cols="12" md="4">
<v-select
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
v-model="loket.statusPelayanan"
variant="outlined"
density="comfortable"
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
required
hide-details="auto"
class="form-field"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-select
label="Pembayaran"
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
v-model="loket.pembayaran"
variant="outlined"
density="comfortable"
:rules="[v => !!v || 'Pembayaran harus dipilih']"
required
hide-details="auto"
class="form-field"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-select
label="Keterangan"
:items="['ONLINE', 'OFFLINE']"
v-model="loket.keterangan"
variant="outlined"
density="comfortable"
:rules="[v => !!v || 'Keterangan harus dipilih']"
required
hide-details="auto"
class="form-field"
></v-select>
</v-col>
</v-row>
</div>
<!-- Section: Pelayanan -->
<div class="form-section">
<h3 class="section-title">Pilih Pelayanan</h3>
<div class="table-wrapper">
<TabelLayanan
:headers="serviceHeaders"
:items="availableServices"
v-model:selected-items="loket.pelayanan"
/>
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<v-btn
color="#1976d2"
type="submit"
size="large"
class="action-btn save-btn"
>
<v-icon left>mdi-content-save</v-icon>
Simpan Loket
</v-btn>
<v-btn
color="grey-lighten-2"
@click="cancelAdd"
size="large"
class="action-btn cancel-btn"
>
<v-icon left>mdi-close</v-icon>
Batal
</v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
</v-container>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import TabelLayanan from '../../components/features/monitoring/TabelLayanan.vue';
const router = useRouter();
const formRef = ref(null);
const loket = ref({
namaLoket: '',
kuota: null,
statusPelayanan: '',
pembayaran: '',
keterangan: '',
pelayanan: [],
});
const serviceHeaders = ref([
{ title: '#', value: 'no', sortable: false },
{ title: 'Kode', value: 'id' },
{ title: 'Klinik', value: 'nama' },
{ title: 'Pilih', value: 'pilih', sortable: false },
]);
const availableServices = ref([
{ no: 1, id: 'AN', nama: 'ANAK' },
{ no: 2, id: 'AS', nama: 'ANESTESI' },
{ no: 3, id: 'BD', nama: 'BEDAH' },
{ no: 4, id: 'GR', nama: 'GERIATRI' },
{ no: 5, id: 'GI', nama: 'GIGI DAN MULUT' },
{ no: 6, id: 'GZ', nama: 'GIZI' },
{ no: 7, id: 'HO', nama: 'HOM' },
{ no: 8, id: 'IP', nama: 'IPD' },
{ no: 9, id: 'JT', nama: 'JANTUNG' },
{ no: 10, id: 'JW', nama: 'JIWA' },
{ no: 11, id: 'KK', nama: 'KULIT KELAMIN' },
{ no: 12, id: 'PR', nama: 'PARU' },
{ no: 13, id: 'RT', nama: 'RADIOTERAPI' },
{ no: 14, id: 'RM', nama: 'REHAB MEDIK' },
{ no: 15, id: 'SR', nama: 'SARAF' },
{ no: 16, id: 'TD', nama: 'TINDAKAN' },
]);
const simpanLoket = async () => {
const { valid } = await formRef.value.validate();
if (!valid) {
return;
}
// Validasi pelayanan
if (loket.value.pelayanan.length === 0) {
alert('Pilih minimal 1 pelayanan');
return;
}
// Dalam aplikasi nyata, ini adalah tempat untuk memanggil API create data
console.log('Data loket baru:', loket.value);
// Kembali ke halaman master
router.push('/Setting/Master-Loket');
};
const cancelAdd = () => {
router.back();
};
</script>
<style scoped>
.form-card {
border-radius: 16px;
overflow: hidden;
border: 1px solid #e0e0e0;
}
.form-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.2);
}
.header-content {
padding: 28px 32px;
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
background: rgba(255, 255, 255, 0.15);
}
.page-title {
font-size: 26px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.page-subtitle {
margin: 4px 0 0 0;
opacity: 0.9;
font-size: 14px;
}
.form-content {
padding: 32px !important;
background: #fafafa;
}
.form-section {
background: white;
padding: 24px;
border-radius: 12px;
margin-bottom: 24px;
border: 1px solid #e0e0e0;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #1976d2;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid #e3f2fd;
}
.form-field {
margin-bottom: 4px;
}
.table-wrapper {
background: #fafafa;
padding: 16px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 24px;
background: white;
border-radius: 12px;
border: 1px solid #e0e0e0;
}
.action-btn {
text-transform: none;
font-weight: 600;
letter-spacing: 0.5px;
min-width: 140px;
}
.save-btn {
color: white;
}
.cancel-btn {
color: #616161;
}
</style>
+290 -189
View File
@@ -1,11 +1,30 @@
<template>
<div class="user-login-page pa-4">
<!-- Display Authentication Info for context -->
<div class="mb-4 pa-3 bg-blue-grey-lighten-5 rounded-lg text-caption">
User Status:
<v-chip :color="isAuthenticated ? 'green' : 'red'" size="small" class="ml-2 mr-1">
{{ isAuthenticated ? 'Authenticated' : 'Unauthenticated' }}
</v-chip>
(Username: <strong>{{ user?.username || 'N/A' }}</strong>)
</div>
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<v-alert
v-if="fetchError"
type="error"
variant="tonal"
class="mb-4"
icon="mdi-alert-circle-outline"
>
Failed to load initial data: {{ fetchError.message }}. Check your API connections.
</v-alert>
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
@@ -31,7 +50,9 @@
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
:readonly="readOnly || isEditMode"
:hint="isEditMode ? 'Username tidak dapat diubah' : ''"
persistent-hint
></v-text-field>
</v-col>
</v-row>
@@ -91,9 +112,9 @@
></v-select>
</v-col>
</v-row>
<v-row v-if="!readOnly && !isEditMode">
<v-row v-if="!readOnly">
<v-col cols="12">
<v-label class="font-weight-bold">Password</v-label>
<v-label class="font-weight-bold">{{ isEditMode ? 'Ganti Password (Kosongkan jika tidak diubah)' : 'Password' }}</v-label>
<v-text-field
v-model="editedItem.password"
placeholder="Masukkan Password"
@@ -120,6 +141,8 @@
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
:loading="isSaving"
:disabled="isSaving"
>
Submit
</v-btn>
@@ -127,20 +150,21 @@
</v-card>
<div v-else>
<v-card class="d-flex justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card class="d-flex flex-wrap justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login</span>
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login Management</span>
</v-card-title>
<v-btn
<!-- <v-btn
color="success"
prepend-icon="mdi-plus"
rounded
class="text-capitalize"
@click="showAddForm"
:disabled="isPending"
>
Tambah User
</v-btn>
</v-btn> -->
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
@@ -173,16 +197,18 @@
></v-text-field>
</div>
</div>
<v-data-table
:headers="headers"
:items="allUserData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
:loading="isPending || isSaving"
loading-text="Memuat data pengguna..."
class="rounded-lg elevation-0 custom-table"
>
<template v-slot:[`item.roles`]="{ item }">
<template v-slot:item.roles="{ item }">
<v-chip
v-for="role in item.roles"
:key="role"
@@ -194,7 +220,7 @@
</v-chip>
</template>
<template v-slot:[`item.groups`]="{ item }">
<template v-slot:item.groups="{ item }">
<v-chip
v-for="group in item.groups"
:key="group"
@@ -206,7 +232,7 @@
</v-chip>
</template>
<template v-slot:[`item.actions`]="{ item }">
<template v-slot:item.actions="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
@@ -250,92 +276,134 @@
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete">Hapus</v-btn>
<v-btn color="grey-darken-1" variant="text" @click="closeDeleteDialog" :disabled="isSaving">Batal</v-btn>
<v-btn color="red" variant="text" @click="confirmDelete" :loading="isSaving">Hapus</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
>
{{ snackbar.message }}
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
>
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// ----------------------------------------------------------------------
// FIX: Explicitly import useAuth from its source path (~/composables/useAuth.ts)
// This resolves the 'useAuth is not defined' runtime error during SSR/build.
import { useAuth } from '~/composables/useAuth';
// ----------------------------------------------------------------------
// Set page metadata (optional, but good practice for a Nuxt page)
definePageMeta({
middleware:['auth']
})
middleware: ['auth'] // Example: requires user to be logged in
});
// --- NEW DATA LISTS FOR SELECTION ---
const availableRoles = [
'User_Standard', 'User_Report', 'User_Klinik', 'User_Farmasi',
'Admin_UserManagement', 'Admin_System', 'Manager_View'
];
const availableGroups = [
'/RS/Klinik', '/RS/Loket', '/RS/Farmasi', '/RS/IT', '/RS/Management'
];
// ------------------------------------
// Define the expected structure of user data
interface UserManagementItem {
id: string | number; // Keycloak UUID
namaLengkap: string;
namaUser: string; // Keycloak username
tipeUser: string; // Custom attribute mapping
keterangan: string;
roles: string[];
groups: string[];
password?: string;
}
// Define the core user data structure (without IDs initially)
const rawUserData = [
{ namaLengkap: 'LOKET 1', namaUser: 'loket1', tipeUser: 'Loket', keterangan: 'Loket 1', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 2', namaUser: 'loket2', tipeUser: 'Loket', keterangan: 'Loket 2', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'LOKET 3', namaUser: 'loket3', tipeUser: 'Loket', keterangan: 'Loket 3', roles: ['User_Standard'], groups: ['/RS/Loket'] },
{ namaLengkap: 'ANAK', namaUser: 'anak', tipeUser: 'Klinik', keterangan: 'ANAK', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'ADMIN PRAM', namaUser: 'adminpram', tipeUser: 'Admin', keterangan: 'Administrator Utama', roles: ['Admin_UserManagement', 'User_Report'], groups: ['/RS/IT'] },
{ namaLengkap: 'Report Only', namaUser: 'laporan', tipeUser: 'Report Only', keterangan: 'Hanya melihat laporan', roles: ['User_Report'], groups: ['/RS/Management'] },
{ namaLengkap: 'Farmasi Utama', namaUser: 'farmasi_utama', tipeUser: 'Farmasi', keterangan: 'Apoteker Penanggung Jawab', roles: ['User_Farmasi'], groups: ['/RS/Farmasi'] },
{ namaLengkap: 'Super Admin User', namaUser: 'superadmin', tipeUser: 'Super Admin', keterangan: 'Full Control System', roles: ['Admin_System', 'Admin_UserManagement', 'User_Standard'], groups: ['/RS/IT', '/RS/Management'] },
// Adding more generic data to make the list longer and sequential
{ namaLengkap: 'Klinik Umum', namaUser: 'klinik_umum', tipeUser: 'Klinik', keterangan: 'Dokter Umum', roles: ['User_Klinik'], groups: ['/RS/Klinik'] },
{ namaLengkap: 'Manajer Keuangan', namaUser: 'manager_keu', tipeUser: 'Manager', keterangan: 'Pengelola Anggaran', roles: ['Manager_View', 'User_Report'], groups: ['/RS/Management'] },
];
// Data structure for initial API response
interface InitialData {
users: UserManagementItem[];
roles: string[];
groups: string[];
}
// Map over the raw data to assign sequential IDs starting from 1
const allUserData = ref(rawUserData.map((item, index) => ({
...item,
id: index + 1
})));
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false);
// --- AUTHENTICATION & INITIAL DATA FETCHING (SSR Compatible) ---
// 1. Get Auth State
const { user, isAuthenticated } = useAuth();
// 2. Use useAsyncData for Server-Side Data Fetching
const { data: initialData, pending: isPending, error: fetchError, refresh } = await useAsyncData<InitialData>('user-management-data', async () => {
// Note: $fetch is used here and will work on both server and client
const [users, roles, groups] = await Promise.all([
// API endpoint to fetch all users (accessible by admin/manager)
$fetch('/api/users/list'),
// API endpoint to fetch available Keycloak roles
$fetch('/api/keycloak/roles'),
// API endpoint to fetch available Keycloak groups
$fetch('/api/keycloak/groups'),
]);
// Ensure data is formatted correctly before returning
return {
users: (users as UserManagementItem[]).map((u: any) => ({ ...u, id: u.id || 'N/A' })),
roles: roles as string[],
groups: groups as string[],
};
}, {
// Refresh interval for live data (optional)
// server: false // Uncomment if you want this block to run only on client side
});
// State synchronization: Initialize local refs from useAsyncData result
const allUserData = ref<UserManagementItem[]>(initialData.value?.users || []);
const availableRoles = ref<string[]>(initialData.value?.roles || []);
const availableGroups = ref<string[]>(initialData.value?.groups || []);
const isSaving = ref(false); // Tracks client-side CRUD operations (Add/Edit/Delete)
// Watch the Nuxt data ref and pending state to update local data and loading status
watch(initialData, (newData) => {
if (newData) {
allUserData.value = newData.users;
availableRoles.value = newData.roles;
availableGroups.value = newData.groups;
}
}, { deep: true });
// --- LOCAL STATE ---
// Table configuration
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
{ title: 'ID', key: 'id' },
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
// Breadcrumbs
const breadcrumbs = ref([
{ title: 'Dashboard', disabled: false, href: '#/dashboard' },
{ title: 'Setting', disabled: false, href: '#/setting' },
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '#/setting' },
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
]);
// State untuk tabel dan paginasi
// State for table and pagination
const itemsPerPage = ref(10);
const search = ref('');
const page = ref(1);
const itemToDelete = ref(null);
const itemToDelete = ref<UserManagementItem | null>(null);
// State untuk dialog dan form
// State for dialog and form
const showForm = ref(false);
const readOnly = ref(false);
const showDeleteDialog = ref(false);
const isEditMode = ref(false);
const editedIndex = ref(-1);
const snackbar = ref({
show: false,
message: '',
@@ -343,38 +411,9 @@ const snackbar = ref({
timeout: 3000,
});
// Updated `editedItem` structure with new fields
const editedItem = ref({
id: 0,
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
password: '',
roles: [],
groups: []
});
// Computed properties untuk paginasi kustom
const pageCount = computed(() => {
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allUserData.value.length);
const total = allUserData.value.length;
// Handle case where total is 0 (no data)
if (total === 0) return 'Showing 0 to 0 of 0 entries';
return `Showing ${start} to ${end} of ${total} entries`;
});
// FUNCTIONS
const resetForm = () => {
editedItem.value = {
id: 0,
// Edited item structure
const emptyItem: UserManagementItem = {
id: '',
namaLengkap: '',
namaUser: '',
tipeUser: '',
@@ -382,115 +421,177 @@ const resetForm = () => {
password: '',
roles: [],
groups: []
};
};
const editedItem = ref<UserManagementItem>(Object.assign({}, emptyItem));
// --- COMPUTED PROPERTIES ---
const pageCount = computed(() => {
// Using filtered/searched data length for accurate count if search was handled client-side,
// but since we are using v-data-table's built-in search, we use the full length here
// for a simple calculation of total pages based on all data.
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const total = allUserData.value.length;
if (total === 0) return 'Showing 0 to 0 of 0 entries';
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, total);
return `Showing ${start} to ${end} of ${total} entries`;
});
// --- FORM & ACTION FUNCTIONS ---
const resetForm = () => {
editedItem.value = Object.assign({}, emptyItem);
};
const showAddForm = () => {
resetForm();
isEditMode.value = false;
readOnly.value = false;
showForm.value = true;
resetForm();
isEditMode.value = false;
readOnly.value = false;
showForm.value = true;
};
const viewItem = (item) => {
// We copy the item data for view mode
editedItem.value = Object.assign({}, item);
isEditMode.value = false;
readOnly.value = true;
showForm.value = true;
const viewItem = (item: UserManagementItem) => {
editedItem.value = Object.assign({}, item);
isEditMode.value = false;
readOnly.value = true;
showForm.value = true;
};
const editItem = (item) => {
// Find the index of the item in the actual array
editedIndex.value = allUserData.value.findIndex(data => data.id === item.id);
// Copy the item data to the editedItem
editedItem.value = Object.assign({}, item);
// Clear password field for security (will only be set if user types a new one)
editedItem.value.password = '';
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
};
const saveItem = () => {
// Basic validation (can be expanded)
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
return;
}
// Remove password from the final object for display purposes,
// as it should be securely handled in a real backend API.
const { password, ...itemData } = editedItem.value;
if (isEditMode.value) {
// Edit existing item
if (editedIndex.value > -1) {
Object.assign(allUserData.value[editedIndex.value], itemData);
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
}
} else {
// Add new item: find the highest current ID and add 1
const maxId = allUserData.value.length > 0 ? Math.max(...allUserData.value.map(item => item.id)) : 0;
itemData.id = maxId + 1;
allUserData.value.push(itemData);
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
}
cancelForm();
};
const deleteItem = (item) => {
itemToDelete.value = item;
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
itemToDelete.value = null;
};
const confirmDelete = () => {
if (itemToDelete.value) {
// Find the index of the item to delete
const index = allUserData.value.findIndex(data => data.id === itemToDelete.value.id);
if (index > -1) {
// Remove the item
allUserData.value.splice(index, 1);
// Re-index the remaining items to keep the 'id' column sequential visually
allUserData.value = allUserData.value.map((item, i) => ({
...item,
id: i + 1
}));
snackbar.value = { show: true, message: 'Data pengguna berhasil dihapus!', color: 'warning', timeout: 3000 };
}
}
closeDeleteDialog();
const editItem = (item: UserManagementItem) => {
editedItem.value = Object.assign({}, item);
editedItem.value.password = ''; // Clear password field for security
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
};
const cancelForm = () => {
showForm.value = false;
resetForm();
editedIndex.value = -1;
showForm.value = false;
resetForm();
};
/**
* Handles form submission (Add or Edit) using Nuxt's $fetch for client-side API call.
*/
const saveItem = async () => {
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
return;
}
if (!isEditMode.value && !editedItem.value.password) {
snackbar.value = { show: true, message: 'Password wajib diisi saat membuat pengguna baru!', color: 'error', timeout: 3000 };
return;
}
isSaving.value = true;
// Prepare payload
const payload = {
namaLengkap: editedItem.value.namaLengkap,
username: editedItem.value.namaUser,
tipeUser: editedItem.value.tipeUser,
keterangan: editedItem.value.keterangan,
roles: editedItem.value.roles,
groups: editedItem.value.groups,
// Only include password if it was set
...(editedItem.value.password && { password: editedItem.value.password })
};
try {
if (isEditMode.value) {
// EDIT: PATCH to update existing user by ID (Keycloak UUID)
await $fetch(`/api/users/${editedItem.value.id}`, {
method: 'PATCH',
body: payload,
});
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
} else {
// ADD: POST to create new user
await $fetch('/api/users/create', {
method: 'POST',
body: payload,
});
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
}
// Re-fetch all initial data to update the table reactively
await refresh();
cancelForm();
} catch (error: any) {
console.error('API Error during save:', error);
// Nuxt's $fetch error handling
const errorMessage = error.data?.message || 'Terjadi kesalahan saat menyimpan data pengguna. Cek log server.';
snackbar.value = { show: true, message: errorMessage, color: 'error', timeout: 5000 };
} finally {
isSaving.value = false;
}
};
const deleteItem = (item: UserManagementItem) => {
itemToDelete.value = item;
showDeleteDialog.value = true;
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
itemToDelete.value = null;
};
/**
* Confirms and executes the user deletion using Nuxt's $fetch.
*/
const confirmDelete = async () => {
if (!itemToDelete.value || !itemToDelete.value.id) {
closeDeleteDialog();
return;
}
isSaving.value = true;
try {
// DELETE request to the backend using the user's ID
await $fetch(`/api/users/${itemToDelete.value.id}`, {
method: 'DELETE',
});
snackbar.value = { show: true, message: `Data pengguna ${itemToDelete.value.namaLengkap} berhasil dihapus!`, color: 'warning', timeout: 3000 };
// Re-fetch data to update the table
await refresh();
} catch (error: any) {
console.error('API Error during delete:', error);
const errorMessage = error.data?.message || 'Gagal menghapus data pengguna.';
snackbar.value = { show: true, message: errorMessage, color: 'error', timeout: 5000 };
} finally {
closeDeleteDialog();
isSaving.value = false;
}
};
</script>
<style scoped>
/* Custom Table Styling for better visual separation */
.custom-table :deep(table) {
border-collapse: collapse;
border-collapse: collapse;
}
.custom-table :deep(th) {
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
background-color: #f5f5f5 !important;
font-weight: bold;
font-size: 0.875rem; /* text-sm */
}
.custom-table :deep(td) {
padding-top: 12px !important;
padding-bottom: 12px !important;
padding-top: 12px !important;
padding-bottom: 12px !important;
}
</style>
+308
View File
@@ -0,0 +1,308 @@
<!-- page edit id data pasien -->
<template>
<div class="edit-pasien">
<!-- Header -->
<div class="d-flex align-center mb-4">
<v-btn icon="mdi-arrow-left" @click="goBack" class="mr-2"></v-btn>
<h2>Edit Pasien</h2>
</div>
<!-- Form -->
<v-card class="pa-6" elevation="2">
<v-form ref="form" v-model="valid">
<v-row>
<!-- Tanggal Daftar -->
<v-col cols="12" md="6">
<v-text-field
v-model="formData.tanggal_daftar"
label="Tanggal Daftar"
variant="outlined"
readonly
density="compact"
></v-text-field>
</v-col>
<!-- Tanggal Periksa -->
<v-col cols="12" md="6">
<v-text-field
v-model="formData.tanggal_periksa"
label="Tanggal Periksa"
variant="outlined"
type="date"
density="compact"
:rules="[rules.required]"
></v-text-field>
</v-col>
<!-- No Barcode -->
<v-col cols="12" md="6">
<v-text-field
v-model="formData.no_barcode"
label="No Barcode"
variant="outlined"
readonly
density="compact"
></v-text-field>
</v-col>
<!-- No Antrian -->
<v-col cols="12" md="6">
<v-text-field
v-model="formData.no_antrian"
label="No Antrian"
variant="outlined"
readonly
density="compact"
></v-text-field>
</v-col>
<!-- No Klinik -->
<v-col cols="12" md="6">
<v-text-field
v-model="formData.no_klinik"
label="No Klinik"
variant="outlined"
placeholder="Belum Mendapatkan Antrian Klinik"
density="compact"
></v-text-field>
</v-col>
<!-- No Rekammedik -->
<v-col cols="12" md="6">
<v-text-field
v-model="formData.no_rekammedik"
label="No Rekammedik"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<!-- Klinik -->
<v-col cols="12" md="6">
<v-select
v-model="formData.klinik"
label="Klinik"
:items="klinikOptions"
variant="outlined"
density="compact"
:rules="[rules.required]"
></v-select>
</v-col>
<!-- Shift -->
<v-col cols="12" md="6">
<v-select
v-model="formData.shift"
label="Shift"
:items="shiftOptions"
variant="outlined"
density="compact"
:rules="[rules.required]"
></v-select>
</v-col>
<!-- Keterangan -->
<v-col cols="12">
<v-text-field
v-model="formData.keterangan"
label="Keterangan"
variant="outlined"
density="compact"
readonly
>
<template v-slot:append-inner>
<span class="text-red font-weight-bold">
{{ formData.keterangan }}
</span>
</template>
</v-text-field>
</v-col>
<!-- Pembayaran -->
<v-col cols="12" md="6">
<v-select
v-model="formData.pembayaran"
label="Pembayaran"
:items="pembayaranOptions"
variant="outlined"
density="compact"
:rules="[rules.required]"
></v-select>
</v-col>
</v-row>
<!-- Action Buttons -->
<div class="d-flex justify-end gap-4 mt-6">
<v-btn variant="outlined" @click="cancel">
Cancel
</v-btn>
<v-btn
color="warning"
@click="submit"
:disabled="!valid"
:loading="loading"
>
Submit
</v-btn>
</div>
</v-form>
</v-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const route = useRoute();
const pasienId = route.params.id;
// Form data
const valid = ref(false);
const loading = ref(false);
const form = ref(null);
const formData = ref({
tanggal_daftar: '',
tanggal_periksa: '',
no_barcode: '',
no_antrian: '',
no_klinik: '',
no_rekammedik: '',
klinik: '',
shift: '',
keterangan: '',
pembayaran: ''
});
// Options
const klinikOptions = [
'HOM',
'KANDUNGAN',
'ANAK',
'IPD',
'JIWA',
'KUL KEL',
'KOMPLEMENTER',
'MATA',
'SARAF',
'ONKOLOGI',
'PARU',
'RADIOTERAPI',
'REHAB MEDIK',
'THT',
'MCU',
'KEMOTERAPI',
'R. TINDAKAN',
'ANESTESI',
'BEDAH',
'GIGI DAN MULUT',
'GERIATRI',
'GIZI',
'JANTUNG'
];
const shiftOptions = [
'Shift 1 = Mulai Pukul 07:00',
'Shift 2 = Mulai Pukul 13:00',
'Shift 3 = Mulai Pukul 19:00'
];
const pembayaranOptions = [
'JKN',
'UMUM',
'ASURANSI',
'KARYAWAN'
];
// Validation rules
const rules = {
required: value => !!value || 'Field ini wajib diisi'
};
// Mock data for editing
const mockPasienData = {
1: {
tanggal_daftar: '2025-08-13 00:00:03',
tanggal_periksa: '2025-08-27',
no_barcode: '25027100007',
no_antrian: 'IP1001',
no_klinik: 'Belum Mendapatkan Antrian Klinik',
no_rekammedik: '11555500',
klinik: 'IPD',
shift: 'Shift 1 = Mulai Pukul 07:00',
keterangan: 'PENDAFTARAN ONLINE',
pembayaran: 'JKN'
},
2: {
tanggal_daftar: '2025-07-24 13:50:01',
tanggal_periksa: '2025-08-27',
no_barcode: '25027100002',
no_antrian: 'OB1001',
no_klinik: '',
no_rekammedik: '',
klinik: 'KANDUNGAN',
shift: 'Shift 1 = Mulai Pukul 07:00',
keterangan: '',
pembayaran: 'JKN'
}
};
// Methods
const loadPasienData = () => {
const data = mockPasienData[pasienId];
if (data) {
formData.value = { ...data };
}
};
const submit = async () => {
if (!valid.value) return;
loading.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Updating pasien data:', formData.value);
// Show success message (you can use a toast/snackbar here)
alert('Data pasien berhasil diperbarui!');
// Navigate back to list
goBack();
} catch (error) {
console.error('Error updating pasien:', error);
alert('Gagal memperbarui data pasien!');
} finally {
loading.value = false;
}
};
const cancel = () => {
goBack();
};
const goBack = () => {
navigateTo('/data-pasien');
};
// Lifecycle
onMounted(() => {
loadPasienData();
});
</script>
<style scoped>
.edit-pasien {
padding: 20px;
}
.gap-4 {
gap: 16px;
}
.text-red {
color: #d32f2f;
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<!-- pages/data-pasien.vue -->
<template>
<div class="data-pasien-container">
<div class="data-pasien">
<!-- Header -->
<!-- <div class="d-flex justify-space-between align-center mb-4">
<h2>Data Pasien</h2>
<div class="d-flex gap-2">
<v-btn
color="#ff9248"
prepend-icon="mdi-plus"
@click="addPatient"
style="color: white"
>
Tambah Pasien
</v-btn>
</div>
</div> -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Data Pasien</h1>
</div>
</div>
<div class="header-right">
<v-btn
color="#ff9248"
prepend-icon="mdi-plus"
@click="addPatient"
style="color: white"
>
Tambah Pasien
</v-btn>
</div>
</div>
</div>
<!-- Menggunakan komponen TabelData -->
<TabelData
:headers="headers"
:items="pasienItems"
title="Daftar Data Pasien"
:show-search="true"
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
size="small"
color="#ff9248"
variant="flat"
@click="viewPasien(item)"
style="color: white"
>VIEW</v-btn
>
<v-btn
size="small"
color="grey-lighten-4"
variant="flat"
@click="editPasien(item)"
>EDIT</v-btn
>
</div>
</template>
</TabelData>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import TabelData from "@/components/TabelData.vue";
// Headers untuk tabel
const headers = [
{ title: "No", key: "no", sortable: false, width: "60px" },
{ title: "Tgl Daftar", key: "tgl_daftar", sortable: true, width: "140px" },
{ title: "No Barcode", key: "no_barcode", sortable: true, width: "120px" },
{ title: "No Antrian", key: "no_antrian", sortable: true, width: "100px" },
{ title: "No Klinik", key: "no_klinik", sortable: true, width: "100px" },
{ title: "RM", key: "rm", sortable: true, width: "100px" },
{ title: "Klinik", key: "klinik", sortable: true, width: "120px" },
{ title: "Shift", key: "shift", sortable: true, width: "80px" },
{ title: "Ket", key: "keterangan", sortable: false, width: "150px" },
{ title: "Pembayaran", key: "pembayaran", sortable: true, width: "100px" },
{ title: "Status", key: "status", sortable: true, width: "120px" },
{ title: "Aksi", key: "aksi", sortable: false, width: "100px" },
];
// Data pasien dengan informasi lengkap untuk edit
const pasienItems = ref([
{
id: 1,
no: 1,
tgl_daftar: "2025-07-15 13:47:33",
no_barcode: "25027100001",
no_antrian: "HO1001",
no_klinik: "",
rm: "",
klinik: "HOM",
shift: "Shift 1",
keterangan: "",
pembayaran: "JKN",
status: "Tunggu Daftar",
// Data tambahan untuk form edit
editData: {
tanggal_daftar: "2025-07-15 13:47:33",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100001",
no_antrian: "HO1001",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "",
klinik: "HOM",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "",
pembayaran: "JKN",
},
},
{
id: 2,
no: 2,
tgl_daftar: "2025-07-24 13:50:01",
no_barcode: "25027100002",
no_antrian: "OB1001",
no_klinik: "",
rm: "",
klinik: "KANDUNGAN",
shift: "Shift 1",
keterangan: "",
pembayaran: "JKN",
status: "Barcode",
editData: {
tanggal_daftar: "2025-07-24 13:50:01",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100002",
no_antrian: "OB1001",
no_klinik: "",
no_rekammedik: "",
klinik: "KANDUNGAN",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "",
pembayaran: "JKN",
},
},
{
id: 3,
no: 3,
tgl_daftar: "2025-07-24 13:50:37",
no_barcode: "25027100003",
no_antrian: "OB1002",
no_klinik: "",
rm: "",
klinik: "KANDUNGAN",
shift: "Shift 1",
keterangan: "",
pembayaran: "JKN",
status: "Barcode",
editData: {
tanggal_daftar: "2025-07-24 13:50:37",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100003",
no_antrian: "OB1002",
no_klinik: "",
no_rekammedik: "",
klinik: "KANDUNGAN",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "",
pembayaran: "JKN",
},
},
{
id: 4,
no: 4,
tgl_daftar: "2025-07-28 08:18:20",
no_barcode: "25027100004",
no_antrian: "AN1001",
no_klinik: "",
rm: "",
klinik: "ANAK",
shift: "Shift 1",
keterangan: "",
pembayaran: "JKN",
status: "Barcode",
editData: {
tanggal_daftar: "2025-07-28 08:18:20",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100004",
no_antrian: "AN1001",
no_klinik: "",
no_rekammedik: "",
klinik: "ANAK",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "",
pembayaran: "JKN",
},
},
{
id: 5,
no: 5,
tgl_daftar: "2025-08-13 00:00:02",
no_barcode: "25027100005",
no_antrian: "HO1002",
no_klinik: "",
rm: "11412684",
klinik: "HOM",
shift: "Shift 1",
keterangan: "Online 25#27100005",
pembayaran: "JKN",
status: "Tunggu Daftar",
editData: {
tanggal_daftar: "2025-08-13 00:00:02",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100005",
no_antrian: "HO1002",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "11412684",
klinik: "HOM",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "Online 25#27100005",
pembayaran: "JKN",
},
},
{
id: 6,
no: 6,
tgl_daftar: "2025-08-13 00:00:03",
no_barcode: "25027100006",
no_antrian: "HO1003",
no_klinik: "",
rm: "",
klinik: "HOM",
shift: "Shift 1",
keterangan: "Online 25#27100006",
pembayaran: "JKN",
status: "Tunggu Daftar",
editData: {
tanggal_daftar: "2025-08-13 00:00:03",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100006",
no_antrian: "HO1003",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "",
klinik: "HOM",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "Online 25#27100006",
pembayaran: "JKN",
},
},
{
id: 7,
no: 7,
tgl_daftar: "2025-08-13 00:00:03",
no_barcode: "25027100007",
no_antrian: "IP1001",
no_klinik: "",
rm: "11555500",
klinik: "IPD",
shift: "Shift 1",
keterangan: "Online 25#27100007",
pembayaran: "JKN",
status: "Tunggu Daftar",
editData: {
tanggal_daftar: "2025-08-13 00:00:03",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100007",
no_antrian: "IP1001",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "11555500",
klinik: "IPD",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "PENDAFTARAN ONLINE",
pembayaran: "JKN",
},
},
{
id: 8,
no: 8,
tgl_daftar: "2025-08-13 00:00:04",
no_barcode: "25027100008",
no_antrian: "IP1001",
no_klinik: "",
rm: "11333855",
klinik: "IPD",
shift: "Shift 1",
keterangan: "Online 25#27100008",
pembayaran: "JKN",
status: "Tunggu Daftar",
editData: {
tanggal_daftar: "2025-08-13 00:00:04",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100008",
no_antrian: "IP1001",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "11333855",
klinik: "IPD",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "Online 25#27100008",
pembayaran: "JKN",
},
},
{
id: 9,
no: 9,
tgl_daftar: "2025-08-13 00:00:04",
no_barcode: "25027100009",
no_antrian: "IP1001",
no_klinik: "",
rm: "11565554",
klinik: "IPD",
shift: "Shift 1",
keterangan: "Online 25#27100009",
pembayaran: "JKN",
status: "Tunggu Daftar",
editData: {
tanggal_daftar: "2025-08-13 00:00:04",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100009",
no_antrian: "IP1001",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "11565554",
klinik: "IPD",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "Online 25#27100009",
pembayaran: "JKN",
},
},
{
id: 10,
no: 10,
tgl_daftar: "2025-08-13 00:00:04",
no_barcode: "25027100010",
no_antrian: "IP1001",
no_klinik: "",
rm: "11627608",
klinik: "IPD",
shift: "Shift 1",
keterangan: "Online 25#27100010",
pembayaran: "JKN",
status: "Tunggu Daftar",
editData: {
tanggal_daftar: "2025-08-13 00:00:04",
tanggal_periksa: "2025-08-27",
no_barcode: "25027100010",
no_antrian: "IP1001",
no_klinik: "Belum Mendapatkan Antrian Klinik",
no_rekammedik: "11627608",
klinik: "IPD",
shift: "Shift 1 = Mulai Pukul 07:00",
keterangan: "Online 25#27100010",
pembayaran: "JKN",
},
},
]);
// Methods
const addPatient = () => {
// Navigate to add patient page
navigateTo("/data-pasien/add");
};
const viewPasien = (item) => {
// Implement view functionality
console.log("View pasien:", item);
// You can navigate to a view page or open a modal
// navigateTo(`/data-pasien/view/${item.id}`);
};
const editPasien = (item) => {
// Navigate to edit page
navigateTo(`/data-pasien/edit/${item.id}`);
};
// Provide data globally untuk diakses oleh halaman edit
provide("pasienData", pasienItems);
</script>
<style scoped>
.data-pasien-container {
background: #f5f7fa;
min-height: 100vh;
padding: 20px;
}
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32px;
color: rgb(255, 255, 255);
}
.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: 32px;
font-weight: 700;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-right {
display: flex;
align-items: center;
}
.data-pasien {
padding: 20px;
}
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<template>
<v-container>
<v-app>
<!-- Page Header -->
<PageHeader
icon="mdi-shield-check"
title="Verifikasi Akun"
subtitle="Manajemen Pasien Digital"
:show-add-button="false"
theme="primary"
>
<template #actions>
<v-chip color="secondary-600" class="admin-chip" variant="flat" rounded="xl" size="large">
<v-icon start color="white">mdi-account-star</v-icon>
<span class="chip-text">Admin</span>
</v-chip>
</template>
</PageHeader>
<v-main class="main-bg">
<v-container fluid class="pa-6 pt-8 pb-12">
<v-card class="content-card">
<!-- Search & Tabs -->
<VerificationSearchTabs
v-model:search-query="search"
v-model:selected-tab="filterStatus"
:all-count="patients.length"
:pending-count="unverifiedPatients.length"
:verified-count="verifiedPatients.length"
/>
<!-- Patient List -->
<PatientVerificationList
:patients="filteredPatients"
:empty-message="noDataText"
@verify="openDaftarModal"
/>
</v-card>
<!-- QR Verification Dialog -->
<QrVerificationDialog
v-model="isModalOpen"
:patient="selectedPatient"
v-model:phone-number="tempTelepon"
:qr-generated="qrCodeGenerated"
:is-mobile="display.xs.value"
@generate="generateQrCode"
@reload="reloadQr"
@complete="completeVerification"
@close="closeModal"
>
<template #qr-code>
<qrcode-vue
:value="qrCodeData"
:size="qrSize"
level="H"
:foreground="'#0663C7'"
:background="'#FFFFFF'"
class="qr-code"
/>
</template>
</QrVerificationDialog>
</v-container>
</v-main>
</v-app>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useDisplay } from 'vuetify';
import QrcodeVue from 'qrcode.vue';
import PageHeader from '@/components/common/PageHeader.vue';
import VerificationSearchTabs from '@/components/verification/VerificationSearchTabs.vue';
import PatientVerificationList from '@/components/verification/PatientVerificationList.vue';
import QrVerificationDialog from '@/components/verification/QrVerificationDialog.vue';
definePageMeta({
middleware: ['auth']
});
const display = useDisplay();
// 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' },
{ nama: 'Joko Susilo', rm: '00123458', alamat: 'Jl. Pahlawan 5', telepon: '', status: 'Belum Terverifikasi' },
{ nama: 'Dewi Lestari', rm: '00123459', alamat: 'Gang Mawar 12', telepon: '089876543210', status: 'Terverifikasi' },
{ nama: 'Rina Wijaya', rm: '00123460', alamat: 'Jl. Sudirman 21', telepon: '', status: 'Belum Terverifikasi' },
]);
const filterStatus = ref(0);
const search = ref('');
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')
);
const filteredPatients = computed(() => {
let statusFiltered = patients.value;
if (filterStatus.value === 1) {
statusFiltered = unverifiedPatients.value;
} else if (filterStatus.value === 2) {
statusFiltered = verifiedPatients.value;
}
if (!search.value) return statusFiltered;
const searchTerm = search.value.toLowerCase();
return statusFiltered.filter(p =>
p.nama.toLowerCase().includes(searchTerm) ||
p.rm.includes(searchTerm)
);
});
const qrCodeData = computed(() => {
return qrCodeGenerated.value
? JSON.stringify({
rm: selectedPatient.value.rm,
phone: tempTelepon.value,
action: 'verify-account',
timestamp: Date.now()
})
: '';
});
const qrSize = computed(() => {
if (display.xs.value) return 200;
if (display.sm.value) return 240;
return 280;
});
// Methods
const openDaftarModal = (patient) => {
selectedPatient.value = patient;
tempTelepon.value = patient.telepon || '';
qrCodeGenerated.value = false;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedPatient.value = {};
tempTelepon.value = '';
qrCodeGenerated.value = false;
};
const generateQrCode = () => {
if (tempTelepon.value.length >= 8) {
console.log(`[API CALL] Generating QR for ${selectedPatient.value.nama}. QR Data: ${qrCodeData.value}`);
setTimeout(() => {
qrCodeGenerated.value = true;
}, 200);
}
};
const reloadQr = () => {
console.log('[API CALL] Reloading QR Code...');
qrCodeGenerated.value = false;
setTimeout(() => {
qrCodeGenerated.value = true;
console.log('QR Code reloaded.');
}, 500);
};
const completeVerification = () => {
const patientIndex = patients.value.findIndex(
(p) => p.rm === selectedPatient.value.rm
);
if (patientIndex !== -1) {
patients.value[patientIndex].status = 'Terverifikasi';
patients.value[patientIndex].telepon = tempTelepon.value;
}
filterStatus.value = 2;
closeModal();
};
</script>
<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;
}
.admin-chip {
font-weight: $font-weight-bold;
padding: 12px 20px;
}
.chip-text {
color: $neutral-100;
font-weight: $font-weight-bold;
font-family: $font-family-base;
}
.content-card {
background: $neutral-100;
border-radius: 12px;
padding: 0;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
border: 1px solid $neutral-500;
}
.qr-code {
border-radius: 8px;
display: block;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+16 -15
View File
@@ -1,4 +1,12 @@
// server/api/auth/keycloak-callback.ts - FIX APPLIED
// server/api/auth/keycloak-callback.ts - EXTENDED SESSION FIX
// Add this at the top of the file (after imports)
const SESSION_DURATION = 24 * 60 * 60; // 7 days in seconds (customize as needed)
// Or use one of these alternatives:
// const SESSION_DURATION = 24 * 60 * 60; // 1 day
// const SESSION_DURATION = 30 * 24 * 60 * 60; // 30 days
// const SESSION_DURATION = 12 * 60 * 60; // 12 hours
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig();
@@ -28,8 +36,8 @@ export default defineEventHandler(async (event) => {
if (!state || state !== storedState) {
console.error('❌ Invalid state parameter - possible CSRF attack');
console.error('   Expected:', storedState);
console.error('   Received:', state);
console.error(' Expected:', storedState);
console.error(' Received:', state);
const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
@@ -46,7 +54,6 @@ export default defineEventHandler(async (event) => {
const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
// ... (Token exchange logic remains the same) ...
const tokenPayload = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.keycloakClientId,
@@ -72,7 +79,6 @@ export default defineEventHandler(async (event) => {
const tokens = await tokenResponse.json();
// ... (Token decoding and sessionData creation remains the same) ...
let idTokenPayload;
try {
idTokenPayload = JSON.parse(
@@ -96,33 +102,28 @@ export default defineEventHandler(async (event) => {
accessToken: tokens.access_token,
idToken: tokens.id_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
// CHANGED: Use custom session duration instead of Keycloak's token expiry
expiresAt: Date.now() + (SESSION_DURATION * 1000),
createdAt: Date.now(),
};
// ----------------------------------------------------
// 👇 CRITICAL FIX FOR DEPLOYED HTTPS ENVIRONMENTS 👇
// ----------------------------------------------------
// Check if the request was originally HTTPS (via proxy)
const isSecure = process.env.NODE_ENV === 'production' ||
event.node.req.headers['x-forwarded-proto'] === 'https';
console.log('🔗 Setting session cookie with secure flag:', isSecure);
console.log('⏱️ Session duration:', SESSION_DURATION, 'seconds');
setCookie(event, 'user_session', JSON.stringify(sessionData), {
httpOnly: true,
// CRITICAL: Must be TRUE when operating over HTTPS (deployed)
secure: isSecure,
// Ensures cookie is sent on cross-site redirects (Keycloak -> Your App)
sameSite: 'lax',
maxAge: tokens.expires_in,
// CHANGED: Use custom session duration (7 days default)
maxAge: SESSION_DURATION,
path: '/',
});
console.log('✅ Session cookie created successfully');
// Note: The following line will still log false because the cookie
// is in the response header, not the request header yet. This is expected.
const testCookie = getCookie(event, 'user_session');
console.log('🧪 Cookie test - can read back in this handler (Expected False):', !!testCookie);
+75 -76
View File
@@ -2,94 +2,93 @@
// Helper function to safely decode the JWT payload (Access Token or ID Token)
const decodeTokenPayload = (token: string | undefined): any | null => {
if (!token) return null;
try {
// Tokens are base64 encoded and separated by '.'
const parts = token.split('.');
if (parts.length < 2) return null; // Not a valid JWT format
const payloadBase64 = parts[1];
// Decode from base64 and parse the JSON
// Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3)
return JSON.parse(Buffer.from(payloadBase64, 'base64').toString());
} catch (e) {
console.error('❌ Failed to decode token payload:', e);
return null;
}
if (!token) return null;
try {
// Tokens are base64 encoded and separated by '.'
const parts = token.split(".");
if (parts.length < 2) return null; // Not a valid JWT format
const payloadBase64 = parts[1];
// Decode from base64 and parse the JSON
// Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3)
return JSON.parse(Buffer.from(payloadBase64, "base64").toString());
} catch (e) {
console.error("❌ Failed to decode token payload:", e);
return null;
}
};
// --- START OF THE SINGLE EXPORT DEFAULT HANDLER ---
export default defineEventHandler(async (event) => {
console.log('🔍 Session endpoint called');
console.log("🔍 Session endpoint called");
const sessionCookie = getCookie(event, 'user_session');
console.log('🍪 Session cookie exists:', !!sessionCookie);
const sessionCookie = getCookie(event, "user_session");
console.log("🍪 Session cookie exists:", !!sessionCookie);
if (!sessionCookie) {
console.log('❌ No session cookie found');
throw createError({
statusCode: 401,
statusMessage: 'No session cookie found'
});
if (!sessionCookie) {
console.log("❌ No session cookie found");
throw createError({
statusCode: 401,
statusMessage: "No session cookie found",
});
}
try {
const session = JSON.parse(sessionCookie);
console.log("📋 Session parsed successfully");
const isExpired = Date.now() > session.expiresAt;
console.log("   Is Expired:", isExpired);
// Check if the token has expired
if (isExpired) {
console.log("⏰ Session has expired, clearing cookie");
deleteCookie(event, "user_session");
throw createError({
statusCode: 401,
statusMessage: "Session expired",
});
}
try {
const session = JSON.parse(sessionCookie);
console.log('📋 Session parsed successfully');
const isExpired = Date.now() > session.expiresAt;
console.log('   Is Expired:', isExpired);
// Decode tokens and prepare the enhanced response data
const idTokenPayload = decodeTokenPayload(session.idToken);
const accessTokenPayload = decodeTokenPayload(session.accessToken);
// Check if the token has expired
if (isExpired) {
console.log('⏰ Session has expired, clearing cookie');
deleteCookie(event, 'user_session');
throw createError({
statusCode: 401,
statusMessage: 'Session expired'
});
}
// Final response object for the frontend debug page
const sessionResponse = {
// Basic User Info
user: session.user,
// Decode tokens and prepare the enhanced response data
const idTokenPayload = decodeTokenPayload(session.idToken);
const accessTokenPayload = decodeTokenPayload(session.accessToken);
// Final response object for the frontend debug page
const sessionResponse = {
// Basic User Info
user: session.user,
// Raw Tokens
idToken: session.idToken,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
// Session Timestamps
expiresAt: session.expiresAt,
createdAt: session.createdAt,
// Raw Tokens
idToken: session.idToken,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
// Parsed Payloads
idTokenPayload: idTokenPayload,
accessTokenPayload: accessTokenPayload,
// Session Timestamps
expiresAt: session.expiresAt,
createdAt: session.createdAt,
// Raw Session Data (for Debug section)
fullSessionObject: session,
status: 'authenticated',
};
// Parsed Payloads
idTokenPayload: idTokenPayload,
accessTokenPayload: accessTokenPayload,
console.log('✅ Session is valid, returning full session data');
return sessionResponse;
// Raw Session Data (for Debug section)
fullSessionObject: session,
} catch (parseError) {
console.error('❌ Failed to parse session cookie:', parseError);
// If JSON parsing fails or any other error occurs, the session is invalid
deleteCookie(event, 'user_session');
throw createError({
statusCode: 401,
statusMessage: 'Invalid session data'
});
}
status: "authenticated",
};
console.log("✅ Session is valid, returning full session data");
return sessionResponse;
} catch (parseError) {
console.error("❌ Failed to parse session cookie:", parseError);
// If JSON parsing fails or any other error occurs, the session is invalid
deleteCookie(event, "user_session");
throw createError({
statusCode: 401,
statusMessage: "Invalid session data",
});
}
});
// --- END OF THE SINGLE EXPORT DEFAULT HANDLER ---
// --- END OF THE SINGLE EXPORT DEFAULT HANDLER ---
+283
View File
@@ -0,0 +1,283 @@
// stores/klinikRuangStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useKlinikRuangStore = defineStore('klinikRuang', () => {
// State - List Master Klinik
const masterKlinikList = ref([
{ kode: 'AN', nama: 'ANAK' },
{ kode: 'AS', nama: 'ANESTESI' },
{ kode: 'BD', nama: 'BEDAH' },
{ kode: 'GR', nama: 'GERIATRI' },
{ kode: 'GI', nama: 'GIGI DAN MULUT' },
{ kode: 'GZ', nama: 'GIZI' },
{ kode: 'HO', nama: 'HOM' },
{ kode: 'IP', nama: 'IPD' },
{ kode: 'JT', nama: 'JANTUNG' },
{ kode: 'JW', nama: 'JIWA' },
{ kode: 'KK', nama: 'KULIT KELAMIN' },
{ kode: 'MT', nama: 'MATA' },
{ kode: 'OB', nama: 'OBGYN' },
{ kode: 'PR', nama: 'PARU' },
{ kode: 'RT', nama: 'RADIOTERAPI' },
]);
// State - Klinik Ruang Data
const klinikRuangList = ref([
{
id: 1,
no: 1,
kodeKlinik: 'AN',
namaKlinik: 'ANAK',
namaRuang: 'R. TINDAKAN',
ruangList: [
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
]
},
{
id: 2,
no: 2,
kodeKlinik: 'AS',
namaKlinik: 'ANESTESI',
namaRuang: 'Ruang 1, Ruang 2',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }
]
},
{
id: 3,
no: 3,
kodeKlinik: 'BD',
namaKlinik: 'BEDAH',
namaRuang: 'Ruang Konsultasi',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }
]
},
{
id: 4,
no: 4,
kodeKlinik: 'GR',
namaKlinik: 'GERIATRI',
namaRuang: 'Ruang Pemeriksaan',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }
]
},
{
id: 5,
no: 5,
kodeKlinik: 'GI',
namaKlinik: 'GIGI DAN MULUT',
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
]
},
]);
// Computed
const totalKlinikRuang = computed(() => klinikRuangList.value.length);
const totalRuangan = computed(() => {
return klinikRuangList.value.reduce((total, klinik) => {
return total + klinik.ruangList.length;
}, 0);
});
// Get klinik by code
const getKlinikByCode = (kode) => {
return klinikRuangList.value.find(k => k.kodeKlinik === kode);
};
// Get all ruang for a specific klinik
const getRuangByKlinik = (kodeKlinik) => {
const klinik = klinikRuangList.value.find(k => k.kodeKlinik === kodeKlinik);
return klinik ? klinik.ruangList : [];
};
// Actions - CRUD Operations
// Create new Klinik Ruang
const createKlinikRuang = (data) => {
const newId = Math.max(...klinikRuangList.value.map(r => r.id), 0) + 1;
const newNo = klinikRuangList.value.length + 1;
// Generate nama ruang untuk display
const namaRuangDisplay = data.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
const newKlinikRuang = {
id: newId,
no: newNo,
kodeKlinik: data.kodeKlinik,
namaKlinik: data.namaKlinik,
namaRuang: namaRuangDisplay,
ruangList: data.ruangList
};
klinikRuangList.value.push(newKlinikRuang);
return {
success: true,
message: `Klinik Ruang ${data.namaKlinik} berhasil ditambahkan`,
data: newKlinikRuang
};
};
// Update existing Klinik Ruang
const updateKlinikRuang = (id, data) => {
const index = klinikRuangList.value.findIndex(r => r.id === id);
if (index === -1) {
return {
success: false,
message: 'Klinik Ruang tidak ditemukan'
};
}
// Generate nama ruang untuk display
const namaRuangDisplay = data.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
klinikRuangList.value[index] = {
...klinikRuangList.value[index],
kodeKlinik: data.kodeKlinik,
namaKlinik: data.namaKlinik,
namaRuang: namaRuangDisplay,
ruangList: data.ruangList
};
return {
success: true,
message: `Klinik Ruang ${data.namaKlinik} berhasil diupdate`,
data: klinikRuangList.value[index]
};
};
// Delete Klinik Ruang
const deleteKlinikRuang = (id) => {
const index = klinikRuangList.value.findIndex(r => r.id === id);
if (index === -1) {
return {
success: false,
message: 'Klinik Ruang tidak ditemukan'
};
}
const deletedKlinik = klinikRuangList.value[index];
klinikRuangList.value.splice(index, 1);
// Reorder numbers
klinikRuangList.value.forEach((r, idx) => {
r.no = idx + 1;
});
return {
success: true,
message: `Klinik Ruang ${deletedKlinik.namaKlinik} berhasil dihapus`
};
};
// Add ruang to existing klinik
const addRuangToKlinik = (klinikId, ruangData) => {
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
if (!klinik) {
return {
success: false,
message: 'Klinik tidak ditemukan'
};
}
klinik.ruangList.push(ruangData);
// Update display name
klinik.namaRuang = klinik.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
return {
success: true,
message: `Ruang ${ruangData.namaRuang} berhasil ditambahkan`,
data: klinik
};
};
// Remove ruang from klinik
const removeRuangFromKlinik = (klinikId, ruangIndex) => {
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
if (!klinik) {
return {
success: false,
message: 'Klinik tidak ditemukan'
};
}
if (klinik.ruangList.length <= 1) {
return {
success: false,
message: 'Minimal harus ada 1 ruangan'
};
}
const removedRuang = klinik.ruangList[ruangIndex];
klinik.ruangList.splice(ruangIndex, 1);
// Update display name
klinik.namaRuang = klinik.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
return {
success: true,
message: `Ruang ${removedRuang.namaRuang} berhasil dihapus`,
data: klinik
};
};
// Search klinik ruang
const searchKlinikRuang = (searchTerm) => {
if (!searchTerm) return klinikRuangList.value;
const term = searchTerm.toLowerCase();
return klinikRuangList.value.filter(k =>
k.kodeKlinik.toLowerCase().includes(term) ||
k.namaKlinik.toLowerCase().includes(term) ||
k.namaRuang.toLowerCase().includes(term)
);
};
return {
// State
masterKlinikList,
klinikRuangList,
// Computed
totalKlinikRuang,
totalRuangan,
// Getters
getKlinikByCode,
getRuangByKlinik,
// Actions
createKlinikRuang,
updateKlinikRuang,
deleteKlinikRuang,
addRuangToKlinik,
removeRuangFromKlinik,
searchKlinikRuang,
};
});
+691
View File
@@ -0,0 +1,691 @@
// stores/masterStore.js - Integrated dengan Penunjang
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useMasterStore = defineStore('master', () => {
// ============================================
// MASTER KLINIK
// ============================================
const klinikData = ref([
{
id: 1,
no: 1,
kode: 'AN',
nama: 'ANAK',
shift: 1,
totalQuota: 1000,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 1000 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 2,
no: 2,
kode: 'AS',
nama: 'ANESTESI',
shift: 2,
totalQuota: 1500,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 1000 },
{ dari: '13:00', sampai: '16:00', kuota: 500 }
],
autoShift: false,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{
id: 3,
no: 3,
kode: 'BD',
nama: 'BEDAH',
shift: 1,
totalQuota: 800,
jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }],
autoShift: false,
jadwalKlinik: ['Senin', 'Rabu', 'Jumat'],
},
{
id: 4,
no: 4,
kode: 'GI',
nama: 'GIGI DAN MULUT',
shift: 3,
totalQuota: 2100,
jamShiftList: [
{ dari: '07:00', sampai: '11:00', kuota: 1000 },
{ dari: '13:00', sampai: '16:00', kuota: 700 },
{ dari: '18:00', sampai: '20:00', kuota: 400 }
],
autoShift: true,
jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'],
},
{ id: 5, no: 5, kode: 'GR', nama: 'GERIATRI', shift: 1, totalQuota: 600, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
{ id: 6, no: 6, kode: 'GZ', nama: 'GIZI', shift: 1, totalQuota: 500, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 500 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 7, no: 7, kode: 'HO', nama: 'HOM', shift: 1, totalQuota: 750, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 750 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu'] },
{ id: 8, no: 8, kode: 'IP', nama: 'IPD', shift: 1, totalQuota: 900, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 900 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 9, no: 9, kode: 'JT', nama: 'JANTUNG', shift: 2, totalQuota: 1200, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }, { dari: '13:00', sampai: '16:00', kuota: 400 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'] },
{ id: 10, no: 10, kode: 'JW', nama: 'JIWA', shift: 1, totalQuota: 700, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
{ id: 11, no: 11, kode: 'KK', nama: 'KULIT KELAMIN', shift: 1, totalQuota: 600, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }], autoShift: false, jadwalKlinik: ['Selasa', 'Kamis'] },
{ id: 12, no: 12, kode: 'MT', nama: 'MATA', shift: 1, totalQuota: 800, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 800 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 13, no: 13, kode: 'OB', nama: 'KANDUNGAN', shift: 2, totalQuota: 1000, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }, { dari: '13:00', sampai: '16:00', kuota: 400 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'] },
{ id: 14, no: 14, kode: 'PR', nama: 'PARU', shift: 1, totalQuota: 700, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
{ id: 15, no: 15, kode: 'RT', nama: 'RADIOTERAPI', shift: 1, totalQuota: 500, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 500 }], autoShift: false, jadwalKlinik: ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jum\'at'] },
{ id: 16, no: 16, kode: 'RM', nama: 'REHAB MEDIK', shift: 1, totalQuota: 600, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 600 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu', 'Jumat'] },
{ id: 17, no: 17, kode: 'SR', nama: 'SARAF', shift: 1, totalQuota: 700, jamShiftList: [{ dari: '07:00', sampai: '11:00', kuota: 700 }], autoShift: false, jadwalKlinik: ['Senin', 'Rabu'] },
]);
// Computed - Get klinik list for dropdowns
const klinikList = computed(() =>
klinikData.value.map(k => ({
id: k.id,
kode: k.kode,
nama: k.nama
}))
);
// Actions - Klinik
const addKlinik = (klinikPayload) => {
const newId = Math.max(...klinikData.value.map(k => k.id), 0) + 1;
const newNo = klinikData.value.length + 1;
const totalQuota = klinikPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
const newKlinik = {
id: newId,
no: newNo,
...klinikPayload,
totalQuota: totalQuota,
};
klinikData.value.push(newKlinik);
return { success: true, message: `Klinik ${newKlinik.nama} berhasil ditambahkan`, data: newKlinik };
};
const updateKlinik = (klinikPayload) => {
const index = klinikData.value.findIndex(k => k.id === klinikPayload.id);
if (index !== -1) {
const totalQuota = klinikPayload.jamShiftList.reduce((total, shift) => {
return total + (parseInt(shift.kuota) || 0);
}, 0);
klinikData.value[index] = {
...klinikData.value[index],
...klinikPayload,
totalQuota: totalQuota,
};
return { success: true, message: `Klinik ${klinikPayload.nama} berhasil diupdate` };
}
return { success: false, message: 'Klinik tidak ditemukan' };
};
const deleteKlinik = (klinikId) => {
const index = klinikData.value.findIndex(k => k.id === klinikId);
if (index !== -1) {
const klinikName = klinikData.value[index].nama;
klinikData.value.splice(index, 1);
klinikData.value.forEach((k, idx) => {
k.no = idx + 1;
});
return { success: true, message: `Klinik ${klinikName} berhasil dihapus` };
}
return { success: false, message: 'Klinik tidak ditemukan' };
};
const getKlinikById = (id) => {
return klinikData.value.find(k => k.id === id);
};
const getKlinikByKode = (kode) => {
return klinikData.value.find(k => k.kode === kode);
};
// ============================================
// MASTER LOKET
// ============================================
const loketData = ref([
{
id: 1,
no: 1,
namaLoket: "Loket 1",
kuota: 500,
pelayanan: ["RT", "RM", "TD"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 2,
no: 2,
namaLoket: "Loket 2",
kuota: 666,
pelayanan: ["JW", "SR"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 3,
no: 3,
namaLoket: "Loket 3",
kuota: 666,
pelayanan: ["AS", "JT"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
{
id: 4,
no: 4,
namaLoket: "Loket 4",
kuota: 3676,
pelayanan: ["KK", "PR"],
pembayaran: "JKN",
keterangan: "ONLINE",
statusPelayanan: "RAWAT JALAN",
},
]);
const availableServices = computed(() =>
klinikData.value.map(k => ({
id: k.kode,
nama: k.nama,
}))
);
// Actions - Loket
const addLoket = (loketPayload) => {
const newId = Math.max(...loketData.value.map(l => l.id), 0) + 1;
const newNo = loketData.value.length + 1;
const newLoket = {
id: newId,
no: newNo,
...loketPayload,
};
loketData.value.push(newLoket);
return { success: true, message: `Loket ${newLoket.namaLoket} berhasil ditambahkan`, data: newLoket };
};
const updateLoket = (loketPayload) => {
const index = loketData.value.findIndex(l => l.id === loketPayload.id);
if (index !== -1) {
loketData.value[index] = {
...loketData.value[index],
...loketPayload,
};
return { success: true, message: `Loket ${loketPayload.namaLoket} berhasil diupdate` };
}
return { success: false, message: 'Loket tidak ditemukan' };
};
const deleteLoket = (loketId) => {
const index = loketData.value.findIndex(l => l.id === loketId);
if (index !== -1) {
const loketName = loketData.value[index].namaLoket;
loketData.value.splice(index, 1);
loketData.value.forEach((l, idx) => {
l.no = idx + 1;
});
return { success: true, message: `Loket ${loketName} berhasil dihapus` };
}
return { success: false, message: 'Loket tidak ditemukan' };
};
const getLoketById = (id) => {
return loketData.value.find(l => l.id === id);
};
// ============================================
// MASTER KLINIK RUANG
// ============================================
const ruangData = ref([
{
id: 1,
no: 1,
kodeKlinik: 'AN',
namaKlinik: 'ANAK',
namaRuang: 'R. TINDAKAN',
ruangList: [
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
]
},
{
id: 2,
no: 2,
kodeKlinik: 'AS',
namaKlinik: 'ANESTESI',
namaRuang: 'Ruang 1, Ruang 2',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }
]
},
{
id: 3,
no: 3,
kodeKlinik: 'GI',
namaKlinik: 'GIGI DAN MULUT',
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
]
},
]);
// Computed
const totalKlinikRuang = computed(() => ruangData.value.length);
const totalRuangan = computed(() => {
return ruangData.value.reduce((total, klinik) => {
return total + klinik.ruangList.length;
}, 0);
});
// Actions - Ruang
const addRuang = (ruangPayload) => {
const newId = Math.max(...ruangData.value.map(r => r.id), 0) + 1;
const newNo = ruangData.value.length + 1;
const namaRuangDisplay = ruangPayload.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
const newRuang = {
id: newId,
no: newNo,
...ruangPayload,
namaRuang: namaRuangDisplay,
};
ruangData.value.push(newRuang);
return { success: true, message: `Ruang klinik ${newRuang.namaKlinik} berhasil ditambahkan`, data: newRuang };
};
const updateRuang = (ruangPayload) => {
const index = ruangData.value.findIndex(r => r.id === ruangPayload.id);
if (index !== -1) {
const namaRuangDisplay = ruangPayload.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
ruangData.value[index] = {
...ruangData.value[index],
...ruangPayload,
namaRuang: namaRuangDisplay,
};
return { success: true, message: `Ruang klinik ${ruangPayload.namaKlinik} berhasil diupdate` };
}
return { success: false, message: 'Ruang tidak ditemukan' };
};
const deleteRuang = (ruangId) => {
const index = ruangData.value.findIndex(r => r.id === ruangId);
if (index !== -1) {
const ruangName = `${ruangData.value[index].namaKlinik} - ${ruangData.value[index].namaRuang}`;
ruangData.value.splice(index, 1);
ruangData.value.forEach((r, idx) => {
r.no = idx + 1;
});
return { success: true, message: `Ruang ${ruangName} berhasil dihapus` };
}
return { success: false, message: 'Ruang tidak ditemukan' };
};
const getRuangByKlinik = (kodeKlinik) => {
return ruangData.value.filter(r => r.kodeKlinik === kodeKlinik);
};
// Get all ruang list (for antrian display)
const getAllRuangList = computed(() => {
const allRuang = [];
ruangData.value.forEach(klinik => {
klinik.ruangList.forEach(ruang => {
allRuang.push({
kodeKlinik: klinik.kodeKlinik,
namaKlinik: klinik.namaKlinik,
...ruang
});
});
});
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
// ============================================
const getKlinikNameByKode = (kode) => {
const klinik = klinikData.value.find(k => k.kode === kode);
return klinik ? klinik.nama : kode;
};
const getPenunjangNameByKode = (kode) => {
const penunjang = penunjangData.value.find(p => p.kode === kode);
return penunjang ? penunjang.nama : kode;
};
return {
// ============================================
// KLINIK
// ============================================
// State
klinikData,
klinikList,
// Actions
addKlinik,
updateKlinik,
deleteKlinik,
getKlinikById,
getKlinikByKode,
// ============================================
// LOKET
// ============================================
// State
loketData,
availableServices,
// Actions
addLoket,
updateLoket,
deleteLoket,
getLoketById,
// ============================================
// RUANG
// ============================================
// State
ruangData,
totalKlinikRuang,
totalRuangan,
getAllRuangList,
// Actions
addRuang,
updateRuang,
deleteRuang,
getRuangByKlinik,
// ============================================
// PENUNJANG
// ============================================
// State
penunjangData,
totalPenunjang,
activePenunjang,
penunjangMedis,
penunjangNonMedis,
penunjangList,
// Actions
addPenunjang,
updatePenunjang,
deletePenunjang,
getPenunjangById,
getPenunjangByKode,
getActivePenunjangList,
// ============================================
// UTILITIES
// ============================================
getKlinikNameByKode,
getPenunjangNameByKode,
};
});
-82
View File
@@ -1,82 +0,0 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
// Add other main menu items
{ id: 2, name: "Loket Admin", icon: "mdi-account-supervisor", path: "/LoketAdmin" },
{ id: 3, name: "Ranap Admin", icon: "mdi-bed", path: "/RanapAdmin" },
{ id: 4, name: "Klinik Admin", icon: "mdi-hospital-box", path: "/KlinikAdmin" },
{ id: 5, name: "Klinik Ruang Admin", icon: "mdi-hospital-marker", path: "/KlinikRuangAdmin" },
{
id: 6,
name: "Anjungan",
icon: "mdi-account-box-multiple",
path: "",
children: [
{ id: 7, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-account-box" },
{ id: 8, name: "Admin Anjungan", path: "/Anjungan/AdminAnjungan", icon: "mdi-account-cog" },
],
},
{ id: 9, name: "Fast Track", icon: "mdi-clock-fast", path: "/FastTrack" },
{ id: 10, name: "Data Pasien", icon: "mdi-account-multiple", path: "/DataPasien" },
{
id: 11,
name: "Screen",
icon: "mdi-monitor",
path: "",
children: [
{ id: 12, name: "Antrian Masuk 1", path: "/Screen/Antrian Masuk 1", icon: "mdi-monitor" },
{ id: 13, name: "Antrian Masuk 2", path: "/Screen/Antrian Masuk 2", icon: "mdi-monitor" },
// ... more screen pages
],
},
{ id: 14, name: "List Pasien", icon: "mdi-format-list-bulleted", path: "/ListPasien" },
{
id: 15 ,
name: "Setting",
icon: "mdi-cog",
path: "",
children: [
{ id: 16, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-shield-lock-outline" },
{ id: 17, name: "User Login", path: "/setting/UserLogin", icon: "mdi-account-circle" },
{ id: 18, name: "Master Loket", path: "/setting/MasterLoket", icon: "mdi-counter" },
{ id: 19, name: "Master Klinik", path: "/setting/MasterKlinik", icon: "mdi-hospital" },
{ id: 20, name: "Master Klinik Ruang", path: "/setting/MasterKlinikRuang", icon: "mdi-hospital-box" },
{ id: 21, name: "Screen", path: "/setting/Screen", icon: "mdi-monitor" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
function updateNavItems(newItems: NavItem[]) {
// This will update the local storage and the state
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
updateNavItems,
addNavItem,
};
});
+114
View File
@@ -0,0 +1,114 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
import { computed } from 'vue'; // Import computed dari Vue
import { computed } from 'vue'; // Import computed dari Vue
interface NavItem {
id: number;
name: string; // Menggantikan 'title'
path: string; // Menggantikan 'to'
name: string; // Menggantikan 'title'
path: string; // Menggantikan 'to'
icon: string;
children?: NavItem[];
badge?: string; // Tambahkan properti badge
badge?: string; // Tambahkan properti badge
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
path: "/checkinPasien/checkin"
// badge: "3",
},
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" },
{ id: 5, name: "Admin Klinik", icon: "mdi-text-box-plus-outline", path: "/AdminKlinik" },
{ id: 6, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" },
{ id: 7, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" },
{ id: 8, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" },
{
id: 9,
name: "Layar Informasi",
icon: "mdi-monitor-multiple",
id: 9,
name: "Layar Informasi",
icon: "mdi-monitor-multiple",
path: "",
children: [
{ id: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Klinik Ruang", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
{ id: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Klinik Ruang", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
],
},
{
id: 12,
name: "Master Data",
icon: "mdi-cog-outline",
{
id: 12,
name: "Master Data",
icon: "mdi-cog-outline",
path: "",
children: [
{ id: 13, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-circle-small" },
{ id: 14, name: "User Login", path: "/setting/UserLogin", icon: "mdi-circle-small" },
{ id: 15, name: "Master Loket", path: "/setting/masterloket", icon: "mdi-circle-small" },
{ id: 16, name: "Master Klinik", path: "/setting/masterklinik", icon: "mdi-circle-small" },
{ id: 17, name: "Master Klinik Ruang", path: "/setting/masterklinikruang", icon: "mdi-circle-small" },
{ id: 18, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
// === GETTER PENTING UNTUK MENGATASI MASALAH 'NULL' DI AWAL ===
const getNavItems = computed(() => {
// Jika navItems.value null, undefined, atau bukan array yang valid,
// kembalikan array default.
if (!Array.isArray(navItems.value) || navItems.value === null || navItems.value === undefined) {
return defaultNavItems;
}
return navItems.value;
});
// =============================================================
// === GETTER PENTING UNTUK MENGATASI MASALAH 'NULL' DI AWAL ===
const getNavItems = computed(() => {
// Jika navItems.value null, undefined, atau bukan array yang valid,
// kembalikan array default.
if (!Array.isArray(navItems.value) || navItems.value === null || navItems.value === undefined) {
return defaultNavItems;
}
return navItems.value;
});
// =============================================================
function updateNavItems(newItems: NavItem[]) {
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
getNavItems, // Diekspor untuk digunakan di Sidebar
getNavItems, // Diekspor untuk digunakan di Sidebar
updateNavItems,
addNavItem,
};
});
+93
View File
@@ -0,0 +1,93 @@
// stores/navItems.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
import { computed } from 'vue'; // Import computed dari Vue
interface NavItem {
id: number;
name: string; // Menggantikan 'title'
path: string; // Menggantikan 'to'
icon: string;
children?: NavItem[];
badge?: string; // Tambahkan properti badge
}
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
path: "/checkinPasien/checkin"
// badge: "3",
},
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" },
{ id: 5, name: "Admin Klinik", icon: "mdi-text-box-plus-outline", path: "/AdminKlinik" },
{ id: 6, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" },
{ id: 7, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" },
{ id: 8, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" },
{
id: 9,
name: "Layar Informasi",
icon: "mdi-monitor-multiple",
path: "",
children: [
{ id: 10, name: "Anjungan", path: "/anjungan/anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Klinik", path: "/anjungan/AntrianKlinik", icon: "mdi-circle-small" },
{ id: 12, name: "Klinik Ruang", path: "/anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"},
{ id: 13, name: "Penunjang", path: "/anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
],
},
{
id: 14,
name: "Master Data",
icon: "mdi-cog-outline",
path: "",
children: [
{ id: 15, name: "Hak Akses", path: "/setting/HakAkses", icon: "mdi-circle-small" },
{ id: 16, name: "User Login", path: "/setting/UserLogin", icon: "mdi-circle-small" },
{ 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: "Master Penunjang", path: "/setting/masterpenunjang", icon: "mdi-circle-small" },
{ id: 21, name: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
],
},
];
export const useNavItemsStore = defineStore('navItems', () => {
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
// === GETTER PENTING UNTUK MENGATASI MASALAH 'NULL' DI AWAL ===
const getNavItems = computed(() => {
// Jika navItems.value null, undefined, atau bukan array yang valid,
// kembalikan array default.
if (!Array.isArray(navItems.value) || navItems.value === null || navItems.value === undefined) {
return defaultNavItems;
}
return navItems.value;
});
// =============================================================
function updateNavItems(newItems: NavItem[]) {
navItems.value = newItems.map((item, index) => ({
...item,
id: index + 1
}));
}
function addNavItem(newItem: Omit<NavItem, 'id'>) {
const newId = navItems.value.length > 0 ? Math.max(...navItems.value.map(item => item.id)) + 1 : 1;
navItems.value.push({ ...newItem, id: newId });
}
return {
navItems,
getNavItems, // Diekspor untuk digunakan di Sidebar
updateNavItems,
addNavItem,
};
});
+21
View File
@@ -0,0 +1,21 @@
// /stores/permissionStore.ts
import { defineStore } from "pinia";
export const usePermissionStore = defineStore("permission", {
state: () => ({
data: null as any,
}),
actions: {
async load(path: string) {
const parts = path.split("/").filter(Boolean);
const group = parts[1] || "";
const role = parts.at(-1)?.toLowerCase() || "";
const url = `http://10.10.150.131:8080/api/permission?roles=${role}&groups=${group}`;
const { data } = await useFetch(url);
this.data = data.value;
},
can(action: string) {
return this.data?.[action] === true;
},
},
});
+504
View File
@@ -0,0 +1,504 @@
// stores/queueStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useQueueStore = defineStore('queue', () => {
// State
const allPatients = ref([
{
no: 1,
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: 2,
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: 3,
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: 4,
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: 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",
createdAt: new Date().toISOString(),
},
]);
const quotaUsed = ref(5);
const currentProcessingPatient = ref({
loket: null,
klinik: null,
penunjang: null,
});
const kliniks = ref([
{ id: 1, name: "KANDUNGAN" },
{ id: 2, name: "IPD" },
{ id: 3, name: "THT" },
{ id: 4, name: "SARAF" },
{ id: 5, name: "JIWA" },
{ id: 6, name: "BEDAH" },
{ id: 7, name: "MATA" },
{ id: 8, name: "ANAK" },
{ id: 9, name: "KULIT" },
]);
const penunjangs = ref([
{ id: 1, name: "LABORATORIUM" },
{ id: 2, name: "RADIOLOGI" },
{ id: 3, name: "USG" },
{ id: 4, name: "CT SCAN" },
{ id: 5, name: "MRI" },
{ id: 6, name: "EKG" },
{ id: 7, name: "ENDOSKOPI" },
{ id: 8, name: "FISIOTERAPI" },
{ id: 9, name: "FARMASI" },
]);
// Computed - Filter berdasarkan process stage dan status
const getPatientsByStage = (stage) => {
return computed(() => {
const patients = allPatients.value.filter(p => p.processStage === stage);
return {
all: patients,
waiting: patients.filter(p => p.status === 'waiting'),
diLoket: patients.filter(p => p.status === 'di-loket'),
terlambat: patients.filter(p => p.status === 'terlambat'),
pending: patients.filter(p => p.status === 'pending'),
};
});
};
// Total pasien per stage
const getTotalPasienByStage = (stage) => {
return computed(() =>
allPatients.value.filter(p => p.processStage === stage).length
);
};
const totalPasien = computed(() => allPatients.value.length);
// Actions
const callNext = (adminType = 'loket') => {
const stageMap = {
'loket': 'loket',
'klinik': 'klinik',
'penunjang': 'penunjang'
};
const targetStage = stageMap[adminType];
const nextPatient = allPatients.value.find(p =>
p.status === 'waiting' && p.processStage === targetStage
);
if (!nextPatient) {
return { success: false, message: "Tidak ada pasien selanjutnya" };
}
if (quotaUsed.value >= 150) {
return { success: false, message: "Quota sudah penuh" };
}
// Update dengan cara yang Vue reactive
const index = allPatients.value.findIndex(p => p.no === nextPatient.no);
if (index !== -1) {
allPatients.value[index] = { ...allPatients.value[index], status: "di-loket" };
currentProcessingPatient.value[adminType] = allPatients.value[index];
}
quotaUsed.value++;
return {
success: true,
message: `Memanggil pasien ${nextPatient.noAntrian.split(" |")[0]}`,
};
};
const callMultiplePatients = (count, adminType = 'loket') => {
const stageMap = {
'loket': 'loket',
'klinik': 'klinik',
'penunjang': 'penunjang'
};
const targetStage = stageMap[adminType];
const waitingList = allPatients.value.filter(p =>
p.status === 'waiting' && p.processStage === targetStage
);
const patientsToCall = waitingList.slice(0, count);
if (patientsToCall.length === 0) {
return { success: false, message: "Tidak ada pasien yang menunggu" };
}
if (quotaUsed.value + patientsToCall.length > 150) {
return { success: false, message: "Quota tidak mencukupi" };
}
// Update dengan cara yang Vue reactive
patientsToCall.forEach((patient) => {
const index = allPatients.value.findIndex(p => p.no === patient.no);
if (index !== -1) {
allPatients.value[index] = { ...allPatients.value[index], status: "di-loket" };
}
});
quotaUsed.value += patientsToCall.length;
return {
success: true,
message: `Memanggil ${patientsToCall.length} pasien ke loket`,
};
};
const processPatient = (patient, action, adminType = 'loket') => {
const patientCode = patient.noAntrian.split(" |")[0];
let message = "";
// Find patient index for reactive update
const patientIndex = allPatients.value.findIndex(p => p.no === patient.no);
if (patientIndex === -1) {
return { success: false, message: "Pasien tidak ditemukan" };
}
switch (action) {
case "check-in":
// Jika check-in di loket, pindahkan ke klinik dengan status di-loket
if (adminType === 'loket') {
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "di-loket",
processStage: "klinik"
};
message = `Pasien ${patientCode} berhasil check in dan masuk ke Tabel Loket Klinik`;
}
// Jika check-in di klinik, selesai
else if (adminType === 'klinik') {
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "processed"
};
message = `Pasien ${patientCode} berhasil check in di Klinik`;
}
// Jika check-in di penunjang, selesai
else if (adminType === 'penunjang') {
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "processed"
};
message = `Pasien ${patientCode} berhasil check in di Penunjang`;
}
// Clear current processing di admin yang melakukan check-in
if (currentProcessingPatient.value[adminType]?.no === patient.no) {
currentProcessingPatient.value[adminType] = null;
}
break;
case "terlambat":
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "terlambat"
};
if (currentProcessingPatient.value[adminType]?.no === patient.no) {
currentProcessingPatient.value[adminType] = null;
}
message = `Pasien ${patientCode} ditandai terlambat`;
break;
case "pending":
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "pending"
};
if (currentProcessingPatient.value[adminType]?.no === patient.no) {
currentProcessingPatient.value[adminType] = null;
}
message = `Pasien ${patientCode} di-pending`;
break;
case "aktifkan":
const currentStatus = allPatients.value[patientIndex].status;
if (currentStatus === "terlambat" || currentStatus === "pending") {
// PERBAIKAN: Update dengan cara yang Vue reactive
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "di-loket"
};
message = `Pasien ${patientCode} diaktifkan kembali dan masuk ke tabel Di Loket`;
} else {
message = `Pasien ${patientCode} tidak dapat diaktifkan (status: ${currentStatus})`;
}
break;
case "proses":
// Ambil data terbaru dari array
currentProcessingPatient.value[adminType] = allPatients.value[patientIndex];
message = `Memproses pasien ${patientCode}`;
break;
}
return { success: true, message };
};
const createAntreanKlinik = (klinik, adminType = 'loket') => {
const newNo = allPatients.value.length + 1;
const timestamp = new Date();
const barcode = `250811${String(timestamp.getTime()).slice(-6)}`;
const newPatient = {
no: newNo,
jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String(
timestamp.getMinutes()
).padStart(2, "0")}`,
barcode: barcode,
noAntrian: `KL${String(newNo).padStart(4, "0")} | Klinik - ${barcode}`,
shift: "Shift 1",
klinik: klinik.name,
fastTrack: "UMUM",
pembayaran: "UMUM",
status: "di-loket", // Langsung masuk tabel Di Loket Klinik
processStage: "klinik",
createdAt: timestamp.toISOString(),
};
allPatients.value.push(newPatient);
return {
success: true,
message: `Antrean klinik ${klinik.name} berhasil dibuat dan masuk ke Tabel Loket Klinik`,
patient: newPatient,
};
};
const createAntreanPenunjang = (penunjang, patient = null, adminType = 'loket') => {
const newNo = allPatients.value.length + 1;
const timestamp = new Date();
const barcode = patient ? patient.barcode : `250811${String(timestamp.getTime()).slice(-6)}`;
const newPatient = {
no: newNo,
jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String(
timestamp.getMinutes()
).padStart(2, "0")}`,
barcode: barcode,
noAntrian: `PN${String(newNo).padStart(4, "0")} | Penunjang - ${barcode}`,
shift: "Shift 1",
klinik: penunjang.name,
fastTrack: "UMUM",
pembayaran: patient ? patient.pembayaran : "UMUM",
status: "di-loket", // Langsung masuk tabel Di Loket Penunjang
processStage: "penunjang",
createdAt: timestamp.toISOString(),
referencePatient: patient ? patient.noAntrian : null,
};
allPatients.value.push(newPatient);
return {
success: true,
message: `Antrean penunjang ${penunjang.name} berhasil dibuat dan masuk ke Tabel Loket Penunjang`,
patient: newPatient,
};
};
const changeKlinik = (patient, newKlinik, adminType = 'loket') => {
const patientIndex = allPatients.value.findIndex((p) => p.no === patient.no);
if (patientIndex !== -1) {
// Update dengan cara yang Vue reactive
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
klinik: newKlinik.name
};
// Update current processing if it's the same patient
if (currentProcessingPatient.value[adminType]?.no === patient.no) {
currentProcessingPatient.value[adminType] = {
...currentProcessingPatient.value[adminType],
klinik: newKlinik.name
};
}
return {
success: true,
message: `Klinik berhasil diubah ke ${newKlinik.name}`,
};
}
return { success: false, message: "Pasien tidak ditemukan" };
};
const getCurrentProcessing = (adminType) => {
return computed(() => currentProcessingPatient.value[adminType]);
};
const setCurrentProcessing = (patient, adminType) => {
currentProcessingPatient.value[adminType] = patient;
};
return {
// State
allPatients,
quotaUsed,
currentProcessingPatient,
kliniks,
penunjangs,
// Computed
totalPasien,
// Actions
callNext,
callMultiplePatients,
processPatient,
createAntreanKlinik,
createAntreanPenunjang,
changeKlinik,
getPatientsByStage,
getTotalPasienByStage,
getCurrentProcessing,
setCurrentProcessing,
};
});