Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2c68d993 | ||
|
|
10ef054de6 | ||
|
|
9b3fd7b314 | ||
|
|
817bf8f548 | ||
|
|
82b98fdfe6 | ||
|
|
642be0f9df | ||
|
|
e2cf3d309a | ||
|
|
89688e02f3 | ||
|
|
56fb6b319b | ||
|
|
39d064c30c | ||
|
|
dbb8050dc5 | ||
|
|
4960d4659f | ||
|
|
11d2a4671e | ||
|
|
35024b4797 | ||
|
|
d7f3240a82 | ||
|
|
71890ffb4c | ||
|
|
31e18c5965 | ||
|
|
0351f9116d | ||
|
|
51809468a9 | ||
|
|
a6b16e1cd1 | ||
|
|
3ad64b2223 | ||
|
|
1b1d7e331a | ||
|
|
a66f883372 | ||
|
|
28822718de | ||
|
|
bc5419ca4d | ||
|
|
d6ffc5ee9e | ||
|
|
3abb8de83a | ||
|
|
fc16b2bded | ||
|
|
89b36b7d1f | ||
|
|
15c49262f1 | ||
|
|
1f5b25a873 | ||
|
|
b1c1ff86f0 | ||
|
|
6c27987f53 |
No files matched your search
@@ -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); }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> -->
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+3371
-4520
File diff suppressed because it is too large
Load diff
+13
-1
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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']
|
||||
|
||||
+719
-441
File diff suppressed because it is too large
Load diff
+622
-333
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user