update font dan color

This commit is contained in:
bagus-arie05
2025-11-05 10:51:35 +07:00
parent dbb8050dc5
commit 39d064c30c
37 changed files with 5377 additions and 3228 deletions
+116
View File
@@ -0,0 +1,116 @@
:root {
/* Neutral Colors */
--neutral-900: #212121;
--neutral-800: #4D4D4D;
--neutral-700: #717171;
--neutral-600: #89939E;
--neutral-500: #ABBED1;
--neutral-400: #E5F7FA;
--neutral-300: #F5F7FA;
--neutral-100: #FFFFFF;
/* Primary Colors (Orange) */
--primary-700: #0053AD;
--primary-600: #0663C7;
--primary-500: #0671E0;
--primary-400: #4196F0;
--primary-300: #DBEDFF;
--primary-200: #EEF5FC;
--primary-100: #EEF5FC;
/* Secondary Colors (Blue) */
--secondary-700: #0053AD;
--secondary-600: #0671E0;
--secondary-500: #0663C7;
--secondary-400: #4196F0;
--secondary-300: #DBEDFF;
--secondary-200: #EEF5FC;
--secondary-100: #0053AD;
/* Success Colors (Green) */
--success-700: #1B6E53;
--success-600: #009262;
--success-500: #115B43;
--success-400: #32C997;
--success-300: #84DFC1;
--success-200: #F1FBF8;
/* Danger Colors (Red) */
--danger-700: #E01507;
--danger-600: #E02B1D;
--danger-500: #C33025;
--danger-400: #FF5A4F;
--danger-300: #F0857D;
--danger-200: #FFF1F0;
}
/* Utility Classes untuk Background */
.bg-neutral-900 { background-color: var(--neutral-900); }
.bg-neutral-800 { background-color: var(--neutral-800); }
.bg-neutral-700 { background-color: var(--neutral-700); }
.bg-neutral-600 { background-color: var(--neutral-600); }
.bg-neutral-500 { background-color: var(--neutral-500); }
.bg-neutral-400 { background-color: var(--neutral-400); }
.bg-neutral-300 { background-color: var(--neutral-300); }
.bg-neutral-100 { background-color: var(--neutral-100); }
.bg-primary-700 { background-color: var(--primary-700); }
.bg-primary-600 { background-color: var(--primary-600); }
.bg-primary-500 { background-color: var(--primary-500); }
.bg-primary-400 { background-color: var(--primary-400); }
.bg-primary-300 { background-color: var(--primary-300); }
.bg-primary-200 { background-color: var(--primary-200); }
.bg-primary-100 { background-color: var(--primary-100); }
.bg-secondary-700 { background-color: var(--secondary-700); }
.bg-secondary-600 { background-color: var(--secondary-600); }
.bg-secondary-500 { background-color: var(--secondary-500); }
.bg-secondary-400 { background-color: var(--secondary-400); }
.bg-secondary-300 { background-color: var(--secondary-300); }
.bg-secondary-200 { background-color: var(--secondary-200); }
.bg-success-700 { background-color: var(--success-700); }
.bg-success-600 { background-color: var(--success-600); }
.bg-success-500 { background-color: var(--success-500); }
.bg-success-400 { background-color: var(--success-400); }
.bg-success-300 { background-color: var(--success-300); }
.bg-success-200 { background-color: var(--success-200); }
.bg-danger-700 { background-color: var(--danger-700); }
.bg-danger-600 { background-color: var(--danger-600); }
.bg-danger-500 { background-color: var(--danger-500); }
.bg-danger-400 { background-color: var(--danger-400); }
.bg-danger-300 { background-color: var(--danger-300); }
.bg-danger-200 { background-color: var(--danger-200); }
/* Utility Classes untuk Text Color */
.text-neutral-900 { color: var(--neutral-900); }
.text-neutral-800 { color: var(--neutral-800); }
.text-neutral-700 { color: var(--neutral-700); }
.text-neutral-600 { color: var(--neutral-600); }
.text-neutral-500 { color: var(--neutral-500); }
.text-neutral-100 { color: var(--neutral-100); }
.text-primary-700 { color: var(--primary-700); }
.text-primary-600 { color: var(--primary-600); }
.text-primary-500 { color: var(--primary-500); }
.text-primary-400 { color: var(--primary-400); }
.text-secondary-700 { color: var(--secondary-700); }
.text-secondary-600 { color: var(--secondary-600); }
.text-secondary-500 { color: var(--secondary-500); }
.text-success-700 { color: var(--success-700); }
.text-success-600 { color: var(--success-600); }
.text-success-500 { color: var(--success-500); }
.text-danger-700 { color: var(--danger-700); }
.text-danger-600 { color: var(--danger-600); }
.text-danger-500 { color: var(--danger-500); }
/* Utility Classes untuk Border */
.border-primary-500 { border-color: var(--primary-500); }
.border-secondary-500 { border-color: var(--secondary-500); }
.border-success-500 { border-color: var(--success-500); }
.border-danger-500 { border-color: var(--danger-500); }
.border-neutral-500 { border-color: var(--neutral-500); }
+109
View File
@@ -0,0 +1,109 @@
/* Base Font */
* {
font-family: 'Inter', sans-serif;
}
/* Headlines - Desktop */
.headline-1 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 64px;
line-height: 76px;
}
.headline-2 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 36px;
line-height: 44px;
}
.headline-3 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 28px;
line-height: 36px;
}
.headline-4 {
font-family: 'Inter', sans-serif;
font-weight: 600; /* Semi Bold */
font-size: 20px;
line-height: 28px;
}
/* Body - Desktop */
.body-1 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 18px;
line-height: 28px;
}
.body-2 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 16px;
line-height: 24px;
}
.body-3 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 14px;
line-height: 20px;
}
/* Caption - Desktop */
.text-1 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 16px;
line-height: 24px;
}
.text-2 {
font-family: 'Inter', sans-serif;
font-weight: 400; /* Regular */
font-size: 12px;
line-height: 16px;
}
/* Mobile Typography */
@media (max-width: 768px) {
.headline-1 {
font-weight: 700; /* Bold */
font-size: 28px;
line-height: 36px;
}
.headline-2 {
font-weight: 600; /* Semi Bold */
font-size: 20px;
line-height: 28px;
}
.headline-3 {
font-weight: 600; /* Semi Bold */
font-size: 18px;
line-height: 24px;
}
.headline-4 {
font-weight: 600; /* Semi Bold */
font-size: 18px;
line-height: 24px;
}
.body {
font-weight: 500; /* Medium */
font-size: 16px;
line-height: 24px;
}
.caption {
font-weight: 400; /* Regular */
font-size: 14px;
line-height: 20px;
}
}
+166
View File
@@ -0,0 +1,166 @@
@use 'sass:map';
// Color Palette Variables
$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: #0053AD;
$primary-600: #0663C7;
$primary-500: #0671E0;
$primary-400: #4196F0;
$primary-300: #DBEDFF;
$primary-200: #EEF5FC;
$primary-100: #EEF5FC;
$secondary-700: #0053AD;
$secondary-600: #0671E0;
$secondary-500: #0663C7;
$secondary-400: #4196F0;
$secondary-300: #DBEDFF;
$secondary-200: #EEF5FC;
$success-700: #1B6E53;
$success-600: #009262;
$success-500: #115B43;
$success-400: #32C997;
$success-300: #84DFC1;
$success-200: #F1FBF8;
$danger-700: #E01507;
$danger-600: #E02B1D;
$danger-500: #C33025;
$danger-400: #FF5A4F;
$danger-300: #F0857D;
$danger-200: #FFF1F0;
// CSS Variables untuk dynamic theming
:root {
// Neutral
--color-neutral-900: #{$neutral-900};
--color-neutral-800: #{$neutral-800};
--color-neutral-700: #{$neutral-700};
--color-neutral-600: #{$neutral-600};
--color-neutral-500: #{$neutral-500};
--color-neutral-400: #{$neutral-400};
--color-neutral-300: #{$neutral-300};
--color-neutral-100: #{$neutral-100};
// Primary
--color-primary-700: #{$primary-700};
--color-primary-600: #{$primary-600};
--color-primary-500: #{$primary-500};
--color-primary-400: #{$primary-400};
--color-primary-300: #{$primary-300};
--color-primary-200: #{$primary-200};
--color-primary-100: #{$primary-100};
// Secondary
--color-secondary-700: #{$secondary-700};
--color-secondary-600: #{$secondary-600};
--color-secondary-500: #{$secondary-500};
--color-secondary-400: #{$secondary-400};
--color-secondary-300: #{$secondary-300};
--color-secondary-200: #{$secondary-200};
// Success
--color-success-700: #{$success-700};
--color-success-600: #{$success-600};
--color-success-500: #{$success-500};
--color-success-400: #{$success-400};
--color-success-300: #{$success-300};
--color-success-200: #{$success-200};
// Danger
--color-danger-700: #{$danger-700};
--color-danger-600: #{$danger-600};
--color-danger-500: #{$danger-500};
--color-danger-400: #{$danger-400};
--color-danger-300: #{$danger-300};
--color-danger-200: #{$danger-200};
// Theme colors (akan di-override oleh theme system)
--color-primary: #{$primary-500};
--color-secondary: #{$secondary-500};
--color-success: #{$success-600};
--color-danger: #{$danger-600};
--color-background: #{$neutral-300};
--color-surface: #{$neutral-100};
--color-textPrimary: #{$neutral-900};
--color-textSecondary: #{$neutral-700};
--color-borderColor: #{$neutral-500};
}
// Dark mode base
.dark {
--color-background: #1a1a1a;
--color-surface: #{$neutral-900};
--color-textPrimary: #{$neutral-100};
--color-textSecondary: #{$neutral-500};
--color-borderColor: #{$neutral-800};
}
// Mixin untuk generate color utilities
@mixin color-utilities($prefix, $color) {
.text-#{$prefix} {
color: $color;
}
.bg-#{$prefix} {
background-color: $color;
}
.border-#{$prefix} {
border-color: $color;
}
}
// Generate color utilities
$colors: (
'neutral-900': $neutral-900,
'neutral-800': $neutral-800,
'neutral-700': $neutral-700,
'neutral-600': $neutral-600,
'neutral-500': $neutral-500,
'neutral-400': $neutral-400,
'neutral-300': $neutral-300,
'neutral-100': $neutral-100,
'primary-700': $primary-700,
'primary-600': $primary-600,
'primary-500': $primary-500,
'primary-400': $primary-400,
'primary-300': $primary-300,
'primary-200': $primary-200,
'secondary-700': $secondary-700,
'secondary-600': $secondary-600,
'secondary-500': $secondary-500,
'success-700': $success-700,
'success-600': $success-600,
'success-500': $success-500,
'danger-700': $danger-700,
'danger-600': $danger-600,
'danger-500': $danger-500
);
@each $name, $color in $colors {
@include color-utilities($name, $color);
}
// Shorthand semantic colors
.text-primary { color: $primary-500; }
.text-secondary { color: $secondary-500; }
.text-success { color: $success-600; }
.text-danger { color: $danger-600; }
.text-muted { color: $neutral-600; }
.bg-primary { background-color: $primary-500; }
.bg-secondary { background-color: $secondary-500; }
.bg-success { background-color: $success-600; }
.bg-danger { background-color: $danger-600; }
.bg-light { background-color: $neutral-300; }
.bg-white { background-color: $neutral-100; }
+121
View File
@@ -0,0 +1,121 @@
@use 'sass:map';
@use './variables' as *;
// Mixin untuk generate typography classes
@mixin typography($name, $size, $line-height, $weight) {
.#{$name} {
font-family: $font-family-base;
font-size: $size;
line-height: $line-height;
font-weight: $weight;
margin: 0;
}
}
// Base typography
* {
font-family: $font-family-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: $font-family-base;
font-size: 16px;
line-height: 24px;
font-weight: $font-weight-regular;
margin: 0;
padding: 0;
}
// Generate desktop typography classes - Gunakan map.get
@each $name, $props in $font-sizes {
@include typography(
$name,
map.get($props, 'size'),
map.get($props, 'line-height'),
map.get($props, 'weight')
);
}
// Generate mobile typography classes - Gunakan map.get
@media (max-width: $breakpoint-mobile) {
@each $name, $props in $font-sizes-mobile {
@include typography(
$name,
map.get($props, 'size'),
map.get($props, 'line-height'),
map.get($props, 'weight')
);
}
}
// HTML semantic tags
h1 {
@extend .headline-1;
}
h2 {
@extend .headline-2;
}
h3 {
@extend .headline-3;
}
h4 {
@extend .headline-4;
}
p {
@extend .body-1;
}
small {
@extend .caption-2;
}
// Utility classes
.text-regular {
font-weight: $font-weight-regular !important;
}
.text-medium {
font-weight: $font-weight-medium !important;
}
.text-semibold {
font-weight: $font-weight-semibold !important;
}
.text-bold {
font-weight: $font-weight-bold !important;
}
.text-uppercase {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
// Line clamp utilities
@for $i from 1 through 5 {
.line-clamp-#{$i} {
display: -webkit-box;
-webkit-line-clamp: $i;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
+117
View File
@@ -0,0 +1,117 @@
@use 'sass:map';
@use 'sass:math';
// Font Family
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
// Font Weights
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// Font Sizes & Line Heights - Desktop
$font-sizes: (
'headline-1': (
'size': 64px,
'line-height': 76px,
'weight': $font-weight-semibold
),
'headline-2': (
'size': 36px,
'line-height': 44px,
'weight': $font-weight-semibold
),
'headline-3': (
'size': 28px,
'line-height': 36px,
'weight': $font-weight-semibold
),
'headline-4': (
'size': 20px,
'line-height': 28px,
'weight': $font-weight-semibold
),
'body-1': (
'size': 18px,
'line-height': 28px,
'weight': $font-weight-regular
),
'body-2': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-regular
),
'body-3': (
'size': 14px,
'line-height': 20px,
'weight': $font-weight-regular
),
'caption-1': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-regular
),
'caption-2': (
'size': 12px,
'line-height': 16px,
'weight': $font-weight-regular
)
);
// Font Sizes - Mobile
$font-sizes-mobile: (
'headline-1': (
'size': 28px,
'line-height': 36px,
'weight': $font-weight-bold
),
'headline-2': (
'size': 20px,
'line-height': 28px,
'weight': $font-weight-semibold
),
'headline-3': (
'size': 18px,
'line-height': 24px,
'weight': $font-weight-semibold
),
'headline-4': (
'size': 18px,
'line-height': 24px,
'weight': $font-weight-semibold
),
'body': (
'size': 16px,
'line-height': 24px,
'weight': $font-weight-medium
),
'caption': (
'size': 14px,
'line-height': 20px,
'weight': $font-weight-regular
)
);
// Breakpoints
$breakpoint-mobile: 768px;
$breakpoint-tablet: 1024px;
$breakpoint-desktop: 1280px;
// Mixins
@mixin heading($level) {
@if map.has-key($font-sizes, $level) {
$props: map.get($font-sizes, $level);
font-size: map.get($props, 'size');
line-height: map.get($props, 'line-height');
font-weight: map.get($props, 'weight');
font-family: $font-family-base;
}
}
@mixin responsive-text {
@media (max-width: $breakpoint-mobile) {
font-size: 14px;
line-height: 20px;
}
}
+71
View File
@@ -0,0 +1,71 @@
// Gunakan @use daripada @import
@use './variables' as *;
@use './colors' as *;
@use './typography' as *;
// Global styles
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
background-color: var(--color-background);
color: var(--color-textPrimary);
transition: background-color 0.3s ease, color 0.3s ease;
}
// Selection color
::selection {
background-color: var(--color-primary-300);
color: var(--color-primary-700);
}
// Focus styles
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
// Responsive images
img {
max-width: 100%;
height: auto;
display: block;
}
// Links
a {
color: var(--color-primary-500);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--color-primary-600);
}
&:active {
color: var(--color-primary-700);
}
}
// Buttons base
button {
font-family: $font-family-base;
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
+1 -1
View File
@@ -110,7 +110,7 @@
}
.hospital-logo {
height: 64px;
height: 232px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
}
+180
View File
@@ -0,0 +1,180 @@
<template>
<v-navigation-drawer
:model-value="drawer"
:rail="rail"
permanent
app
class="bg-white d-flex flex-column"
@update:model-value="emit('update:drawer', $event)"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<v-list-item class="py-4 px-2" :class="{ 'text-center': rail }">
<div class="d-flex align-center" :class="{ 'justify-center': rail }">
<img
src="/Antrean Logo.png"
alt="Antrean Logo"
style="width: 72px; height: 72px; object-fit: contain"
/>
<v-list-item-title
v-if="!rail"
class="ml-2 text-h6 font-weight-bold text-blue-grey-darken-4"
>
<span class="text-orange-darken-2">Antrean</span> RSSA
</v-list-item-title>
</div>
</v-list-item>
<v-divider></v-divider>
<v-list
density="compact"
nav
class="mt-2 flex-grow-1"
v-model:opened="openedGroups"
>
<template v-for="item in items" :key="item.name">
<v-list-group v-if="item.children" :value="item.name" :disabled="rail">
<template v-slot:activator="{ props: groupProps }">
<v-list-item
v-bind="groupProps"
:prepend-icon="item.icon"
:title="item.name"
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
</template>
<v-list-item
v-for="child in item.children"
:key="child.name"
:to="child.path"
:title="child.name"
:prepend-icon="child.icon"
link
class="pl-8"
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
</v-list-group>
<v-tooltip
v-else
:disabled="!rail"
open-on-hover
location="end"
:text="item.name"
>
<template #activator="{ props: tooltipProps }">
<v-list-item
v-bind="tooltipProps"
:prepend-icon="item.icon"
:title="item.name"
:to="item.path"
link
color="orange-darken-2"
active-class="bg-orange-lighten-5 text-orange-darken-2 font-weight-bold"
></v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
<v-divider></v-divider>
<v-list v-if="user" nav density="compact" class="pa-2 flex-shrink-0">
<ProfilePopup :user="user" @logout="handleLogout" :rail="rail" />
</v-list>
<v-list v-else nav density="compact" class="flex-shrink-0">
<v-list-item
@click="redirectToLogin"
prepend-icon="mdi-login"
title="Login"
link
color="blue-grey-darken-4"
></v-list-item>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref, watch } from "vue";
import { navigateTo } from "#app";
import ProfilePopup from "./ProfilePopup.vue";
import { useAuth } from "~/composables/useAuth";
const { user, logout, checkAuth } = useAuth();
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
const props = defineProps({
items: {
type: Array as () => NavItem[],
required: true,
},
rail: {
type: Boolean,
required: true,
},
drawer: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:drawer", "toggle-rail"]);
const openedGroups = ref<string[]>([]);
watch(
() => props.rail,
(newRailState) => {
if (newRailState === true) {
openedGroups.value = [];
}
}
);
const isHovering = ref(false);
const handleMouseEnter = () => {
if (props.rail) {
isHovering.value = true;
emit("toggle-rail");
}
};
const handleMouseLeave = () => {
if (isHovering.value) {
isHovering.value = false;
emit("toggle-rail");
}
};
// --- AUTH LOGIC (Kept the same) ---
const handleLogout = async () => {
console.log("🚪 SideBar logout initiated...");
try {
await logout();
} catch (error) {
console.error("❌ SideBar logout error:", error);
}
};
const redirectToLogin = () => {
navigateTo("/LoginPage");
};
onMounted(async () => {
await checkAuth();
});
</script>
<style scoped>
/* No specific overrides needed */
</style>
-210
View File
@@ -1,210 +0,0 @@
<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}">
<v-icon color="orange-darken-2" size="36">mdi-hospital-box-outline</v-icon>
<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">Antrian</span> RSSA
</v-list-item-title>
</div>
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav class="mt-2" 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
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>
>>>>>>> d7f3240a8226d36d66c5dc319c7a665539d5d8e3
<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 WATCH
import { navigateTo } from '#app';
import ProfilePopup from './ProfilePopup.vue';
import { useAuth } from '~/composables/useAuth';
const { user, logout, checkAuth } = useAuth();
const route = useRoute(); // Access the current route
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']);
// --- NEW STATE FOR DROPDOWN GROUPS ---
// This holds the names of the currently open list groups.
const openedGroups = ref<string[]>([]);
// --- NEW WATCHER TO CLOSE DROPDOWNS ---
watch(() => props.rail, (newRailState) => {
if (newRailState === true) {
// If the sidebar is collapsing (rail is true), close all groups
openedGroups.value = [];
}
});
// ------------------------------------
// Local state for hover-to-expand rail feature
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 ---
const handleLogout = async () => {
console.log('🚪 SideBar logout initiated...');
try {
// Calls the external logout function (from useAuth.ts)
await logout();
} catch (error) {
console.error('❌ SideBar logout error:', error);
}
};
const redirectToLogin = () => {
navigateTo('/LoginPage');
};
onMounted(async () => {
// Fetches real user data on mount
await checkAuth();
});
</script>
<style scoped>
/* No specific overrides needed */
</style>
+1 -1
View File
@@ -15,7 +15,7 @@
<script setup lang="ts">
import { ref } from "vue";
import SideBar from "../components/SideBar1.vue";
import SideBar from "../components/SideBar.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '~/stores/navItems1';
+49 -34
View File
@@ -1,38 +1,39 @@
// nuxt.config.ts
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
compatibilityDate: "2025-05-15",
devtools: {
enabled: true,
timeline: {
enabled: true
}
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",
"@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
googleFonts: {
families: {
Inter: [400, 500, 600, 700],
},
display: "swap",
},
runtimeConfig: {
authSecret: process.env.NUXT_AUTH_SECRET,
@@ -40,23 +41,37 @@ export default defineNuxtConfig({
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || 'http://localhost:3001'
}
authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
},
},
build: {
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",
],
vite: {
ssr: {
noExternal: ['vuetify']
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
// ✅ Ini akan membuat variables & colors tersedia di SEMUA component
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"],
},
},
});
+336 -4
View File
@@ -23,6 +23,7 @@
"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",
@@ -31,7 +32,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"
@@ -3191,6 +3194,18 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/@nuxtjs/google-fonts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/google-fonts/-/google-fonts-3.2.0.tgz",
"integrity": "sha512-cGAjDJoeQ2jm6VJCo4AtSmKO6KjsbO9RSLj8q261fD0lMVNMZCxkCxBkg8L0/2Vfgp+5QBHWVXL71p1tiybJFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.10.3",
"google-fonts-helper": "^3.5.0",
"pathe": "^1.1.2"
}
},
"node_modules/@nuxtjs/mdc": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@nuxtjs/mdc/-/mdc-0.9.5.tgz",
@@ -5444,6 +5459,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/parse-path": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@@ -7534,6 +7559,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-api": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
@@ -8352,6 +8386,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -8536,6 +8579,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -10127,6 +10176,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/google-fonts-helper": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/google-fonts-helper/-/google-fonts-helper-3.7.3.tgz",
"integrity": "sha512-dENZeK6RMvkCOFoKbLbS+pyoDgpmk4U6BYmfXnztMar2d8SjEG6valxql/Lkyi7bu5F+3FTdE+gAEYSYZ5zzMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deepmerge": "^4.3.1",
"hookable": "^5.5.3",
"ofetch": "^1.4.1",
"ufo": "^1.5.4"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -13524,6 +13586,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -13811,6 +13882,15 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -14511,6 +14591,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode.vue": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz",
@@ -14520,6 +14617,174 @@
"vue": "^3.0.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -15027,6 +15292,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reserved-identifiers": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
@@ -15289,11 +15560,11 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.93.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz",
"integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==",
"devOptional": true,
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -15368,6 +15639,27 @@
"sass": "1.93.2"
}
},
"node_modules/sass-embedded-all-unknown/node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.2.tgz",
@@ -15608,6 +15900,27 @@
"sass": "1.93.2"
}
},
"node_modules/sass-embedded-unknown-all/node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.2.tgz",
@@ -15764,6 +16077,12 @@
"node": ">= 18"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -16930,6 +17249,13 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unenv": {
"version": "2.0.0-rc.23",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.23.tgz",
@@ -18313,6 +18639,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+3
View File
@@ -28,6 +28,7 @@
"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",
@@ -36,7 +37,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"
+25 -24
View File
@@ -9,29 +9,28 @@
<v-icon size="32" color="white">mdi-hospital-building</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Klinik Admin</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
<h1 class="page-title">Admin Klinik</h1>
<!-- <p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p> -->
</div>
</div>
<div class="header-right">
<!-- <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">
Max 150 Pasien
</v-chip>
</div>
</div> -->
</div>
</div>
<!-- Combined Control Section -->
<!-- Control Section -->
<v-row class="mb-4">
<!-- Left Side: Current Processing -->
<v-col cols="12" lg="6">
<!-- Current Patient Processing Card -->
<v-card class="current-processing-card" elevation="2">
<v-card-text class="pa-4">
<!-- State when there's a patient being processed -->
<div
v-if="currentProcessingPatient"
class="d-flex align-center justify-space-between"
@@ -61,7 +60,7 @@
@click="processPatient(currentProcessingPatient, 'check-in')"
>
<v-icon start>mdi-check-circle</v-icon>
Check In
Selesai
</v-btn>
<v-btn
color="warning"
@@ -95,7 +94,7 @@
</div>
</div>
<!-- Empty state when no patient is being processed -->
<!-- Empty state (tidak ada pasien) -->
<div v-else class="text-center pa-6">
<v-icon size="64" color="grey-lighten-1" class="mb-3">
mdi-account-off-outline
@@ -104,7 +103,8 @@
Tidak Ada Pasien yang Diproses
</div>
<div class="text-body-2 text-grey">
Pasien akan muncul otomatis saat check-in dari loket atau buat antrean klinik baru
Pasien akan muncul otomatis saat check-in dari loket atau buat
antrean klinik baru
</div>
</div>
</v-card-text>
@@ -155,7 +155,7 @@
<div class="call-buttons">
<v-btn
color="success"
variant="flat"
variant="outlined"
class="call-btn"
@click="callNext"
:disabled="!nextPatient"
@@ -164,7 +164,7 @@
</v-btn>
<v-btn
color="info"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(5)"
>
@@ -172,7 +172,7 @@
</v-btn>
<v-btn
color="warning"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(10)"
>
@@ -180,7 +180,7 @@
</v-btn>
<v-btn
color="error"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(20)"
>
@@ -197,18 +197,20 @@
<v-col cols="12">
<div class="d-flex justify-center gap-2">
<v-btn
color="primary"
variant="flat"
size="large"
color="#FF9B1B"
variant="flat"
class="text-white"
@click="showKlinikDialog = true"
>
<v-icon start>mdi-hospital-building</v-icon>
Buat Antrean Klinik Ruang
</v-btn>
<v-btn
color="secondary"
variant="flat"
size="large"
color="#32A3FF"
variant="flat"
class="text-white"
@click="openPenunjangDialog()"
>
<v-icon start>mdi-clipboard-pulse</v-icon>
@@ -227,14 +229,14 @@
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
<!-- <v-btn
size="small"
color="info"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
</v-btn> -->
<v-btn
size="small"
color="success"
@@ -248,7 +250,7 @@
</TabelData>
</v-card>
<!-- Terlambat Patients Table -->
<!-- Tabel Pasien Terlambat -->
<v-card
class="late-table-card mb-4"
elevation="2"
@@ -274,7 +276,7 @@
</TabelData>
</v-card>
<!-- Pending Patients Table -->
<!-- Tabel Pasien Pending -->
<v-card
class="pending-table-card mb-4"
elevation="2"
@@ -465,7 +467,6 @@ import { ref } from "vue";
import TabelData from "../components/TabelData.vue";
import { useQueue } from "../composables/useQueue";
// Use queue composable with 'klinik' admin type
const {
snackbar,
snackbarText,
@@ -493,7 +494,7 @@ const {
selectPenunjang,
openPenunjangDialog,
changeKlinik,
} = useQueue('klinik');
} = useQueue("klinik");
const diLoketHeaders = ref([
{ title: "No", value: "no", sortable: false, width: "60px" },
@@ -743,4 +744,4 @@ const pendingHeaders = ref([
font-size: 28px;
}
}
</style>
</style>
+51 -29
View File
@@ -9,18 +9,19 @@
<v-icon size="32" color="white">mdi-view-dashboard</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Loket Admin</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
<h1 class="page-title">Admin Loket</h1>
<p class="page-subtitle">{{ currentDate }}</p>
<!-- <p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p> -->
</div>
</div>
<div class="header-right">
<!-- <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">
Max 150 Pasien
</v-chip>
</div>
</div> -->
</div>
</div>
@@ -34,10 +35,18 @@
class="current-processing-card"
elevation="2"
>
<v-card-text class="pa-4">
<div class="d-flex align-center justify-space-between">
<v-card-text class="pa-4 d-flex flex-column">
<div
class="d-flex align-center justify-space-between"
>
<div class="patient-info">
<v-chip color="primary" variant="flat" class="mb-2">
<v-chip
color="primary"
variant="flat"
class="mb-2"
>
<v-icon start>mdi-account-clock</v-icon>
SEDANG DIPROSES
</v-chip>
@@ -61,7 +70,7 @@
@click="processPatient(currentProcessingPatient, 'check-in')"
>
<v-icon start>mdi-check-circle</v-icon>
Check In
Selesai
</v-btn>
<v-btn
color="warning"
@@ -98,8 +107,12 @@
</v-card>
<!-- Empty State -->
<v-card v-else class="current-processing-card" elevation="2">
<v-card-text class="pa-6 text-center">
<v-card
v-else
class="current-processing-card d-flex flex-column align-center justify-center"
elevation="2"
>
<v-card-text class="pa-6 text-center" >
<v-icon size="64" color="grey-lighten-1" class="mb-3">
mdi-account-off-outline
</v-icon>
@@ -157,7 +170,7 @@
<div class="call-buttons">
<v-btn
color="success"
variant="flat"
variant="outlined"
class="call-btn"
@click="callNext"
:disabled="!nextPatient"
@@ -166,7 +179,7 @@
</v-btn>
<v-btn
color="info"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(5)"
>
@@ -174,7 +187,7 @@
</v-btn>
<v-btn
color="warning"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(10)"
>
@@ -182,7 +195,7 @@
</v-btn>
<v-btn
color="error"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(20)"
>
@@ -197,20 +210,22 @@
<v-row class="mb-4">
<v-col cols="12">
<div class="d-flex justify-center gap-2">
<div class="d-flex justify-center mx-4">
<v-btn
color="primary"
variant="flat"
size="large"
color="#FF9B1B"
variant="flat"
class="text-white"
@click="showKlinikDialog = true"
>
<v-icon start>mdi-hospital-building</v-icon>
Buat Antrean Klinik Ruang
</v-btn>
<v-btn
color="secondary"
variant="flat"
size="large"
color="#32A3FF"
variant="flat"
class="text-white"
@click="openPenunjangDialog()"
>
<v-icon start>mdi-clipboard-pulse</v-icon>
@@ -229,18 +244,19 @@
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
<!-- <v-btn
size="small"
color="info"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
</v-btn> -->
<v-btn
size="small"
color="success"
color="#FF9B1B"
variant="flat"
class="text-white"
@click="processPatient(item, 'proses')"
>
Proses
@@ -495,8 +511,15 @@ const {
selectPenunjang,
openPenunjangDialog,
changeKlinik,
} = useQueue('loket');
} = useQueue("loket");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
// Headers for different tables
const diLoketHeaders = ref([
{ title: "No", value: "no", sortable: false, width: "60px" },
@@ -539,10 +562,10 @@ const pendingHeaders = ref([
}
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
box-shadow: 0 8px 32px rgba(126, 126, 126, 0.3);
}
.header-content {
@@ -584,8 +607,6 @@ const pendingHeaders = ref([
align-items: center;
}
.current-processing-card,
.quota-call-card,
.main-table-card,
.late-table-card,
.pending-table-card {
@@ -596,6 +617,7 @@ const pendingHeaders = ref([
.current-processing-card {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
border: 2px solid rgba(255, 152, 0, 0.2);
}
.quota-call-card {
@@ -746,4 +768,4 @@ const pendingHeaders = ref([
font-size: 28px;
}
}
</style>
</style>
+14 -14
View File
@@ -9,18 +9,18 @@
<v-icon size="32" color="white">mdi-clipboard-pulse</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Penunjang Admin</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
<h1 class="page-title">Admin Penunjang</h1>
<!-- <p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p> -->
</div>
</div>
<div class="header-right">
<!-- <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">
Max 150 Pasien
</v-chip>
</div>
</div> -->
</div>
</div>
@@ -31,7 +31,7 @@
<!-- Current Patient Processing Card -->
<v-card class="current-processing-card" elevation="2">
<v-card-text class="pa-4">
<!-- State when there's a patient being processed -->
<!-- Empty State (Tidak ada pasien yg diproses) -->
<div
v-if="currentProcessingPatient"
class="d-flex align-center justify-space-between"
@@ -61,7 +61,7 @@
@click="processPatient(currentProcessingPatient, 'check-in')"
>
<v-icon start>mdi-check-circle</v-icon>
Check In
Selesai
</v-btn>
<v-btn
color="warning"
@@ -155,7 +155,7 @@
<div class="call-buttons">
<v-btn
color="success"
variant="flat"
variant="outlined"
class="call-btn"
@click="callNext"
:disabled="!nextPatient"
@@ -164,7 +164,7 @@
</v-btn>
<v-btn
color="info"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(5)"
>
@@ -172,7 +172,7 @@
</v-btn>
<v-btn
color="warning"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(10)"
>
@@ -180,7 +180,7 @@
</v-btn>
<v-btn
color="error"
variant="flat"
variant="outlined"
class="call-btn"
@click="callMultiplePatients(20)"
>
@@ -197,7 +197,7 @@
<v-col cols="12">
<div class="d-flex justify-center">
<v-btn
color="secondary"
color="#32A3FF"
variant="flat"
size="large"
@click="showPenunjangDialog = true"
@@ -219,14 +219,14 @@
>
<template #actions="{ item }">
<div class="d-flex gap-1">
<v-btn
<!-- <v-btn
size="small"
color="info"
variant="flat"
@click="processPatient(item, 'aktifkan')"
>
Aktifkan
</v-btn>
</v-btn> -->
<v-btn
size="small"
color="success"
@@ -476,7 +476,7 @@ const pendingHeaders = ref([
}
.page-header {
background: linear-gradient(135deg, #9c27b0 0%, #7b1fa2 100%);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(156, 39, 176, 0.3);
-189
View File
@@ -1,189 +0,0 @@
<template>
<v-divider class="my-8"></v-divider>
<v-main class="bg-grey-lighten-3">
<!-- Konten utama dibungkus dalam div yang menyesuaikan padding kiri -->
<div :style="contentStyle">
<v-container fluid>
<h1 class="text-h4">Admin Anjungan</h1>
<v-card class="pa-5 mb-5" color="white" flat>
<v-row align="center">
<v-col cols="12" md="4">
<v-text-field
label="Barcode"
placeholder="Masukkan Barcode"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-chip color="#B71C1C" class="text-caption">
Tekan Enter. (Barcode depan nomor selalu ada huruf lain, Ex:
J20073010005 "Hiraukan huruf 'J' nya")
</v-chip>
</v-col>
<v-col cols="12" md="2">
<v-btn block color="info">Pendaftaran Online</v-btn>
</v-col>
</v-row>
</v-card>
<v-divider class="my-5"></v-divider>
<v-card class="mb-5">
<v-toolbar flat color="transparent" dense>
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
DATA PENGUNJUNG TERLAMBAT
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="searchLate"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
dense
class="mr-2"
variant="outlined"
></v-text-field>
<v-select
:items="[10, 25, 50, 100]"
label="Show"
dense
single-line
hide-details
class="shrink"
variant="outlined"
></v-select>
</v-toolbar>
<v-card-text>
<v-data-table
:headers="lateHeaders"
:items="lateVisitors"
:search="searchLate"
no-data-text="No data available in table"
hide-default-footer
class="elevation-1"
></v-data-table>
<div class="d-flex justify-end pt-2">
<v-pagination
v-model="page"
:length="10"
:total-visible="5"
></v-pagination>
</div>
</v-card-text>
</v-card>
<v-divider class="my-5"></v-divider>
<v-card>
<v-toolbar flat color="transparent" dense>
<v-toolbar-title class="text-subtitle-1 font-weight-bold red--text">
DATA PENGUNJUNG
</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
dense
class="mr-2"
variant="outlined"
></v-text-field>
<v-select
:items="[10, 25, 50, 100]"
label="Show"
dense
single-line
hide-details
class="shrink"
variant="outlined"
></v-select>
</v-toolbar>
<v-card-text>
<v-data-table
:headers="headers"
:items="visitors"
:search="search"
no-data-text="No data available in table"
class="elevation-1"
>
<template v-slot:item.aksi="{ item }">
<div class="d-flex flex-column">
<v-btn small color="success" class="my-1">Tiket</v-btn>
<v-btn small color="primary" class="my-1">Tiket Pengantar</v-btn>
<v-btn small color="warning" class="my-1">ByPass</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</div>
</v-main>
</template>
<script setup>
import { ref, computed } from "vue";
// Definisikan props untuk menerima status 'rail' dari layout induk
const props = defineProps({
rail: Boolean,
});
// Reactive data
const search = ref("");
const searchLate = ref("");
const page = ref(1);
// Gaya komputasi untuk menyesuaikan padding
const contentStyle = computed(() => {
return {
paddingLeft: props.rail ? '56px' : '64px',
transition: 'padding-left 0.3s ease-in-out',
};
});
// Table headers for late visitors
const lateHeaders = [
{ text: 'No', value: 'no' },
{ text: 'Barcode', value: 'barcode' },
{ text: 'No Rekamedik', value: 'noRekamedik' },
{ text: 'No Antrian', value: 'noAntrian' },
{ text: 'No Antrian Klinik', value: 'noAntrianKlinik' },
{ text: 'Shift', value: 'shift' },
{ text: 'Pembayaran', value: 'pembayaran' },
{ text: 'Status', value: 'status' },
];
// Table headers for all visitors
const headers = [
{ text: 'No', value: 'no' },
{ text: 'Barcode', value: 'barcode' },
{ text: 'No Rekamedik', value: 'noRekamedik' },
{ text: 'No Antrian', value: 'noAntrian' },
{ text: 'Shift', value: 'shift' },
{ text: 'Ket', value: 'ket' },
{ text: 'Fast Track', value: 'fastTrack' },
{ text: 'Pembayaran', value: 'pembayaran' },
{ text: 'Panggil', value: 'panggil' },
{ text: 'Aksi', value: 'aksi' },
];
// Mock data for late visitors
const lateVisitors = ref([
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', noAntrianKlinik: 'K1', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', noAntrianKlinik: 'K2', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', noAntrianKlinik: 'K3', shift: 'Shift 1', pembayaran: 'JKN', status: 'Terlambat' },
]);
// Mock data for all visitors
const visitors = ref([
{ no: 1, barcode: '250813100928', noRekamedik: 'RM001', noAntrian: 'ON1045', shift: 'Shift 1', ket: '', fastTrack: 'Ya', pembayaran: 'JKN', panggil: 'Ya' },
{ no: 2, barcode: '250813100930', noRekamedik: 'RM002', noAntrian: 'GI1018', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
{ no: 3, barcode: '250813100937', noRekamedik: 'RM003', noAntrian: 'MT1073', shift: 'Shift 1', ket: '', fastTrack: 'Tidak', pembayaran: 'JKN', panggil: 'Tidak' },
]);
</script>
+395 -159
View File
@@ -36,7 +36,7 @@
:class="{
'clinic-card': true,
'clinic-available': clinic.available,
'clinic-closed': !clinic.available
'clinic-closed': !clinic.available,
}"
:disabled="!clinic.available"
@click="selectClinic(clinic)"
@@ -50,9 +50,9 @@
variant="flat"
class="mr-2"
>
{{ clinic.available ? 'TERSEDIA' : 'TUTUP' }}
{{ clinic.available ? "TERSEDIA" : "TUTUP" }}
</v-chip>
<v-chip
<v-chip
size="small"
:color="clinic.available ? 'info' : 'error'"
variant="outlined"
@@ -73,12 +73,18 @@
{{ clinic.name }}
</h3>
<p v-if="clinic.subtitle" class="text-caption text-grey-darken-1 mb-2">
<p
v-if="clinic.subtitle"
class="text-caption text-grey-darken-1 mb-2"
>
{{ clinic.subtitle }}
</p>
<div class="shift-info">
<span v-if="clinic.schedule" class="text-caption text-grey-darken-1">
<span
v-if="clinic.schedule"
class="text-caption text-grey-darken-1"
>
{{ clinic.schedule }}
</span>
</div>
@@ -88,9 +94,15 @@
</v-row>
<div v-if="filteredClinics.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-hospital-marker-outline</v-icon>
<h3 class="text-h6 mt-4 text-grey-darken-1">Tidak ada klinik yang sesuai filter</h3>
<p class="text-body-2 text-grey-darken-1">Coba ubah filter pencarian Anda</p>
<v-icon size="64" color="grey-lighten-1"
>mdi-hospital-marker-outline</v-icon
>
<h3 class="text-h6 mt-4 text-grey-darken-1">
Tidak ada klinik yang sesuai filter
</h3>
<p class="text-body-2 text-grey-darken-1">
Coba ubah filter pencarian Anda
</p>
</div>
</v-card-text>
</v-card>
@@ -101,23 +113,37 @@
<v-icon class="mr-2">mdi-check-circle</v-icon>
Konfirmasi Pilihan
</v-card-title>
<v-card-text class="pa-6" v-if="selectedClinic">
<div class="text-center">
<div class="mb-3">
<v-icon :icon="selectedClinic.icon" size="48" color="primary"></v-icon>
<v-icon
:icon="selectedClinic.icon"
size="48"
color="primary"
></v-icon>
</div>
<h3 class="text-h5 font-weight-bold mb-2">{{ selectedClinic.name }}</h3>
<p v-if="selectedClinic.subtitle" class="text-body-1 text-grey-darken-1 mb-3">
<h3 class="text-h5 font-weight-bold mb-2">
{{ selectedClinic.name }}
</h3>
<p
v-if="selectedClinic.subtitle"
class="text-body-1 text-grey-darken-1 mb-3"
>
{{ selectedClinic.subtitle }}
</p>
<v-divider class="my-4"></v-divider>
<div class="text-left">
<p><strong>Shift:</strong> {{ selectedClinic.shift }}</p>
<p v-if="selectedClinic.schedule"><strong>Jadwal:</strong> {{ selectedClinic.schedule }}</p>
<p><strong>Status:</strong> <span class="text-success">Tersedia</span></p>
<p v-if="selectedClinic.schedule">
<strong>Jadwal:</strong> {{ selectedClinic.schedule }}
</p>
<p>
<strong>Status:</strong>
<span class="text-success">Tersedia</span>
</p>
</div>
</div>
</v-card-text>
@@ -151,7 +177,11 @@
</v-btn>
</v-card-text>
<v-card-actions class="justify-center pa-4 pt-0">
<v-btn variant="outlined" color="grey-darken-1" @click="showVisitTypeDialog = false">
<v-btn
variant="outlined"
color="grey-darken-1"
@click="showVisitTypeDialog = false"
>
Tutup
</v-btn>
</v-card-actions>
@@ -185,7 +215,11 @@
</v-btn>
</v-card-text>
<v-card-actions class="justify-center pa-4 pt-0">
<v-btn variant="outlined" color="grey-darken-1" @click="showPaymentTypeDialog = false">
<v-btn
variant="outlined"
color="grey-darken-1"
@click="showPaymentTypeDialog = false"
>
Tutup
</v-btn>
</v-card-actions>
@@ -231,7 +265,11 @@
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn @click="showBookingFormDialog = false" variant="outlined" color="grey-darken-1">
<v-btn
@click="showBookingFormDialog = false"
variant="outlined"
color="grey-darken-1"
>
Tutup
</v-btn>
<v-btn color="success" variant="flat" @click="submitBooking">
@@ -296,7 +334,11 @@
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn @click="showFastTrackFormDialog = false" variant="outlined" color="grey-darken-1">
<v-btn
@click="showFastTrackFormDialog = false"
variant="outlined"
color="grey-darken-1"
>
Tutup
</v-btn>
<v-btn color="success" variant="flat" @click="submitFastTrack">
@@ -323,189 +365,383 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted } from "vue";
definePageMeta({
layout: false, // Disables the layout for this specific page
})
layout: false,
});
// Reactive data (tambahan untuk dialog baru)
const loading = ref(false)
const selectedStatus = ref(null)
const searchQuery = ref('')
const showVisitTypeDialog = ref(false) // Dialog: Pilih Kunjungan
const showPaymentTypeDialog = ref(false) // Dialog: Jenis Pembayaran (Sekarang)
const showBookingFormDialog = ref(false) // Dialog: Form Pesan
const showFastTrackFormDialog = ref(false) // Dialog: Form Fast Track
const selectedClinic = ref(null)
const selectedVisitType = ref(null)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const loading = ref(false);
const selectedStatus = ref(null);
const searchQuery = ref("");
const showVisitTypeDialog = ref(false); // Dialog: Pilih Kunjungan
const showPaymentTypeDialog = ref(false); // Dialog: Jenis Pembayaran (Sekarang)
const showBookingFormDialog = ref(false); // Dialog: Form Pesan
const showFastTrackFormDialog = ref(false); // Dialog: Form Fast Track
const selectedClinic = ref(null);
const selectedVisitType = ref(null);
const snackbar = ref(false);
const snackbarText = ref("");
const snackbarColor = ref("success");
// Form data models
const bookingForm = ref({
date: new Date().toISOString().substring(0, 10),
shift: 'Shift 1',
shift: "Shift 1",
payment: null,
})
});
const fastTrackForm = ref({
guarantor: '',
noRek: '',
patientName: '',
reason: '',
guarantor: "",
noRek: "",
patientName: "",
reason: "",
payment: null,
})
});
const statusOptions = ["Tersedia", "Tutup"];
// Options (Tidak Berubah)
const statusOptions = ['Tersedia', 'Tutup']
// Clinic data (Tidak Berubah)
const clinics = ref([
// ... data klinik ... (dibiarkan seperti semula)
{ id: 1, name: 'ANAK', subtitle: '', icon: 'mdi-baby-face', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 2, name: 'ANESTESI', subtitle: '', icon: 'mdi-sleep', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 3, name: 'BEDAH', subtitle: '', icon: 'mdi-medical-bag', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 4, name: 'GERIATRI', subtitle: '', icon: 'mdi-account-supervisor', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 5, name: 'GIGI DAN MULUT', subtitle: '', icon: 'mdi-tooth', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 6, name: 'GIZI', subtitle: '', icon: 'mdi-food-apple', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 7, name: 'HEMATO ONKOLOGI MEDIS', subtitle: '', icon: 'mdi-water', shift: 'TUTUP', schedule: '', available: false, },
{ id: 8, name: 'IPD (PENYAKIT DALAM)', subtitle: '', icon: 'mdi-hospital', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 9, name: 'JANTUNG (CARDIOLOGI)', subtitle: '', icon: 'mdi-heart-pulse', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 10, name: 'JIWA', subtitle: '', icon: 'mdi-brain', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 11, name: 'KANDUNGAN', subtitle: '', icon: 'mdi-human-pregnant', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 12, name: 'KEMOTERAPI', subtitle: '', icon: 'mdi-needle', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 13, name: 'KOMPLEMENTER (NYERI)', subtitle: '', icon: 'mdi-leaf', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 14, name: 'KULIT KELAMIN', subtitle: '', icon: 'mdi-hand-back-right', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 15, name: 'MATA', subtitle: '', icon: 'mdi-eye', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 16, name: 'MCU', subtitle: '', icon: 'mdi-clipboard-check', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 17, name: 'ONKOLOGI', subtitle: '', icon: 'mdi-ribbon', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 18, name: 'PARU', subtitle: '', icon: 'mdi-lungs', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 19, name: 'R. TINDAKAN (EMG, ECG, DLL)', subtitle: '', icon: 'mdi-monitor-heart-rate', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 20, name: 'RADIOTERAPI', subtitle: '', icon: 'mdi-radioactive', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 21, name: 'REHAB MEDIK', subtitle: '', icon: 'mdi-human-cane', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, },
{ id: 22, name: 'SARAF (NEUROLOGI)', subtitle: '', icon: 'mdi-head-cog', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: false, },
{ id: 23, name: 'THT', subtitle: '', icon: 'mdi-ear-hearing', shift: 'SHIFT 1', schedule: 'Mulai Pukul 07:00', available: true, }
])
{
id: 1,
name: "ANAK",
subtitle: "",
icon: "mdi-baby-face",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 2,
name: "ANESTESI",
subtitle: "",
icon: "mdi-sleep",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 3,
name: "BEDAH",
subtitle: "",
icon: "mdi-medical-bag",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 4,
name: "GERIATRI",
subtitle: "",
icon: "mdi-account-supervisor",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 5,
name: "GIGI DAN MULUT",
subtitle: "",
icon: "mdi-tooth",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 6,
name: "GIZI",
subtitle: "",
icon: "mdi-food-apple",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 7,
name: "HEMATO ONKOLOGI MEDIS",
subtitle: "",
icon: "mdi-water",
shift: "TUTUP",
schedule: "",
available: false,
},
{
id: 8,
name: "IPD (PENYAKIT DALAM)",
subtitle: "",
icon: "mdi-hospital",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 9,
name: "JANTUNG (CARDIOLOGI)",
subtitle: "",
icon: "mdi-heart-pulse",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 10,
name: "JIWA",
subtitle: "",
icon: "mdi-brain",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 11,
name: "KANDUNGAN",
subtitle: "",
icon: "mdi-human-pregnant",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 12,
name: "KEMOTERAPI",
subtitle: "",
icon: "mdi-needle",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 13,
name: "KOMPLEMENTER (NYERI)",
subtitle: "",
icon: "mdi-leaf",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 14,
name: "KULIT KELAMIN",
subtitle: "",
icon: "mdi-hand-back-right",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 15,
name: "MATA",
subtitle: "",
icon: "mdi-eye",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 16,
name: "MCU",
subtitle: "",
icon: "mdi-clipboard-check",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 17,
name: "ONKOLOGI",
subtitle: "",
icon: "mdi-ribbon",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 18,
name: "PARU",
subtitle: "",
icon: "mdi-lungs",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 19,
name: "R. TINDAKAN (EMG, ECG, DLL)",
subtitle: "",
icon: "mdi-monitor-heart-rate",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 20,
name: "RADIOTERAPI",
subtitle: "",
icon: "mdi-radioactive",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 21,
name: "REHAB MEDIK",
subtitle: "",
icon: "mdi-human-cane",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
{
id: 22,
name: "SARAF (NEUROLOGI)",
subtitle: "",
icon: "mdi-head-cog",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: false,
},
{
id: 23,
name: "THT",
subtitle: "",
icon: "mdi-ear-hearing",
shift: "SHIFT 1",
schedule: "Mulai Pukul 07:00",
available: true,
},
]);
// Computed properties (Tidak Berubah)
const filteredClinics = computed(() => {
let filtered = clinics.value
let filtered = clinics.value;
if (selectedStatus.value) {
const isAvailable = selectedStatus.value === 'Tersedia'
filtered = filtered.filter(clinic => clinic.available === isAvailable)
const isAvailable = selectedStatus.value === "Tersedia";
filtered = filtered.filter((clinic) => clinic.available === isAvailable);
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(clinic =>
clinic.name.toLowerCase().includes(query) ||
clinic.subtitle.toLowerCase().includes(query)
)
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(
(clinic) =>
clinic.name.toLowerCase().includes(query) ||
clinic.subtitle.toLowerCase().includes(query)
);
}
return filtered
})
return filtered;
});
const totalClinics = computed(() => clinics.value.length)
const totalClinics = computed(() => clinics.value.length);
// Methods
const showSnackbar = (text, color = 'success') => {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
const showSnackbar = (text, color = "success") => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
// 1. Memilih Klinik
const selectClinic = (clinic) => {
if (clinic.available) {
selectedClinic.value = clinic
showVisitTypeDialog.value = true // Tampilkan dialog Pilih Kunjungan
selectedClinic.value = clinic;
showVisitTypeDialog.value = true;
}
}
};
// 2. Memilih Jenis Kunjungan (Sekarang, Pesan, Fast Track)
const selectVisitType = (type) => {
selectedVisitType.value = type
showVisitTypeDialog.value = false // Tutup dialog jenis kunjungan
selectedVisitType.value = type;
showVisitTypeDialog.value = false;
if (type === 'SEKARANG') {
showPaymentTypeDialog.value = true // Lanjut ke dialog Jenis Pembayaran
} else if (type === 'PESAN') {
// Reset form booking sebelum ditampilkan
if (type === "SEKARANG") {
showPaymentTypeDialog.value = true;
} else if (type === "PESAN") {
bookingForm.value = {
date: new Date().toISOString().substring(0, 10),
shift: 'Shift 1',
shift: "Shift 1",
payment: null,
}
showBookingFormDialog.value = true // Tampilkan form Pesan
} else if (type === 'FAST TRACK') {
// Reset form fast track sebelum ditampilkan
};
showBookingFormDialog.value = true;
} else if (type === "FAST TRACK") {
fastTrackForm.value = {
guarantor: '',
noRek: '',
patientName: '',
reason: '',
payment: null,
}
showFastTrackFormDialog.value = true // Tampilkan form Fast Track
guarantor: "",
noRek: "",
patientName: "",
reason: "",
payment: null,
};
showFastTrackFormDialog.value = true; // Tampilkan form Fast Track
}
}
};
// 3. Memilih Jenis Pembayaran (untuk SEKARANG)
const selectPaymentType = (paymentType) => {
showPaymentTypeDialog.value = false
const message = `Pendaftaran **${selectedClinic.value.name}** untuk kunjungan **SEKARANG** dengan pembayaran **${paymentType}** berhasil diproses.`
showSnackbar(message, 'success')
console.log('Proceeding to registration for:', selectedClinic.value.name, 'Type: SEKARANG', 'Payment:', paymentType)
selectedClinic.value = null; // Reset
}
showPaymentTypeDialog.value = false;
const message = `Pendaftaran **${selectedClinic.value.name}** untuk kunjungan **SEKARANG** dengan pembayaran **${paymentType}** berhasil diproses.`;
showSnackbar(message, "success");
console.log(
"Proceeding to registration for:",
selectedClinic.value.name,
"Type: SEKARANG",
"Payment:",
paymentType
);
selectedClinic.value = null;
};
// 4. Submit Form Pesan
const submitBooking = () => {
if (!bookingForm.value.date || !bookingForm.value.shift || !bookingForm.value.payment) {
showSnackbar('Mohon lengkapi semua field Pemesanan.', 'error')
return
if (
!bookingForm.value.date ||
!bookingForm.value.shift ||
!bookingForm.value.payment
) {
showSnackbar("Mohon lengkapi semua field Pemesanan.", "error");
return;
}
showBookingFormDialog.value = false
const message = `Pemesanan antrian **${selectedClinic.value.name}** pada ${bookingForm.value.date} (**${bookingForm.value.payment}**) berhasil disimpan.`
showSnackbar(message, 'success')
console.log('Submitted booking for:', selectedClinic.value.name, bookingForm.value)
showBookingFormDialog.value = false;
const message = `Pemesanan antrian **${selectedClinic.value.name}** pada ${bookingForm.value.date} (**${bookingForm.value.payment}**) berhasil disimpan.`;
showSnackbar(message, "success");
console.log(
"Submitted booking for:",
selectedClinic.value.name,
bookingForm.value
);
selectedClinic.value = null; // Reset
}
};
// 5. Submit Form Fast Track
const submitFastTrack = () => {
if (!fastTrackForm.value.guarantor || !fastTrackForm.value.patientName || !fastTrackForm.value.reason || !fastTrackForm.value.payment) {
showSnackbar('Mohon lengkapi semua field Fast Track yang wajib diisi.', 'error')
return
if (
!fastTrackForm.value.guarantor ||
!fastTrackForm.value.patientName ||
!fastTrackForm.value.reason ||
!fastTrackForm.value.payment
) {
showSnackbar(
"Mohon lengkapi semua field Fast Track yang wajib diisi.",
"error"
);
return;
}
showFastTrackFormDialog.value = false
const message = `Permintaan Fast Track **${selectedClinic.value.name}** atas nama ${fastTrackForm.value.patientName} (**${fastTrackForm.value.payment}**) telah diajukan.`
showSnackbar(message, 'success')
console.log('Submitted Fast Track for:', selectedClinic.value.name, fastTrackForm.value)
showFastTrackFormDialog.value = false;
const message = `Permintaan Fast Track **${selectedClinic.value.name}** atas nama ${fastTrackForm.value.patientName} (**${fastTrackForm.value.payment}**) telah diajukan.`;
showSnackbar(message, "success");
console.log(
"Submitted Fast Track for:",
selectedClinic.value.name,
fastTrackForm.value
);
selectedClinic.value = null; // Reset
}
};
const refreshData = () => {
loading.value = true
loading.value = true;
setTimeout(() => {
loading.value = false
showSnackbar('Status klinik berhasil diperbarui', 'success')
}, 1000)
}
loading.value = false;
showSnackbar("Status klinik berhasil diperbarui", "success");
}, 1000);
};
// Lifecycle
onMounted(() => {
refreshData()
})
refreshData();
});
</script>
<style scoped>
/* Style yang sudah ada (Tidak Berubah) */
.anjungan-container {
background: #f5f7fa;
min-height: 100vh;
@@ -513,7 +749,7 @@ onMounted(() => {
}
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 118, 210, 0.3);
@@ -577,13 +813,13 @@ onMounted(() => {
}
.clinic-available {
border-left: 6px solid #4CAF50;
border-left: 6px solid #4caf50;
}
.clinic-closed {
opacity: 0.7;
cursor: not-allowed;
border-left: 6px solid #F44336;
border-left: 6px solid #f44336;
background: #fafafa;
}
@@ -613,12 +849,12 @@ onMounted(() => {
gap: 20px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.page-title {
font-size: 28px;
}
@@ -628,15 +864,15 @@ onMounted(() => {
.anjungan-container {
padding: 16px;
}
.header-content {
padding: 24px 20px;
}
.page-title {
font-size: 24px;
}
.header-icon {
padding: 12px;
}
@@ -655,9 +891,9 @@ onMounted(() => {
.clinic-card {
height: 240px;
}
.page-title {
font-size: 20px;
}
}
</style>
</style>
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
<!-- pages/AntrianKlinikRuang.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-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" 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 Clinics -->
<div class="clinics-grid">
<div
v-for="clinic in displayedClinics"
:key="clinic.name"
class="clinic-box"
>
<!-- Clinic Header -->
<div class="clinic-header-bar" style="background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);">
<span class="clinic-title">{{ clinic.name }}</span>
<v-chip
:color="clinic.totalQueues > 0 ? '#FF9800' : '#89939E'"
size="small"
class="clinic-count"
>
<v-icon size="16" class="mr-1">mdi-account-multiple</v-icon>
{{ clinic.totalQueues }}
</v-chip>
</div>
<!-- Queue Content -->
<div class="clinic-content">
<!-- Current Queue -->
<div v-if="clinic.currentQueue" class="current-section">
<div class="current-label">SEKARANG</div>
<div class="current-number">
{{ clinic.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Next Queues -->
<div v-if="clinic.nextQueues.length > 0" class="next-section">
<div class="next-label">SELANJUTNYA</div>
<div class="next-numbers">
<div
v-for="queue in clinic.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-item"
>
{{ queue.noAntrian.split(' |')[0] }}
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!clinic.currentQueue && clinic.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: #FFF3E0;">
<v-icon size="32" color="#FF9800">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: #FFF3E0;">
<v-icon size="32" color="#FF9800">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="#FF9800" 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 klinikPatients = computed(() => {
return queueStore.getPatientsByStage('klinik').value.all
})
const displayedClinics = computed(() => {
const allKliniks = queueStore.kliniks.slice(0, 9)
return allKliniks.map(klinik => {
const queues = klinikPatients.value
.filter(p => p.klinik === klinik.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: klinik.name,
currentQueue: currentQueue,
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))
return calledQueues[0] || 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>
.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;
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
border-radius: 20px;
padding: 24px 40px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(255, 152, 0, 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 #FFF3E0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.08);
transition: all 0.3s ease;
min-height: 260px;
display: flex;
flex-direction: column;
}
.clinic-box:hover {
box-shadow: 0 8px 24px rgba(255, 152, 0, 0.15);
transform: translateY(-2px);
}
.clinic-header-bar {
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
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 {
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
color: #FFFFFF;
padding: 8px 18px;
border-radius: 10px;
font-size: 22px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(255, 152, 0, 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 #FFF3E0;
border-radius: 20px;
padding: 24px 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 4px 16px rgba(255, 152, 0, 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: #FFF3E0;
}
.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>
+520
View File
@@ -0,0 +1,520 @@
<!-- pages/AntrianPenunjang.vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<v-icon size="48" color="white">mdi-clipboard-pulse</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">RUMAH SAKIT</h1>
<p class="display-subtitle">Antrian Penunjang</p>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Hero Call Section -->
<div v-if="currentCalledQueue" class="hero-call-section">
<div class="call-label">
<v-icon size="32" class="mr-2">mdi-bullhorn</v-icon>
NOMOR ANTRIAN YANG DIPANGGIL
</div>
<div class="call-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="call-clinic">{{ currentCalledQueue.klinik }}</div>
</div>
<!-- Main Grid - 3x3 Penunjang -->
<div class="clinics-grid">
<div
v-for="penunjang in displayedPenunjangs"
:key="penunjang.name"
class="clinic-box"
>
<!-- Penunjang Header -->
<div class="clinic-header-bar" style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);">
<span class="clinic-title">{{ penunjang.name }}</span>
<v-chip
:color="penunjang.totalQueues > 0 ? '#9C27B0' : '#89939E'"
size="small"
class="clinic-count"
>
<v-icon size="16" class="mr-1">mdi-account-multiple</v-icon>
{{ penunjang.totalQueues }}
</v-chip>
</div>
<!-- Queue Content -->
<div class="clinic-content">
<!-- Current Queue -->
<div v-if="penunjang.currentQueue" class="current-section">
<div class="current-label">SEKARANG</div>
<div class="current-number">
{{ penunjang.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Next Queues -->
<div v-if="penunjang.nextQueues.length > 0" class="next-section">
<div class="next-label">SELANJUTNYA</div>
<div class="next-numbers">
<div
v-for="queue in penunjang.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-item"
style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);"
>
{{ queue.noAntrian.split(' |')[0] }}
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!penunjang.currentQueue && penunjang.nextQueues.length === 0" class="empty-state">
<v-icon size="56" color="#F5F7FA">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon" style="background: #F3E5F5;">
<v-icon size="32" color="#9C27B0">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon" style="background: #F3E5F5;">
<v-icon size="32" color="#9C27B0">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.waiting }}</div>
<div class="stat-label">Menunggu</div>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon" style="background: #E8F5E9;">
<v-icon size="32" color="#18B653">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="#9C27B0" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor antrian Anda di layar</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '../stores/queueStore'
definePageMeta({
layout: false,
});
const queueStore = useQueueStore()
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
const penunjangPatients = computed(() => {
return queueStore.getPatientsByStage('penunjang').value.all
})
const displayedPenunjangs = computed(() => {
const allPenunjangs = queueStore.penunjangs.slice(0, 9)
return allPenunjangs.map(penunjang => {
const queues = penunjangPatients.value
.filter(p => p.klinik === penunjang.name)
.sort((a, b) => {
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'terlambat': 3,
'pending': 4
}
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
const timeA = a.jamPanggil.split(':').map(Number)
const timeB = b.jamPanggil.split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
)
return {
name: penunjang.name,
currentQueue: currentQueue,
nextQueues: nextQueues,
totalQueues: queues.length
}
})
})
const currentCalledQueue = computed(() => {
const calledQueues = penunjangPatients.value
.filter(p => p.status === 'di-loket')
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return calledQueues[0] || null
})
const statistics = computed(() => {
const all = penunjangPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
})
</script>
<style scoped>
.antrian-display-container {
background: #FFFFFF;
min-height: 100vh;
padding: 24px;
font-family: 'Roboto', sans-serif;
}
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 20px;
padding: 24px 40px;
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(255, 252, 255, 0.2);
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.2);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.hospital-name {
font-size: 42px;
font-weight: 900;
color: #FFFFFF;
margin: 0;
letter-spacing: 1px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.display-subtitle {
font-size: 22px;
font-weight: 500;
color: #FFFFFF;
margin: 0;
opacity: 0.95;
}
.time-large {
font-size: 56px;
font-weight: 800;
color: #FFFFFF;
line-height: 1;
letter-spacing: 2px;
}
.date-small {
font-size: 18px;
color: #FFFFFF;
margin-top: 8px;
font-weight: 400;
opacity: 0.95;
}
.hero-call-section {
background: linear-gradient(135deg, #E01507 0%, #E02B1D 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 24px;
text-align: center;
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
animation: pulse-shadow 2s infinite;
}
.call-label {
font-size: 28px;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 2px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.call-number {
font-size: 110px;
font-weight: 900;
color: #FFFFFF;
line-height: 1;
margin: 16px 0;
letter-spacing: 4px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2);
}
.call-clinic {
font-size: 38px;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 1px;
}
@keyframes pulse-shadow {
0%, 100% {
box-shadow: 0 12px 32px rgba(224, 21, 7, 0.25);
}
50% {
box-shadow: 0 16px 48px rgba(224, 21, 7, 0.4);
}
}
.clinics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.clinic-box {
background: #FFFFFF;
border: 2px solid #F3E5F5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(156, 39, 176, 0.08);
transition: all 0.3s ease;
min-height: 260px;
display: flex;
flex-direction: column;
}
.clinic-box:hover {
box-shadow: 0 8px 24px rgba(156, 39, 176, 0.15);
transform: translateY(-2px);
}
.clinic-header-bar {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.clinic-title {
font-size: 26px;
font-weight: 800;
color: #FFFFFF;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.clinic-count {
background: rgba(255, 255, 255, 0.2) !important;
color: #FFFFFF !important;
font-weight: 700;
font-size: 16px;
backdrop-filter: blur(10px);
}
.clinic-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 24px;
background: #FAFBFC;
}
.current-section {
text-align: center;
margin-bottom: 16px;
}
.current-label {
font-size: 16px;
font-weight: 700;
color: #18B653;
margin-bottom: 8px;
letter-spacing: 1px;
}
.current-number {
font-size: 68px;
font-weight: 900;
color: #212121;
line-height: 1;
letter-spacing: 2px;
}
.next-section {
text-align: center;
}
.next-label {
font-size: 13px;
font-weight: 700;
color: #717171;
margin-bottom: 10px;
letter-spacing: 1px;
text-transform: uppercase;
}
.next-numbers {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.next-item {
color: #FFFFFF;
padding: 8px 18px;
border-radius: 10px;
font-size: 22px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.2);
letter-spacing: 1px;
}
.empty-state {
text-align: center;
padding: 32px 0;
}
.empty-text {
font-size: 18px;
color: #89939E;
margin-top: 12px;
font-weight: 600;
}
.footer-stats-bar {
background: #FFFFFF;
border: 2px solid #F3E5F5;
border-radius: 20px;
padding: 24px 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: 0 4px 16px rgba(156, 39, 176, 0.08);
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
color: #212121;
line-height: 1;
}
.stat-label {
font-size: 15px;
color: #717171;
font-weight: 600;
margin-top: 4px;
}
.stat-divider {
width: 2px;
height: 64px;
background: #F3E5F5;
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: #4D4D4D;
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>
+543
View File
@@ -0,0 +1,543 @@
<!-- pages/AntrianKlinik.vue -->
<template>
<div class="tv-display-container">
<!-- Header -->
<div class="tv-header">
<div class="tv-header-left">
<div class="hospital-logo">
<v-icon size="64" color="white">mdi-hospital-box</v-icon>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN KLINIK</h1>
<p class="display-title">RSUD Dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
<div class="tv-header-right">
<div class="current-time">{{ currentTime }}</div>
<div class="current-date">{{ currentDate }}</div>
</div>
</div>
<!-- Current Called Queue - Super Prominent -->
<div v-if="currentCalledQueue" class="hero-queue">
<div class="hero-label">SEDANG DIPANGGIL</div>
<div class="hero-number">{{ currentCalledQueue.noAntrian.split(' |')[0] }}</div>
<div class="hero-clinic">{{ currentCalledQueue.klinik }}</div>
</div>
<!-- Clinic Grid - 3x3 Layout -->
<div class="clinic-grid">
<div
v-for="clinic in displayedClinics"
:key="clinic.name"
class="clinic-card"
>
<div class="clinic-name">{{ clinic.name }}</div>
<div class="queue-display">
<!-- Show current queue number -->
<div v-if="clinic.currentQueue" class="current-queue-large">
<div class="queue-label">SEKARANG</div>
<div class="queue-number-huge">
{{ clinic.currentQueue.noAntrian.split(' |')[0] }}
</div>
</div>
<!-- Show next queues -->
<div v-if="clinic.nextQueues.length > 0" class="next-queues">
<div class="queue-label-small">SELANJUTNYA</div>
<div class="next-queue-numbers">
<span
v-for="(queue, index) in clinic.nextQueues.slice(0, 3)"
:key="queue.no"
class="next-number"
>
{{ queue.noAntrian.split(' |')[0] }}
</span>
</div>
</div>
<!-- Empty state -->
<div v-if="!clinic.currentQueue && clinic.nextQueues.length === 0" class="empty-queue">
<v-icon size="48" color="grey-lighten-2">mdi-checkbox-blank-circle-outline</v-icon>
<div class="empty-text">Tidak Ada Antrian</div>
</div>
</div>
<!-- Queue count badge -->
<div class="queue-count">
<v-icon size="20" class="mr-1">mdi-account-multiple</v-icon>
{{ clinic.totalQueues }}
</div>
</div>
</div>
<!-- Footer Info -->
<div class="tv-footer">
<div class="footer-stats">
<div class="stat-box">
<div class="stat-value">{{ statistics.total }}</div>
<div class="stat-label">Total Antrian</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ statistics.waiting }}</div>
<div class="stat-label">Menunggu</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">Sedang Dilayani</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" class="mr-2">mdi-information</v-icon>
Harap perhatikan nomor antrian Anda di layar
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useQueueStore } from '../stores/queueStore'
definePageMeta({
layout: false,
});
// Store
const queueStore = useQueueStore()
// Reactive data
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
let refreshInterval = null
// Computed - Get all klinik patients (stage: klinik)
const klinikPatients = computed(() => {
return queueStore.getPatientsByStage('klinik').value.all
})
// Get clinics with their queues organized
const displayedClinics = computed(() => {
const allKliniks = queueStore.kliniks.slice(0, 9) // Max 9 clinics for 3x3 grid
return allKliniks.map(klinik => {
const queues = klinikPatients.value
.filter(p => p.klinik === klinik.name)
.sort((a, b) => {
// Sort by status priority then time
const statusPriority = {
'di-loket': 1,
'waiting': 2,
'terlambat': 3,
'pending': 4
}
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
// Sort by time if same status
const timeA = a.jamPanggil.split(':').map(Number)
const timeB = b.jamPanggil.split(':').map(Number)
return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1])
})
// Get current (first di-loket or first waiting)
const currentQueue = queues.find(q => q.status === 'di-loket') ||
(queues.find(q => q.status === 'waiting') || null)
// Get next queues (excluding current)
const nextQueues = queues.filter(q =>
q.no !== currentQueue?.no &&
(q.status === 'waiting' || q.status === 'di-loket')
)
return {
name: klinik.name,
currentQueue: currentQueue,
nextQueues: nextQueues,
totalQueues: queues.length
}
})
})
// Current queue being called (most recent di-loket)
const currentCalledQueue = computed(() => {
const calledQueues = klinikPatients.value
.filter(p => p.status === 'di-loket')
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return calledQueues[0] || null
})
// Statistics
const statistics = computed(() => {
const all = klinikPatients.value
return {
total: all.length,
waiting: all.filter(p => p.status === 'waiting').length,
active: all.filter(p => p.status === 'di-loket').length
}
})
// Methods
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// Lifecycle
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
// Auto refresh every 5 seconds
refreshInterval = setInterval(() => {
// Data will auto-update due to reactive store
}, 5000)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
if (refreshInterval) clearInterval(refreshInterval)
})
</script>
<style scoped>
.tv-display-container {
background: linear-gradient(135deg, #e7e7e7 0%, #FFDCAF 100%);
min-height: 100vh;
padding: 20px;
font-family: 'Roboto', sans-serif;
color: #fff;
overflow: hidden;
}
/* Header */
.tv-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #FAFAFA;
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 20px 40px;
margin-bottom: 20px;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
}
.tv-header-left {
display: flex;
align-items: center;
gap: 20px;
}
.hospital-logo {
background: #1e3c72;
border-radius: 50%;
padding: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.hospital-name {
font-size: 36px;
margin: 0;
letter-spacing: 2px;
color: #1e3c72;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.display-title {
font-size: 24px;
font-weight: 600;
margin: 0;
color: #1e3c72;
opacity: 0.9;
}
.tv-header-right {
text-align: right;
}
.current-time {
font-size: 48px;
font-weight: 700;
line-height: 1;
color: #1e3c72;
text-shadow: 2px 2px 4px rgba(124, 124, 124, 0.3);
}
.current-date {
font-size: 18px;
opacity: 0.9;
margin-top: 5px;
}
/* Hero Queue - Sedang Dipanggil */
.hero-queue {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
border-radius: 24px;
padding: 30px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
animation: pulse-glow 2s infinite;
}
.hero-label {
font-size: 32px;
font-weight: 700;
letter-spacing: 3px;
margin-bottom: 10px;
text-transform: uppercase;
}
.hero-number {
font-size: 120px;
font-weight: 900;
line-height: 1;
margin: 10px 0;
text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
letter-spacing: 4px;
}
.hero-clinic {
font-size: 40px;
font-weight: 600;
margin-top: 10px;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 12px 48px rgba(255, 107, 107, 0.4);
}
50% {
box-shadow: 0 16px 64px rgba(255, 107, 107, 0.6);
}
}
/* Clinic Grid - 3x3 */
.clinic-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 20px;
}
.clinic-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
position: relative;
min-height: 200px;
display: flex;
flex-direction: column;
}
.clinic-name {
font-size: 28px;
font-weight: 800;
color: #1e3c72;
text-align: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 3px solid #2a5298;
text-transform: uppercase;
letter-spacing: 1px;
}
.queue-display {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* Current Queue Display */
.current-queue-large {
text-align: center;
margin-bottom: 10px;
}
.queue-label {
font-size: 18px;
font-weight: 600;
color: #4caf50;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 1px;
}
.queue-number-huge {
font-size: 72px;
font-weight: 900;
color: #1e3c72;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
/* Next Queues */
.next-queues {
text-align: center;
}
.queue-label-small {
font-size: 14px;
font-weight: 600;
color: #666;
margin-bottom: 5px;
text-transform: uppercase;
}
.next-queue-numbers {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.next-number {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 12px;
font-size: 24px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
/* Empty State */
.empty-queue {
text-align: center;
opacity: 0.5;
}
.empty-text {
font-size: 18px;
color: #999;
margin-top: 10px;
font-weight: 500;
}
/* Queue Count Badge */
.queue-count {
position: absolute;
top: 15px;
right: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Footer */
.tv-footer {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.footer-stats {
display: flex;
gap: 40px;
}
.stat-box {
text-align: center;
}
.stat-value {
font-size: 42px;
font-weight: 900;
line-height: 1;
color:#1e3c72;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.stat-label {
font-size: 16px;
opacity: 0.9;
margin-top: 5px;
color:#1e3c72;
font-weight: 500;
}
.footer-message {
font-size: 22px;
font-weight: 600;
display: flex;
align-items: center;
opacity: 0.95;
color:#1e3c72;
}
/* Responsive for different TV sizes */
@media (max-width: 1920px) {
.hero-number {
font-size: 100px;
}
.queue-number-huge {
font-size: 64px;
}
}
@media (max-width: 1366px) {
.clinic-grid {
gap: 10px;
}
.clinic-card {
padding: 15px;
min-height: 180px;
}
.clinic-name {
font-size: 24px;
}
.queue-number-huge {
font-size: 56px;
}
.hero-number {
font-size: 90px;
}
}
/* Animation for smooth transitions */
.clinic-card {
transition: all 0.3s ease;
}
.queue-number-huge,
.hero-number {
transition: all 0.3s ease;
}
</style>
+698
View File
@@ -0,0 +1,698 @@
<!-- pages/BuatAntrean.vue -->
<template>
<v-container fluid class="buat-antrean-container">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-ticket-account</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">Buat Antrean</h1>
<p class="page-subtitle">{{ currentDate }}</p>
</div>
</div>
<div class="header-right">
<v-chip color="white" variant="flat" class="mr-2">
<v-icon start size="16">mdi-account-multiple</v-icon>
{{ totalPasien }} Pasien
</v-chip>
</div>
</div>
</div>
<!-- Search & Filter -->
<v-card class="filter-card mb-4" elevation="2">
<v-card-text>
<v-row align="center">
<v-col cols="12" md="6">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Pencarian"
placeholder="Cari nama pasien, No. RM, atau alamat..."
variant="outlined"
density="comfortable"
hide-details
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
:items="statusOptions"
label="Status"
variant="outlined"
density="comfortable"
hide-details
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="primary"
variant="outlined"
block
size="large"
@click="refreshData"
:loading="loading"
>
<v-icon start>mdi-refresh</v-icon>
Refresh Data
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Patient Table -->
<v-card elevation="2">
<v-card-title class="d-flex align-center pa-4 bg-grey-lighten-4">
<v-icon class="mr-2">mdi-account-group</v-icon>
<span>Daftar Pasien - {{ filteredPasien.length }} pasien</span>
</v-card-title>
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="filteredPasien"
:search="search"
:items-per-page="10"
class="elevation-0"
>
<template v-slot:item.namaPasien="{ item }">
<div class="patient-info">
<div class="patient-name">{{ item.namaPasien }}</div>
<div class="patient-meta">{{ item.noRM }}</div>
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.buatAntrean="{ item }">
<div class="d-flex ga-2 flex-nowrap">
<v-btn
size="small"
color="#0053AD"
variant="outlined"
@click="openKlinikDialog(item)"
>
<v-icon size="16" start>mdi-hospital-building</v-icon>
Klinik
</v-btn>
<v-btn
size="small"
color="#FF9B1B"
variant="outlined"
@click="openKlinikRuangDialog(item)"
>
<v-icon size="16" start>mdi-door-open</v-icon>
Klinik Ruang
</v-btn>
<v-btn
size="small"
color="#9C27B0"
variant="outlined"
@click="openPenunjangDialog(item)"
>
<v-icon size="16" start>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="600px">
<v-card>
<v-card-title class="dialog-header bg-primary">
<v-icon class="mr-2">mdi-hospital-building</v-icon>
<span>Pilih Klinik</span>
<v-spacer></v-spacer>
<v-btn icon @click="showKlinikDialog = false" size="small">
<v-icon color="white">mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<div v-if="selectedPatient" class="patient-card mb-4">
<h4 class="mb-2">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ selectedPatient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ selectedPatient.noRM }}</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikSearch"
prepend-inner-icon="mdi-magnify"
label="Cari Klinik"
variant="outlined"
density="comfortable"
hide-details
class="mb-4"
/>
<v-row>
<v-col
v-for="klinik in filteredKliniks"
:key="klinik.id"
cols="6"
>
<v-card
class="klinik-option"
@click="buatAntreanKlinik(klinik)"
elevation="1"
hover
>
<v-card-text class="text-center pa-4">
<v-icon size="32" color="primary" class="mb-2">
mdi-hospital-building
</v-icon>
<div class="option-title">{{ klinik.name }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- Dialog Klinik Ruang -->
<v-dialog v-model="showKlinikRuangDialog" max-width="800px">
<v-card>
<v-card-title class="dialog-header" style="background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);">
<v-icon class="mr-2">mdi-door-open</v-icon>
<span>Pilih Klinik Ruang</span>
<v-spacer></v-spacer>
<v-btn icon @click="showKlinikRuangDialog = false" size="small">
<v-icon color="white">mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<div v-if="selectedPatient" class="patient-card mb-4">
<h4 class="mb-2">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ selectedPatient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ selectedPatient.noRM }}</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikRuangSearch"
prepend-inner-icon="mdi-magnify"
label="Cari Klinik Ruang"
variant="outlined"
density="comfortable"
hide-details
class="mb-4"
/>
<div class="ruang-list">
<v-expansion-panels>
<v-expansion-panel
v-for="klinikRuang in filteredKlinikRuang"
:key="klinikRuang.id"
>
<v-expansion-panel-title>
<div class="d-flex align-center">
<v-chip size="small" color="primary" class="mr-2">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<strong>{{ klinikRuang.namaKlinik }}</strong>
<v-spacer></v-spacer>
<v-chip size="small" variant="outlined">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list density="compact">
<v-list-item
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
class="ruang-item"
>
<template v-slot:prepend>
<v-icon color="orange">mdi-door</v-icon>
</template>
<v-list-item-title>
<strong>Ruang {{ ruang.nomorRuang }}</strong> - {{ ruang.namaRuang }}
</v-list-item-title>
<v-list-item-subtitle>
Screen: {{ ruang.nomorScreen }}
</v-list-item-subtitle>
<template v-slot:append>
<v-icon>mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- Dialog Penunjang -->
<v-dialog v-model="showPenunjangDialog" max-width="600px">
<v-card>
<v-card-title class="dialog-header" style="background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);">
<v-icon class="mr-2">mdi-clipboard-pulse</v-icon>
<span>Pilih Penunjang</span>
<v-spacer></v-spacer>
<v-btn icon @click="showPenunjangDialog = false" size="small">
<v-icon color="white">mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<div v-if="selectedPatient" class="patient-card mb-4">
<h4 class="mb-2">Informasi Pasien</h4>
<v-row>
<v-col cols="6">
<div class="info-label">Nama</div>
<div class="info-value">{{ selectedPatient.namaPasien }}</div>
</v-col>
<v-col cols="6">
<div class="info-label">No. RM</div>
<div class="info-value">{{ selectedPatient.noRM }}</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="penunjangSearch"
prepend-inner-icon="mdi-magnify"
label="Cari Penunjang"
variant="outlined"
density="comfortable"
hide-details
class="mb-4"
/>
<v-row>
<v-col
v-for="penunjang in filteredPenunjangs"
:key="penunjang.id"
cols="6"
>
<v-card
class="klinik-option"
@click="buatAntreanPenunjang(penunjang)"
elevation="1"
hover
>
<v-card-text class="text-center pa-4">
<v-icon size="32" color="purple" class="mb-2">
mdi-clipboard-pulse
</v-icon>
<div class="option-title">{{ penunjang.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 @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQueueStore } from '../stores/queueStore'
// Store
const queueStore = useQueueStore()
// State
const loading = ref(false)
const search = ref('')
const statusFilter = ref(null)
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'])
// 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: 'Buat Antrean', value: 'buatAntrean', sortable: false, width: '300px' },
])
// Mock patient 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'
},
])
// Mock klinik ruang data (dari Master Klinik Ruang)
const klinikRuangList = ref([
{
id: 1,
kodeKlinik: 'AN',
namaKlinik: 'ANAK',
ruangList: [
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
]
},
{
id: 2,
kodeKlinik: 'GI',
namaKlinik: 'GIGI DAN MULUT',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
]
},
{
id: 3,
kodeKlinik: 'BD',
namaKlinik: 'BEDAH',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }
]
},
])
// Computed
const totalPasien = computed(() => pasienList.value.length)
const filteredPasien = computed(() => {
let filtered = pasienList.value
if (statusFilter.value) {
filtered = filtered.filter(p => p.status === statusFilter.value)
}
return filtered
})
const filteredKliniks = computed(() => {
if (!klinikSearch.value) return queueStore.kliniks
return queueStore.kliniks.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 queueStore.penunjangs
return queueStore.penunjangs.filter(p =>
p.name.toLowerCase().includes(penunjangSearch.value.toLowerCase())
)
})
// Methods
const showSnackbar = (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] || 'grey'
}
const refreshData = () => {
loading.value = true
setTimeout(() => {
loading.value = false
showSnackbar('Data berhasil diperbarui', 'success')
}, 1000)
}
const openKlinikDialog = (patient) => {
selectedPatient.value = patient
showKlinikDialog.value = true
}
const openKlinikRuangDialog = (patient) => {
selectedPatient.value = patient
showKlinikRuangDialog.value = true
}
const openPenunjangDialog = (patient) => {
selectedPatient.value = patient
showPenunjangDialog.value = true
}
const buatAntreanKlinik = (klinik) => {
const result = queueStore.createAntreanKlinik(klinik, 'loket')
showSnackbar(`Antrean klinik ${klinik.name} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`, 'success')
showKlinikDialog.value = false
selectedPatient.value = null
}
const buatAntreanKlinikRuang = (klinikRuang, ruang) => {
// Create queue for klinik ruang with room info
const klinikInfo = {
name: `${klinikRuang.namaKlinik} - ${ruang.namaRuang}`,
kode: klinikRuang.kodeKlinik,
ruang: ruang
}
const result = queueStore.createAntreanKlinik(klinikInfo, 'loket')
showSnackbar(`Antrean ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`, 'success')
showKlinikRuangDialog.value = false
selectedPatient.value = null
}
const buatAntreanPenunjang = (penunjang) => {
const result = queueStore.createAntreanPenunjang(penunjang, selectedPatient.value, 'loket')
showSnackbar(`Antrean penunjang ${penunjang.name} untuk ${selectedPatient.value.namaPasien} berhasil dibuat`, 'success')
showPenunjangDialog.value = false
selectedPatient.value = null
}
</script>
<style scoped>
.buat-antrean-container {
background: #f5f7fa;
min-height: 100vh;
padding: 20px;
}
.page-header {
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(0, 83, 173, 0.2);
}
.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;
}
.filter-card,
.patient-card {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.patient-info {
padding: 4px 0;
}
.patient-name {
font-weight: 600;
font-size: 14px;
color: #212121;
}
.patient-meta {
font-size: 12px;
color: #717171;
}
.dialog-header {
color: white;
padding: 20px 24px;
font-size: 18px;
font-weight: 600;
}
.patient-card {
background: #f5f7fa;
padding: 16px;
border-radius: 12px;
}
.info-label {
font-size: 12px;
color: #717171;
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 600;
color: #212121;
}
.klinik-option {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.klinik-option:hover {
border-color: #0053AD;
transform: translateY(-2px);
}
.option-title {
font-size: 14px;
font-weight: 600;
color: #212121;
}
.ruang-item {
cursor: pointer;
border-radius: 8px;
margin-bottom: 8px;
}
.ruang-item:hover {
background: #f5f7fa;
}
</style>
+95 -97
View File
@@ -101,14 +101,11 @@
<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="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="#D17A4A" class="text-caption rounded-lg">
<v-btn size="small" value="day">Day</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text class="pa-0">
@@ -159,9 +156,15 @@
<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="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">
Realtime Check-in Velocity
<v-chip size="small" color="success" class="ml-2">
<v-icon start size="small">mdi-circle</v-icon>
Live
</v-chip>
</v-card-title>
<v-card-text class="pa-0">
<Line
ref="realtimeChart"
:data="realtimeLineData"
@@ -217,18 +220,33 @@ import {
LineElement,
} from 'chart.js';
// Ensure this middleware setup is correct for your framework (e.g., Nuxt)
definePageMeta({
middleware:['auth']
})
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)
// --- KEYCLOAK/AUTH INTEGRATION ---
// const { user, isLoading, checkAuth, logout } = useAuth()
// const navigateTo = (path) => { console.log('Navigating to', path); };
// Mock user for demo purposes - replace with your actual auth
const user = ref({ name: 'Admin User', preferred_username: 'admin' });
const isLoading = ref(false);
const checkAuth = async () => user.value;
const navigateTo = (path) => console.log('Navigate to:', path);
// ---------------------------------
// Dashboard data
// --- EXPORT STATE ---
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 state
const currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('day');
@@ -243,74 +261,69 @@ const mockQueueData = ref([
{ date: '2025-10-18', registrants: 435, attendees: 380 },
]);
// --- REFACTORED REALTIME LOGIC ---
const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
// --- REALTIME CHART LOGIC - FIXED ---
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 realtimeLineData = ref({
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Check-ins/min',
backgroundColor: 'rgba(74, 95, 122, 0.15)',
borderColor: '#5FB7FF',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
fill: true,
tension: 0.3,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#4A5F7A',
},
],
});
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Crucial: Disable animation for smooth realtime scrolling
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
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)' }
},
plugins: {
legend: { display: true },
title: { display: false }
},
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 }
}
}
});
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();
const data = realtimeLineData.value.datasets[0].data;
const labels = realtimeLineData.value.labels;
// Remove first data point
data.shift();
labels.shift();
// Add new data point
const newDataPoint = Math.floor(Math.random() * 20) + 40;
const newTimeLabel = dayjs().format('HH:mm:ss');
data.push(newDataPoint);
labels.push(newTimeLabel);
console.log('Realtime chart updated:', newTimeLabel, newDataPoint);
};
// --- END REALTIME CHART LOGIC ---
@@ -318,13 +331,13 @@ 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);
// Set date with Indonesian locale
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
console.log('Current date set:', currentDate.value);
// Start realtime updates every 5 seconds
realtimeInterval = setInterval(updateRealtimeData, 5000);
console.log('Realtime interval started');
} else {
await navigateTo('/LoginPage');
}
@@ -337,25 +350,10 @@ onMounted(async () => {
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
console.log('Realtime interval cleared');
}
});
// 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;
};
-635
View File
@@ -1,635 +0,0 @@
<template>
<div class="pasien-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-icon">
<v-icon size="32" color="white">mdi-account-group</v-icon>
</div>
<div class="header-text">
<h1 class="page-title">List Pasien</h1>
<p class="page-subtitle">Senin, 15 September 2025 - Data Master Pasien</p>
</div>
</div>
<div class="header-right">
<v-chip
color="success"
variant="flat"
class="mr-2"
>
Total {{ pasienData.length }} Pasien
</v-chip>
<v-chip
color="white"
variant="flat"
class="text-primary"
>
Status: Aktif
</v-chip>
</div>
</div>
</div>
<!-- Filter Controls
<v-card class="filter-controls-card mb-4" elevation="2">
<v-card-text class="py-4">
<v-row align="center">
<v-col cols="12" md="8">
<div class="d-flex align-center flex-wrap gap-10">
<span class="text-subtitle-1 font-weight-medium">Filter Cepat:</span>
<v-btn
color="primary"
variant="flat"
size="default"
class="px-4"
@click="filterByStatus('Tunggu Daftar')"
>
<v-icon start size="16">mdi-clock-outline</v-icon>
Tunggu Daftar
</v-btn>
<v-btn
color="success"
variant="flat"
size="default"
class="px-4"
@click="filterByStatus('Selesai')"
>
<v-icon start size="16">mdi-check-circle</v-icon>
Selesai
</v-btn>
<v-btn
color="warning"
variant="flat"
size="default"
class="px-4"
@click="filterByKlinik('JKN')"
>
<v-icon start size="16">mdi-card-account-details</v-icon>
JKN
</v-btn>
<v-btn
color="info"
variant="flat"
size="default"
class="px-4"
@click="resetFilter()"
>
<v-icon start size="16">mdi-refresh</v-icon>
Reset
</v-btn>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="d-flex justify-end gap-2">
<v-btn
color="success"
variant="flat"
@click="handleExportLaporan"
:loading="loading"
>
<v-icon start>mdi-file-excel</v-icon>
Export Laporan
</v-btn>
<v-btn
color="primary"
variant="flat"
@click="handleExportLaporanPerKlinik"
:loading="loading"
>
<v-icon start>mdi-hospital-building</v-icon>
Export Per Klinik
</v-btn>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card> -->
<!-- Main Patients Table -->
<v-card class="main-table-card mb-4" elevation="2">
<v-card-title class="d-flex align-center justify-space-between pa-6">
<div class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-table</v-icon>
<span class="text-h6 font-weight-bold">DATA PASIEN</span>
</div>
<v-chip color="info" variant="flat">
{{ filteredData.length }} dari {{ pasienData.length }} pasien
</v-chip>
</v-card-title>
<v-divider></v-divider>
<TabelListPasien
:items="filteredData"
@search="handleSearch"
@export-laporan="handleExportLaporan"
@export-laporan-per-klinik="handleExportLaporanPerKlinik"
/>
</v-card>
Statistics Cards
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="success" class="mb-2">mdi-account-check</v-icon>
<div class="text-h4 font-weight-bold text-success">{{ getStatsByStatus('Tunggu Daftar') }}</div>
<div class="text-body-2 text-grey-darken-1">Tunggu Daftar</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="info" class="mb-2">mdi-barcode</v-icon>
<div class="text-h4 font-weight-bold text-info">{{ getStatsByStatus('Barcode') }}</div>
<div class="text-body-2 text-grey-darken-1">Barcode</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="warning" class="mb-2">mdi-wifi</v-icon>
<div class="text-h4 font-weight-bold text-warning">{{ getStatsByKeterangan('Online') }}</div>
<div class="text-body-2 text-grey-darken-1">Online</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="stats-card" elevation="2">
<v-card-text class="text-center pa-4">
<v-icon size="40" color="error" class="mb-2">mdi-wifi-off</v-icon>
<div class="text-h4 font-weight-bold text-error">{{ getStatsByKeterangan('Offline') }}</div>
<div class="text-body-2 text-grey-darken-1">Offline</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Loading Overlay -->
<v-overlay v-model="loading" class="align-center justify-center">
<v-progress-circular
color="primary"
indeterminate
size="64"
/>
</v-overlay>
<!-- Snackbar -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top right"
>
{{ snackbar.message }}
<template v-slot:actions>
<v-btn
icon
@click="snackbar.show = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import TabelListPasien from '~/components/TabelListPasien.vue'
// Meta untuk SEO
definePageMeta({
title: 'List Pasien',
layout: 'default'
})
// Reactive states
const loading = ref(false)
const currentFilter = ref('')
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
// Sample data
const pasienData = ref([
{
tglPeriksa: '27/08/2025',
nik: '3507264104730004',
rm: '11412584',
barcode: '250627100001',
noAntrian: 'HQ1001',
klinik: 'HOM',
fullName: 'Binti Almatul',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3504096063630001',
rm: '',
barcode: '250627100002',
noAntrian: 'QB1001',
klinik: 'KANDUNGAN',
fullName: 'maret kumalal',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3507114102250002',
rm: '11555560',
barcode: '250627100003',
noAntrian: 'QB1002',
klinik: 'KANDUNGAN',
fullName: 'ayu rafti lelu amanda',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3508185040150002',
rm: '11333655',
barcode: '250627100004',
noAntrian: 'AN1001',
klinik: 'ANAK',
fullName: 'Erin Wahyuni',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3515085040110004',
rm: '11585554',
barcode: '250627100005',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: 'Yohana Karina Pusplta Sari',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '3506246105750002',
rm: '11527608',
barcode: '250627100006',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: 'Elok Suharsti',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Online',
status: 'Tunggu Daftar'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100007',
noAntrian: 'HQ1002',
klinik: 'HOM',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100008',
noAntrian: 'IP1002',
klinik: 'IPD',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100009',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
},
{
tglPeriksa: '27/08/2025',
nik: '',
rm: '',
barcode: '250627100010',
noAntrian: 'IP1001',
klinik: 'IPD',
fullName: '',
shift: 'Shift 1',
pembayaran: 'JKN',
keterangan: 'Offline',
status: 'Barcode'
}
])
// Computed properties
const filteredData = computed(() => {
if (!currentFilter.value) {
return pasienData.value
}
return pasienData.value.filter(item => {
return item.status === currentFilter.value ||
item.pembayaran === currentFilter.value ||
item.keterangan === currentFilter.value
})
})
// Methods
const showSnackbar = (message, color = 'success') => {
snackbar.value = {
show: true,
message,
color
}
}
const filterByStatus = (status) => {
currentFilter.value = status
showSnackbar(`Filter diterapkan: ${status}`, 'info')
}
const filterByKlinik = (pembayaran) => {
currentFilter.value = pembayaran
showSnackbar(`Filter diterapkan: ${pembayaran}`, 'info')
}
const resetFilter = () => {
currentFilter.value = ''
showSnackbar('Filter direset', 'success')
}
const getStatsByStatus = (status) => {
return pasienData.value.filter(item => item.status === status).length
}
const getStatsByKeterangan = (keterangan) => {
return pasienData.value.filter(item => item.keterangan === keterangan).length
}
const handleSearch = async (filters) => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Search filters:', filters)
showSnackbar('Data berhasil difilter', 'success')
} catch (error) {
console.error('Error searching data:', error)
showSnackbar('Gagal memfilter data', 'error')
} finally {
loading.value = false
}
}
const handleExportLaporan = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 2000))
console.log('Exporting laporan pasien...')
showSnackbar('Laporan pasien berhasil diexport', 'success')
} catch (error) {
console.error('Error exporting laporan:', error)
showSnackbar('Gagal export laporan pasien', 'error')
} finally {
loading.value = false
}
}
const handleExportLaporanPerKlinik = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 2000))
console.log('Exporting laporan pasien per klinik...')
showSnackbar('Laporan pasien per klinik berhasil diexport', 'success')
} catch (error) {
console.error('Error exporting laporan per klinik:', error)
showSnackbar('Gagal export laporan pasien per klinik', 'error')
} finally {
loading.value = false
}
}
const fetchPasienData = async () => {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Patient data loaded successfully')
} catch (error) {
console.error('Error fetching patient data:', error)
showSnackbar('Gagal memuat data pasien', 'error')
} finally {
loading.value = false
}
}
// Lifecycle
onMounted(() => {
fetchPasienData()
})
// Head untuk SEO
useHead({
title: 'List Pasien - Antrean RSSA',
meta: [
{
name: 'description',
content: 'Daftar master data seluruh pasien rumah sakit'
}
]
})
</script>
<style scoped>
.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: 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;
}
/* .filter-controls-card, */
.main-table-card,
.stats-card {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.stats-card {
transition: all 0.2s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
/* Enhanced table styling */
.main-table-card :deep(.v-data-table th) {
background: #fafbfc;
font-weight: 600;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.main-table-card :deep(.v-data-table tbody tr:hover) {
background: #f8fafc !important;
}
.main-table-card :deep(.v-data-table tbody td) {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
/* Button styling */
.v-btn {
text-transform: none !important;
font-weight: 500;
}
.v-btn--size-default {
height: 40px;
}
/* Responsive Design */
@media (max-width: 1024px) {
.header-content {
flex-direction: column;
gap: 20px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.page-title {
font-size: 28px;
}
}
@media (max-width: 768px) {
.pasien-container {
padding: 16px;
}
.header-content {
padding: 24px 20px;
}
.page-title {
font-size: 24px;
}
.header-icon {
padding: 12px;
}
/* .filter-controls-card .d-flex {
flex-direction: column;
align-items: flex-start;
gap: 16px;
} */
.filter-controls-card .v-col:last-child .d-flex {
justify-content: flex-start;
}
}
@media (max-width: 600px) {
.page-title {
font-size: 20px;
}
.filter-buttons-container {
justify-content: center;
}
.filter-btn {
margin-right: 12px;
margin-bottom: 10px;
}
.header-right .v-chip {
font-size: 12px;
}
}
</style>
-491
View File
@@ -1,491 +0,0 @@
<template>
<div class="master-klinik-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 Klinik' : readOnly ? 'Detail Klinik' : 'Tambah Klinik' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kode</v-label>
<v-text-field
v-model="editedItem.kode"
placeholder="Masukkan Kode Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama</v-label>
<v-text-field
v-model="editedItem.namaKlinik"
placeholder="Masukkan Nama Klinik"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Shift</v-label>
<v-text-field
v-model="editedItem.shift"
placeholder="Jumlah Shift"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Kuota Shift</v-label>
<v-text-field
v-model="editedItem.kuotaShift"
placeholder="Kuota Per Shift"
type="number"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
</v-row>
<v-row class="d-flex align-center">
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Jam Buka Shift</v-label>
<div class="d-flex align-center">
<v-text-field
v-model="editedItem.jamBuka"
placeholder="Jam Buka Shift 1"
variant="outlined"
density="comfortable"
class="mr-2"
:readonly="readOnly"
></v-text-field>
<span class="text-h6 font-weight-bold mr-2">:</span>
<v-text-field
v-model="editedItem.menitBuka"
placeholder="Menit Buka Shift 1"
variant="outlined"
density="comfortable"
:readonly="readOnly"
></v-text-field>
</div>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="editedItem.autoShift"
inset
color="primary"
label="Auto Shift"
hide-details
:readonly="readOnly"
></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold">Kuota Bangku</v-label>
<v-text-field
v-model="editedItem.kuotaBangku"
placeholder="Masukkan Kuota Bangku Klinik"
type="number"
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">Jadwal Klinik</v-label>
<v-table class="rounded-lg elevation-1 mt-2">
<thead>
<tr>
<th class="text-left text-uppercase font-weight-bold">#</th>
<th class="text-left text-uppercase font-weight-bold">Hari</th>
<th class="text-left text-uppercase font-weight-bold">Pilih</th>
</tr>
</thead>
<tbody>
<tr v-for="(day, index) in days" :key="index">
<td>{{ index + 1 }}</td>
<td>{{ day.name }}</td>
<td>
<v-checkbox
v-model="day.selected"
color="primary"
hide-details
:readonly="readOnly"
></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
</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" class="mr-2" size="40"></v-icon>
<span>Master Klinik</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>
</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>
</div>
<!-- Data Table dengan paginasi kustom -->
<v-data-table
:headers="headers"
:items="allKlinikData"
: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 }">
<!-- Tombol "View" yang hanya menampilkan detail -->
<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>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// State untuk menampilkan/menyembunyikan formulir
const showForm = ref(false);
const readOnly = ref(false); // State untuk mode "view"
// Data dummy untuk Master Klinik
const allKlinikData = ref([
{ id: 1, kode: 'AN', namaKlinik: 'ANAK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 2, kode: 'AS', namaKlinik: 'ANESTESI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 3, kode: 'BD', namaKlinik: 'BEDAH', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 4, kode: 'GR', namaKlinik: 'GERIATRI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 5, kode: 'GI', namaKlinik: 'GIGI DAN MULUT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 6, kode: 'GZ', namaKlinik: 'GIZI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 7, kode: 'HO', namaKlinik: 'HOM', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 8, kode: 'IP', namaKlinik: 'IPD', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 9, kode: 'JT', namaKlinik: 'JANTUNG', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 10, kode: 'JW', namaKlinik: 'JIWA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 11, kode: 'OB', namaKlinik: 'KANDUNGAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 12, kode: 'KT', namaKlinik: 'KEMOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 13, kode: 'KN', namaKlinik: 'KOMPLEMENTER', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 14, kode: 'KL', namaKlinik: 'KUL KEL', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 15, kode: 'MT', namaKlinik: 'MATA', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 16, kode: 'MC', namaKlinik: 'MCU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 17, kode: 'ON', namaKlinik: 'ONKOLOGI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 18, kode: 'PR', namaKlinik: 'PARU', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 19, kode: 'RT', namaKlinik: 'R. TINDAKAN', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 20, kode: 'RD', namaKlinik: 'RADIOTERAPI', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 21, kode: 'RM', namaKlinik: 'REHAB MEDIK', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 22, kode: 'NU', namaKlinik: 'SARAF', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
{ id: 23, kode: 'TH', namaKlinik: 'THT', shift: 1, kuotaShift: 1000, kuotaBangku: 50, jamBuka: '08', menitBuka: '00', autoShift: true, jadwal: [] },
]);
const headers = ref([
{ title: 'No', key: 'id' },
{ title: 'Kode', key: 'kode', sortable: true },
{ title: 'Nama Klinik', key: 'namaKlinik', sortable: true },
{ title: 'Shift', key: 'shift', sortable: true },
{ title: 'Kuota Shift', key: 'kuotaShift', 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: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
]);
const days = ref([
{ name: 'Senin', selected: false },
{ name: 'Selasa', selected: false },
{ name: 'Rabu', selected: false },
{ name: 'Kamis', selected: false },
{ name: 'Jum`at', selected: false },
]);
// 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(allKlinikData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, allKlinikData.value.length);
const total = allKlinikData.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,
kode: '',
namaKlinik: '',
shift: 1,
kuotaShift: 0,
kuotaBangku: 0,
jamBuka: '',
menitBuka: '',
autoShift: false,
});
// Fungsi untuk tombol aksi
// Fungsi untuk tombol View yang hanya menampilkan data tanpa bisa diedit
const viewItem = (item) => {
editedItem.value = { ...item };
readOnly.value = true;
isEditMode.value = false;
showForm.value = true;
// Update breadcrumbs untuk mode view
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
{ title: 'Detail Klinik', disabled: true, href: '/setting/viewklinik' },
];
};
const editItem = (item) => {
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
isEditMode.value = true;
readOnly.value = false;
showForm.value = true;
// Update breadcrumbs untuk mode edit
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
{ title: 'Edit Klinik', disabled: true, href: '/setting/editklinik' },
];
};
const deleteItem = (item) => {
editedIndex.value = allKlinikData.value.findIndex(d => d.id === item.id);
showDeleteDialog.value = true;
};
const confirmDelete = () => {
if (editedIndex.value > -1) {
allKlinikData.value.splice(editedIndex.value, 1);
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
kode: '',
namaKlinik: '',
shift: 1,
kuotaShift: 0,
kuotaBangku: 0,
jamBuka: '',
menitBuka: '',
autoShift: false,
};
editedIndex.value = -1;
// Reset breadcrumbs ke mode tabel
breadcrumbs.value = [
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
{ title: 'Setting', disabled: false, href: '/setting' },
{ title: 'Master Klinik', disabled: false, href: '/setting/masterklinik' },
];
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allKlinikData.value[editedIndex.value], editedItem.value);
} else {
// Generate new ID
const newId = allKlinikData.value.length > 0 ? Math.max(...allKlinikData.value.map(item => item.id)) + 1 : 1;
editedItem.value.id = newId;
allKlinikData.value.push(editedItem.value);
}
cancelForm(); // Kembali ke tampilan tabel setelah menyimpan
};
</script>
<style scoped>
.master-klinik-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>
-376
View File
@@ -1,376 +0,0 @@
<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>
</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>
</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>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
middleware:['auth']
})
// 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 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 },
]);
// 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,
namaKlinik: '',
kode: '',
namaRuang: '',
});
// 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);
}
closeDeleteDialog();
};
const cancelForm = () => {
showForm.value = false;
isEditMode.value = false;
readOnly.value = false;
editedItem.value = {
id: 0,
namaKlinik: '',
kode: '',
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' },
];
};
const closeDeleteDialog = () => {
showDeleteDialog.value = false;
editedIndex.value = -1;
};
const saveItem = () => {
if (isEditMode.value) {
Object.assign(allRuangData.value[editedIndex.value], editedItem.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);
}
cancelForm();
};
</script>
<style scoped>
.master-klinik-ruang-page {
font-family: 'Roboto', sans-serif;
background-color: #f5f7fa;
min-height: 100vh;
}
.custom-table {
border: none;
}
.v-data-table :deep(th) {
font-weight: bold !important;
color: #333 !important;
}
.v-data-table :deep(td) {
vertical-align: middle;
}
.v-btn--icon {
border-radius: 8px;
}
.v-select :deep(.v-field__input) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>
-156
View File
@@ -1,156 +0,0 @@
<template>
<v-container>
<v-card>
<!-- <v-card-title class="d-flex justify-space-between align-center">
<span>Master Loket</span>
<v-btn color=#ff9248 @click="tambahLoket" style="color:white;">Tambah Baru</v-btn>
</v-card-title> -->
<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">Master Loket</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Pelayanan</p>
</div>
</div>
</div>
</div>
<TabelData
:headers="loketHeaders"
:items="loketData"
title="Master Loket"
>
<template #actions="{ item }">
<v-btn
small
color="#ff9248"
@click="editLoket(item)"
class="mr-2"
style="color: white"
>Edit</v-btn
>
<v-btn small color="grey-lighten-4" @click="deleteLoket(item)"
>Delete</v-btn
>
</template>
</TabelData>
</v-card>
</v-container>
</template>
<script setup>
import { ref } from "vue";
import TabelData from "../../components/TabelData.vue";
import { useRouter } from "vue-router";
const router = useRouter();
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 }, // 'value' harus 'actions'
]);
// 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 editLoket = (item) => {
router.push({ path: `/Setting/Edit-Loket/${item.id}` });
};
const deleteLoket = (item) => {
const index = loketData.value.findIndex((loket) => loket.id === item.id);
if (index !== -1) {
loketData.value.splice(index, 1);
}
};
const tambahLoket = () => {
router.push({ path: "/Setting/TambahLoket" });
};
</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;
}
</style>
+6 -6
View File
@@ -46,9 +46,9 @@
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
color="#1976d2"
color="#"
@click="openEditDialog(item)"
class="mr-2"
class="mr-2 text-white"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
@@ -56,9 +56,9 @@
</v-btn>
<v-btn
size="small"
color="#f44336"
color="#E51A08"
@click="deleteKlinik(item)"
variant="flat"
variant="outlined"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
@@ -501,7 +501,7 @@ const deleteKlinik = (item) => {
<style scoped>
.page-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.2);
}
@@ -550,7 +550,7 @@ const deleteKlinik = (item) => {
}
.dialog-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
color: white;
padding: 20px 24px;
display: flex;
+125 -126
View File
@@ -1,3 +1,4 @@
<!-- pages/MasterKlinikRuang.vue -->
<template>
<v-container>
<v-card>
@@ -10,18 +11,30 @@
</div>
<div class="header-text">
<h1 class="page-title">Master Klinik Ruang</h1>
<p class="page-subtitle">Rabu, 13 Agustus 2025 - Manajemen Ruangan</p>
<p class="page-subtitle">
{{ currentDate }} - Manajemen Ruangan
</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 Ruang
</v-btn>
<div class="header-stats">
<v-chip color="white" variant="flat" class="mr-2">
<v-icon start size="16">mdi-hospital-building</v-icon>
{{ totalKlinikRuang }} Klinik
</v-chip>
<v-chip color="white" variant="flat" class="mr-2">
<v-icon start size="16">mdi-door</v-icon>
{{ 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>
@@ -29,7 +42,7 @@
<v-card-text>
<v-data-table
:headers="headers"
:items="ruangData"
:items="klinikRuangList"
:items-per-page="10"
class="elevation-0"
>
@@ -43,9 +56,9 @@
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
color="#1976d2"
color="#FF9B1B"
@click="openEditDialog(item)"
class="mr-2"
class="mr-2 text-white"
variant="flat"
>
<v-icon size="16" left>mdi-pencil</v-icon>
@@ -53,9 +66,9 @@
</v-btn>
<v-btn
size="small"
color="#f44336"
color="#E51A08"
@click="deleteRuang(item)"
variant="flat"
variant="outlined"
>
<v-icon size="16" left>mdi-delete</v-icon>
Delete
@@ -92,7 +105,7 @@
<v-autocomplete
label="Kode Klinik"
v-model="formData.kodeKlinik"
:items="klinikList"
:items="masterKlinikList"
item-title="kode"
item-value="kode"
variant="outlined"
@@ -196,7 +209,7 @@
placeholder="Screen Klinik Ruang"
prepend-inner-icon="mdi-monitor"
></v-text-field>
<small class="text-grey">Masukan Nomor Screen Ruang</small>
<!-- <small class="text-grey">Masukan Nomor Screen Ruang</small> -->
</v-col>
<v-col cols="12" md="1" class="d-flex justify-center">
@@ -245,6 +258,7 @@
color="warning"
@click="submitForm"
class="action-btn"
:loading="loading"
>
<v-icon left size="18">mdi-content-save</v-icon>
Submit
@@ -252,16 +266,54 @@
</v-card-actions>
</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 @click="snackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useKlinikRuangStore } from '../stores/klinikRuangStore';
// Store
const klinikRuangStore = useKlinikRuangStore();
// Reactive data
const dialog = ref(false);
const isEdit = ref(false);
const formRef = ref(null);
const loading = ref(false);
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'
}));
// Computed from store
const masterKlinikList = computed(() => klinikRuangStore.masterKlinikList);
const klinikRuangList = computed(() => klinikRuangStore.klinikRuangList);
const totalKlinikRuang = computed(() => klinikRuangStore.totalKlinikRuang);
const totalRuangan = computed(() => klinikRuangStore.totalRuangan);
// Headers
const headers = ref([
{ title: 'No', value: 'no', sortable: true },
{ title: 'Nama Klinik', value: 'namaKlinik', sortable: true },
@@ -270,81 +322,7 @@ const headers = ref([
{ title: 'Aksi', value: 'aksi', sortable: false },
]);
// List klinik dari Master Klinik
const klinikList = 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' },
]);
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: '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' }
]
},
]);
// Form data
const formData = ref({
id: null,
kodeKlinik: '',
@@ -354,8 +332,15 @@ const formData = ref({
],
});
// Methods
const showSnackbar = (text, color = 'success') => {
snackbarText.value = text;
snackbarColor.value = color;
snackbar.value = true;
};
const onKlinikChange = (kode) => {
const selectedKlinik = klinikList.value.find(k => k.kode === kode);
const selectedKlinik = masterKlinikList.value.find(k => k.kode === kode);
if (selectedKlinik) {
formData.value.namaKlinik = selectedKlinik.nama;
}
@@ -415,51 +400,46 @@ const submitForm = async () => {
const { valid } = await formRef.value.validate();
if (!valid) {
showSnackbar('Mohon lengkapi form dengan benar', 'error');
return;
}
if (formData.value.ruangList.length === 0) {
alert('Tambahkan minimal 1 ruangan');
showSnackbar('Tambahkan minimal 1 ruangan', 'warning');
return;
}
// Generate nama ruang untuk display di table
const namaRuangDisplay = formData.value.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
loading.value = true;
if (isEdit.value) {
const index = ruangData.value.findIndex(r => r.id === formData.value.id);
if (index !== -1) {
ruangData.value[index] = {
...ruangData.value[index],
...formData.value,
namaRuang: namaRuangDisplay,
};
try {
let result;
if (isEdit.value) {
result = klinikRuangStore.updateKlinikRuang(formData.value.id, formData.value);
} else {
result = klinikRuangStore.createKlinikRuang(formData.value);
}
} else {
const newId = Math.max(...ruangData.value.map(r => r.id)) + 1;
const newNo = ruangData.value.length + 1;
ruangData.value.push({
id: newId,
no: newNo,
...formData.value,
namaRuang: namaRuangDisplay,
});
}
closeDialog();
if (result.success) {
showSnackbar(result.message, 'success');
closeDialog();
} else {
showSnackbar(result.message, 'error');
}
} catch (error) {
showSnackbar('Terjadi kesalahan', 'error');
} finally {
loading.value = false;
}
};
const deleteRuang = (item) => {
if (confirm(`Hapus ruangan ${item.namaRuang} dari klinik ${item.namaKlinik}?`)) {
const index = ruangData.value.findIndex(r => r.id === item.id);
if (index !== -1) {
ruangData.value.splice(index, 1);
ruangData.value.forEach((r, idx) => {
r.no = idx + 1;
});
const result = klinikRuangStore.deleteKlinikRuang(item.id);
if (result.success) {
showSnackbar(result.message, 'success');
} else {
showSnackbar(result.message, 'error');
}
}
};
@@ -467,7 +447,8 @@ const deleteRuang = (item) => {
<style scoped>
.page-header {
background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.2);
}
@@ -506,6 +487,12 @@ const deleteRuang = (item) => {
font-size: 16px;
}
.header-stats {
display: flex;
align-items: center;
gap: 8px;
}
.add-btn {
font-weight: 600;
text-transform: none;
@@ -594,4 +581,16 @@ const deleteRuang = (item) => {
font-weight: 600;
font-size: 14px;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.header-stats {
width: 100%;
flex-wrap: wrap;
}
}
</style>
+361 -42
View File
@@ -1,6 +1,7 @@
<template>
<v-container>
<v-card>
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
@@ -14,7 +15,7 @@
</div>
<v-btn
color="white"
@click="tambahLoket"
@click="openTambahDialog"
elevation="0"
class="add-btn"
>
@@ -23,40 +24,220 @@
</v-btn>
</div>
</div>
<TabelData
:headers="loketHeaders"
:items="loketData"
title="Master Loket"
>
<template #actions="{ item }">
<v-btn
small
color="#ff9248"
@click="editLoket(item)"
class="mr-2"
style="color: white"
>
Edit
</v-btn>
<v-btn
small
color="grey-lighten-4"
@click="deleteLoket(item)"
>
Delete
</v-btn>
</template>
</TabelData>
<!-- Table -->
<v-card-text>
<v-data-table
:headers="loketHeaders"
:items="loketData"
:items-per-page="10"
class="elevation-0"
>
<template v-slot:item.pelayanan="{ item }">
<v-chip
v-for="(service, idx) in item.pelayanan.slice(0, 2)"
:key="idx"
size="small"
class="mr-1 mb-1"
color="primary"
variant="outlined"
>
{{ service }}
</v-chip>
<v-chip
v-if="item.pelayanan.length > 2"
size="small"
color="grey"
variant="flat"
>
+{{ item.pelayanan.length - 2 }}
</v-chip>
</template>
<template v-slot:item.aksi="{ item }">
<v-btn
size="small"
color="#FF9B1B"
@click="openEditDialog(item)"
variant="flat"
class="mr-2 text-white"
>
<v-icon size="16" left>mdi-pencil</v-icon>
Edit
</v-btn>
<v-btn
size="small"
color="#E51A08"
variant="outlined"
@click="deleteLoket(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>
<v-card>
<v-card-title class="dialog-header">
<span class="dialog-title">{{ isEdit ? 'Edit Loket' : 'Tambah Loket Baru' }}</span>
<v-btn icon variant="text" @click="closeDialog" size="small">
<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 Loket -->
<div class="field-group">
<div class="group-label">Informasi Loket</div>
<v-text-field
label="Nama Loket"
v-model="formData.namaLoket"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Nama loket harus diisi']"
hide-details="auto"
class="mb-3"
></v-text-field>
<v-text-field
label="Kuota Bangku"
v-model="formData.kuota"
type="number"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Kuota harus diisi', v => v > 0 || 'Kuota minimal 1']"
hide-details="auto"
class="mb-3"
></v-text-field>
</div>
<v-divider class="my-4"></v-divider>
<!-- Konfigurasi -->
<div class="field-group">
<div class="group-label">Konfigurasi</div>
<v-select
label="Status Pelayanan"
:items="['RAWAT JALAN', 'RAWAT INAP']"
v-model="formData.statusPelayanan"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Status pelayanan harus dipilih']"
hide-details="auto"
class="mb-3"
></v-select>
<v-row dense>
<v-col cols="6">
<v-select
label="Pembayaran"
:items="['JKN', 'UMUM', 'SPM', 'JKMM', 'JAMPERSAL', 'T4', 'KARYAWAN']"
v-model="formData.pembayaran"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Pembayaran harus dipilih']"
hide-details="auto"
></v-select>
</v-col>
<v-col cols="6">
<v-select
label="Keterangan"
:items="['ONLINE', 'OFFLINE']"
v-model="formData.keterangan"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Keterangan harus dipilih']"
hide-details="auto"
></v-select>
</v-col>
</v-row>
</div>
<v-divider class="my-4"></v-divider>
<!-- Pelayanan -->
<div class="field-group">
<div class="group-label">Pelayanan</div>
<v-select
label="Pilih Pelayanan"
:items="availableServices"
v-model="formData.pelayanan"
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"
>
<template v-slot:chip="{ props, item }">
<v-chip
v-bind="props"
closable
size="small"
color="primary"
>
{{ 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" color="grey-lighten-2">{{ item.raw.id }}</v-chip>
</template>
<v-list-item-title>{{ item.raw.nama }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
</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="action-btn"
>
<v-icon left size="18">mdi-close</v-icon>
Batal
</v-btn>
<v-btn
color="#FF9B1B"
variant="flat"
@click="submitForm"
class="action-btn"
>
<v-icon left size="18">mdi-content-save</v-icon>
Simpan
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
import { ref } from "vue";
import TabelData from "../../components/TabelData.vue";
import { useRouter } from "vue-router";
import { ref } from 'vue';
const router = useRouter();
const dialog = ref(false);
const isEdit = ref(false);
const formRef = ref(null);
const loketHeaders = ref([
{ title: "No", value: "no" },
@@ -107,28 +288,124 @@ const loketData = ref([
},
]);
const editLoket = (item) => {
router.push({ path: `/Setting/Edit-Loket/${item.id}` });
const availableServices = ref([
{ id: 'AN', nama: 'ANAK' },
{ id: 'AS', nama: 'ANESTESI' },
{ id: 'BD', nama: 'BEDAH' },
{ id: 'GR', nama: 'GERIATRI' },
{ id: 'GI', nama: 'GIGI DAN MULUT' },
{ id: 'GZ', nama: 'GIZI' },
{ id: 'HO', nama: 'HOM' },
{ id: 'IP', nama: 'IPD' },
{ id: 'JT', nama: 'JANTUNG' },
{ id: 'JW', nama: 'JIWA' },
{ id: 'KK', nama: 'KULIT KELAMIN' },
{ id: 'PR', nama: 'PARU' },
{ id: 'RT', nama: 'RADIOTERAPI' },
{ id: 'RM', nama: 'REHAB MEDIK' },
{ id: 'SR', nama: 'SARAF' },
{ id: 'TD', nama: 'TINDAKAN' },
]);
const formData = ref({
id: null,
namaLoket: '',
kuota: null,
statusPelayanan: '',
pembayaran: '',
keterangan: '',
pelayanan: [],
});
const openTambahDialog = () => {
isEdit.value = false;
resetForm();
dialog.value = true;
};
const deleteLoket = (item) => {
const index = loketData.value.findIndex((loket) => loket.id === item.id);
if (index !== -1) {
loketData.value.splice(index, 1);
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 tambahLoket = () => {
router.push({ path: "/Setting/TambahLoket" });
const submitForm = async () => {
const { valid } = await formRef.value.validate();
if (!valid) {
return;
}
if (isEdit.value) {
// Update existing
const index = loketData.value.findIndex(l => l.id === formData.value.id);
if (index !== -1) {
loketData.value[index] = {
...loketData.value[index],
...formData.value,
};
}
} else {
// Add new
const newId = Math.max(...loketData.value.map(l => l.id)) + 1;
const newNo = loketData.value.length + 1;
loketData.value.push({
id: newId,
no: newNo,
...formData.value,
});
}
closeDialog();
};
const deleteLoket = (item) => {
if (confirm(`Hapus loket ${item.namaLoket}?`)) {
const index = loketData.value.findIndex(l => l.id === item.id);
if (index !== -1) {
loketData.value.splice(index, 1);
// Reorder no
loketData.value.forEach((l, idx) => {
l.no = idx + 1;
});
}
}
};
</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);
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
border-radius: 16px 16px 0 0;
box-shadow: 0 4px 16px rgba(245, 124, 0, 0.2);
}
.header-content {
@@ -171,6 +448,48 @@ const tambahLoket = () => {
letter-spacing: 0.5px;
padding: 0 24px !important;
height: 44px !important;
color: #1976d2 !important;
color: #f57c00 !important;
}
.dialog-header {
background: linear-gradient(135deg, #f57c00 0%, #e65100 100%);
color: white;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-title {
font-size: 20px;
font-weight: 600;
}
.dialog-content {
padding: 24px !important;
}
.dialog-actions {
padding: 16px 24px;
background: #fafafa;
}
.action-btn {
text-transform: none;
font-weight: 600;
min-width: 100px;
}
.field-group {
margin-bottom: 0;
}
.group-label {
font-size: 14px;
font-weight: 600;
color: #f57c00;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
+8 -6
View File
@@ -89,12 +89,14 @@
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
color="#ff9248"
@click="editScreen(item)"
style="color: white"
></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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+283
View File
@@ -0,0 +1,283 @@
// stores/klinikRuangStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useKlinikRuangStore = defineStore('klinikRuang', () => {
// State - List Master Klinik
const masterKlinikList = ref([
{ kode: 'AN', nama: 'ANAK' },
{ kode: 'AS', nama: 'ANESTESI' },
{ kode: 'BD', nama: 'BEDAH' },
{ kode: 'GR', nama: 'GERIATRI' },
{ kode: 'GI', nama: 'GIGI DAN MULUT' },
{ kode: 'GZ', nama: 'GIZI' },
{ kode: 'HO', nama: 'HOM' },
{ kode: 'IP', nama: 'IPD' },
{ kode: 'JT', nama: 'JANTUNG' },
{ kode: 'JW', nama: 'JIWA' },
{ kode: 'KK', nama: 'KULIT KELAMIN' },
{ kode: 'MT', nama: 'MATA' },
{ kode: 'OB', nama: 'OBGYN' },
{ kode: 'PR', nama: 'PARU' },
{ kode: 'RT', nama: 'RADIOTERAPI' },
]);
// State - Klinik Ruang Data
const klinikRuangList = ref([
{
id: 1,
no: 1,
kodeKlinik: 'AN',
namaKlinik: 'ANAK',
namaRuang: 'R. TINDAKAN',
ruangList: [
{ nomorRuang: '1', namaRuang: 'R. TINDAKAN', nomorScreen: '101' }
]
},
{
id: 2,
no: 2,
kodeKlinik: 'AS',
namaKlinik: 'ANESTESI',
namaRuang: 'Ruang 1, Ruang 2',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '201' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '202' }
]
},
{
id: 3,
no: 3,
kodeKlinik: 'BD',
namaKlinik: 'BEDAH',
namaRuang: 'Ruang Konsultasi',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Konsultasi', nomorScreen: '301' }
]
},
{
id: 4,
no: 4,
kodeKlinik: 'GR',
namaKlinik: 'GERIATRI',
namaRuang: 'Ruang Pemeriksaan',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang Pemeriksaan', nomorScreen: '401' }
]
},
{
id: 5,
no: 5,
kodeKlinik: 'GI',
namaKlinik: 'GIGI DAN MULUT',
namaRuang: 'Ruang 1, Ruang 2, Ruang 3',
ruangList: [
{ nomorRuang: '1', namaRuang: 'Ruang 1', nomorScreen: '501' },
{ nomorRuang: '2', namaRuang: 'Ruang 2', nomorScreen: '502' },
{ nomorRuang: '3', namaRuang: 'Ruang 3', nomorScreen: '503' }
]
},
]);
// Computed
const totalKlinikRuang = computed(() => klinikRuangList.value.length);
const totalRuangan = computed(() => {
return klinikRuangList.value.reduce((total, klinik) => {
return total + klinik.ruangList.length;
}, 0);
});
// Get klinik by code
const getKlinikByCode = (kode) => {
return klinikRuangList.value.find(k => k.kodeKlinik === kode);
};
// Get all ruang for a specific klinik
const getRuangByKlinik = (kodeKlinik) => {
const klinik = klinikRuangList.value.find(k => k.kodeKlinik === kodeKlinik);
return klinik ? klinik.ruangList : [];
};
// Actions - CRUD Operations
// Create new Klinik Ruang
const createKlinikRuang = (data) => {
const newId = Math.max(...klinikRuangList.value.map(r => r.id), 0) + 1;
const newNo = klinikRuangList.value.length + 1;
// Generate nama ruang untuk display
const namaRuangDisplay = data.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
const newKlinikRuang = {
id: newId,
no: newNo,
kodeKlinik: data.kodeKlinik,
namaKlinik: data.namaKlinik,
namaRuang: namaRuangDisplay,
ruangList: data.ruangList
};
klinikRuangList.value.push(newKlinikRuang);
return {
success: true,
message: `Klinik Ruang ${data.namaKlinik} berhasil ditambahkan`,
data: newKlinikRuang
};
};
// Update existing Klinik Ruang
const updateKlinikRuang = (id, data) => {
const index = klinikRuangList.value.findIndex(r => r.id === id);
if (index === -1) {
return {
success: false,
message: 'Klinik Ruang tidak ditemukan'
};
}
// Generate nama ruang untuk display
const namaRuangDisplay = data.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
klinikRuangList.value[index] = {
...klinikRuangList.value[index],
kodeKlinik: data.kodeKlinik,
namaKlinik: data.namaKlinik,
namaRuang: namaRuangDisplay,
ruangList: data.ruangList
};
return {
success: true,
message: `Klinik Ruang ${data.namaKlinik} berhasil diupdate`,
data: klinikRuangList.value[index]
};
};
// Delete Klinik Ruang
const deleteKlinikRuang = (id) => {
const index = klinikRuangList.value.findIndex(r => r.id === id);
if (index === -1) {
return {
success: false,
message: 'Klinik Ruang tidak ditemukan'
};
}
const deletedKlinik = klinikRuangList.value[index];
klinikRuangList.value.splice(index, 1);
// Reorder numbers
klinikRuangList.value.forEach((r, idx) => {
r.no = idx + 1;
});
return {
success: true,
message: `Klinik Ruang ${deletedKlinik.namaKlinik} berhasil dihapus`
};
};
// Add ruang to existing klinik
const addRuangToKlinik = (klinikId, ruangData) => {
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
if (!klinik) {
return {
success: false,
message: 'Klinik tidak ditemukan'
};
}
klinik.ruangList.push(ruangData);
// Update display name
klinik.namaRuang = klinik.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
return {
success: true,
message: `Ruang ${ruangData.namaRuang} berhasil ditambahkan`,
data: klinik
};
};
// Remove ruang from klinik
const removeRuangFromKlinik = (klinikId, ruangIndex) => {
const klinik = klinikRuangList.value.find(k => k.id === klinikId);
if (!klinik) {
return {
success: false,
message: 'Klinik tidak ditemukan'
};
}
if (klinik.ruangList.length <= 1) {
return {
success: false,
message: 'Minimal harus ada 1 ruangan'
};
}
const removedRuang = klinik.ruangList[ruangIndex];
klinik.ruangList.splice(ruangIndex, 1);
// Update display name
klinik.namaRuang = klinik.ruangList
.map(r => r.namaRuang)
.filter(n => n)
.join(', ');
return {
success: true,
message: `Ruang ${removedRuang.namaRuang} berhasil dihapus`,
data: klinik
};
};
// Search klinik ruang
const searchKlinikRuang = (searchTerm) => {
if (!searchTerm) return klinikRuangList.value;
const term = searchTerm.toLowerCase();
return klinikRuangList.value.filter(k =>
k.kodeKlinik.toLowerCase().includes(term) ||
k.namaKlinik.toLowerCase().includes(term) ||
k.namaRuang.toLowerCase().includes(term)
);
};
return {
// State
masterKlinikList,
klinikRuangList,
// Computed
totalKlinikRuang,
totalRuangan,
// Getters
getKlinikByCode,
getRuangByKlinik,
// Actions
createKlinikRuang,
updateKlinikRuang,
deleteKlinikRuang,
addRuangToKlinik,
removeRuangFromKlinik,
searchKlinikRuang,
};
});
+11 -8
View File
@@ -35,21 +35,24 @@ const defaultNavItems: NavItem[] = [
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: 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: 12,
id: 14,
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" },
{ 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: "Screen", path: "/setting/screen", icon: "mdi-circle-small" },
],
},
];