fix : calender view jadwal dokter

This commit is contained in:
2026-08-03 07:58:47 +07:00
parent b0b5200802
commit 8745b22f07
8 changed files with 1767 additions and 21 deletions
@@ -0,0 +1,114 @@
<script setup>
/**
* DialogDetailEvent — Dialog untuk menampilkan detail jadwal dokter
* yang diklik di kalender. Mendukung tampilan Rutin dan Adhoc.
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Data event yang sedang ditampilkan */
event: { type: Object, default: null },
});
const emit = defineEmits(['update:modelValue', 'delete']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/**
* Emit event delete ke parent untuk menghapus jadwal.
*/
const handleDelete = () => {
emit('delete', props.event?.id);
dialogOpen.value = false;
};
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="420px">
<v-card rounded="lg" class="pa-0">
<v-card-title class="pa-5" :class="event?.tipe === 'Adhoc' ? 'bg-secondary' : 'bg-primary'"
style="color:white;">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>{{ event?.tipe === 'Adhoc' ? 'mdi-calendar-clock' : 'mdi-calendar-sync'
}}</v-icon>
<span class="ml-2 text-h6 font-weight-bold">Jadwal {{ event?.tipe }}</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" style="color:white;"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6" v-if="event">
<v-list density="compact" class="pa-0">
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-hospital-building</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Poli / Klinik</div>
<div class="text-body-2 font-weight-bold">{{ event.poli }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-doctor</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Dokter</div>
<div class="text-body-2 font-weight-bold">{{ event.dokter }}</div>
</v-list-item>
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Tanggal</div>
<div class="text-body-2 font-weight-bold">{{ event.tanggal }}</div>
</v-list-item>
<!-- Rutin: tampilkan hari -->
<template v-if="event.tipe === 'Rutin' && event.hari">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="primary" size="small" class="mr-3">mdi-calendar-week</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Hari Praktek</div>
<div class="text-body-2 font-weight-bold">{{ event.hari }}</div>
</v-list-item>
</template>
<!-- Adhoc: tampilkan shift -->
<template v-if="event.tipe === 'Adhoc' && event.shift">
<v-divider class="my-2" />
<v-list-item class="px-0">
<template #prepend>
<v-icon color="secondary" size="small" class="mr-3">mdi-clock-outline</v-icon>
</template>
<div class="text-caption text-grey-darken-1">Waktu</div>
<div class="text-body-2 font-weight-bold">{{ event.shift }}</div>
</v-list-item>
</template>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-space-between">
<v-btn color="error" variant="text" prepend-icon="mdi-trash-can-outline" @click="handleDelete">
Hapus
</v-btn>
<v-btn color="primary" variant="flat" @click="dialogOpen = false">Tutup</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,336 @@
<script setup>
/**
* DialogTambahJadwal — Dialog form untuk menambahkan jadwal dokter baru.
* Mendukung dua tipe jadwal: Rutin (mingguan berulang) dan Adhoc (satu kali).
* Menggunakan v-model pattern untuk kontrol buka/tutup dialog dari parent.
*/
const DAYS_OF_WEEK = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const props = defineProps({
/** Kontrol buka/tutup dialog (v-model) */
modelValue: { type: Boolean, default: false },
/** Daftar klinik untuk referensi nama poli */
clinicList: { type: Array, default: () => [] },
/** ID poli yang sedang dipilih */
selectedPoli: { type: [String, Number, null], default: null },
/** Nama dokter yang sedang dipilih */
selectedDoctor: { type: [String, null], default: null },
/** Tanggal yang diklik di kalender (opsional, sebagai default adhoc) */
clickedDate: { type: [Date, null], default: null },
/** Mode tampilan saat ini ('list' atau 'calendar') */
viewMode: { type: String, default: 'list' },
/** Daftar jadwal saat ini (untuk preview kalender) */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue', 'save']);
/** Computed v-model proxy untuk dialog visibility */
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
/** State form tambah jadwal */
const form = reactive({
poliId: null,
dokter: null,
tipe: 'Rutin',
hari: '',
shifts: [{ jamMulai: '', jamSelesai: '' }],
adhocTanggal: '',
adhocShifts: [{ jamMulai: '', jamSelesai: '' }],
});
/** Computed daftar dokter berdasarkan poli yang dipilih */
const availableDoctors = computed(() => {
if (!form.poliId) return [];
const clinic = props.clinicList.find(c => c.id === form.poliId);
return clinic?.doctors || [];
});
/** Error validasi form */
const formErrors = ref({});
/**
* Format tanggal ke string YYYY-MM-DD.
* @param {Date} date
* @returns {string}
*/
const toDateStr = (date) => {
const d = new Date(date);
const tzOffset = d.getTimezoneOffset() * 60000;
return new Date(d.getTime() - tzOffset).toISOString().slice(0, 10);
};
/** Reset form saat dialog dibuka */
watch(() => props.modelValue, (open) => {
if (open) {
if (props.viewMode === 'calendar') {
form.poliId = props.selectedPoli || null;
form.dokter = props.selectedDoctor || null;
} else {
form.poliId = null;
form.dokter = null;
}
form.tipe = 'Rutin';
form.hari = '';
form.shifts = [{ jamMulai: '', jamSelesai: '' }];
formErrors.value = {};
if (props.clickedDate) {
const dateStr = toDateStr(props.clickedDate);
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
} else {
const dateStr = toDateStr(new Date());
form.adhocTanggal = dateStr;
form.adhocShifts = [{ jamMulai: '08:00', jamSelesai: '12:00' }];
}
}
});
/**
* Validasi form sebelum simpan.
* @returns {boolean} true jika valid
*/
const validateForm = () => {
const errors = {};
if (!form.poliId) errors.poli = 'Pilih poli terlebih dahulu';
if (!form.dokter) errors.dokter = 'Pilih dokter terlebih dahulu';
if (form.tipe === 'Rutin') {
if (!form.hari) errors.hari = 'Pilih hari kerja';
form.shifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`shift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`shift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`shift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
} else {
if (!form.adhocTanggal) errors.adhocTanggal = 'Pilih tanggal';
form.adhocShifts.forEach((shift, idx) => {
if (!shift.jamMulai) errors[`adhocShift_${idx}_jamMulai`] = 'Pilih jam mulai';
if (!shift.jamSelesai) errors[`adhocShift_${idx}_jamSelesai`] = 'Pilih jam selesai';
if (shift.jamMulai && shift.jamSelesai && shift.jamMulai >= shift.jamSelesai) {
errors[`adhocShift_${idx}_jamSelesai`] = 'Harus setelah jam mulai';
}
});
}
formErrors.value = errors;
return Object.keys(errors).length === 0;
};
/**
* Menangani submit form — validasi lalu emit data ke parent.
*/
const handleSave = () => {
if (!validateForm()) return;
const poliName = props.clinicList.find(c => c.id === form.poliId)?.title ?? '';
const emittedData = [];
if (form.tipe === 'Rutin') {
form.shifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Rutin',
hari: [form.hari],
jamMulai: shift.jamMulai || null,
jamSelesai: shift.jamSelesai || null,
});
});
} else {
form.adhocShifts.forEach((shift, index) => {
emittedData.push({
id: Date.now() + index,
poliId: form.poliId,
poliName,
dokter: form.dokter,
tipe: 'Adhoc',
mulai: `${form.adhocTanggal}T${shift.jamMulai}:00`,
selesai: `${form.adhocTanggal}T${shift.jamSelesai}:00`,
});
});
}
emit('save', emittedData);
dialogOpen.value = false;
};
import MiniCalendarPreview from './MiniCalendarPreview.vue';
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="520px" persistent>
<v-card rounded="lg" class="pa-0">
<!-- Header -->
<v-card-title class="bg-primary text-white pa-5">
<div class="d-flex justify-space-between align-center w-100">
<div class="d-flex align-center gap-2">
<v-icon>mdi-calendar-plus</v-icon>
<span class="text-h6 font-weight-bold">Tambah Jadwal Dokter</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" class="text-white"
@click="dialogOpen = false" />
</div>
</v-card-title>
<v-card-text class="pa-6">
<!-- Tipe Jadwal -->
<div class="text-caption text-grey-darken-1 mb-2 font-weight-medium">Tipe Jadwal</div>
<v-btn-toggle v-model="form.tipe" mandatory color="primary" variant="outlined" class="mb-5 w-100"
divided>
<v-btn value="Rutin" class="flex-grow-1">
<v-icon start>mdi-calendar-sync</v-icon>
Rutin
</v-btn>
<v-btn value="Adhoc" class="flex-grow-1">
<v-icon start>mdi-calendar-clock</v-icon>
Adhoc
</v-btn>
</v-btn-toggle>
<!-- Info poli & dokter -->
<v-row class="mb-4">
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Poli / Klinik <span class="text-error">*</span></div>
<v-select v-model="form.poliId" :items="clinicList" item-title="title" item-value="id"
density="compact" variant="outlined" hide-details="auto" placeholder="Pilih Poli"
prepend-inner-icon="mdi-hospital-building" @update:model-value="form.dokter = null"
:error-messages="formErrors.poli" />
</v-col>
<v-col cols="12" sm="12">
<div class="text-caption text-grey mb-1">Dokter <span class="text-error">*</span></div>
<v-select v-model="form.dokter" :items="availableDoctors" density="compact" variant="outlined"
hide-details="auto" placeholder="Pilih Dokter" prepend-inner-icon="mdi-doctor"
:disabled="!form.poliId" :error-messages="formErrors.dokter" />
</v-col>
</v-row>
<!-- Form Rutin -->
<template v-if="form.tipe === 'Rutin'">
<div class="text-caption text-grey-darken-1 mb-3 font-weight-medium">Pilih Hari Kerja</div>
<div class="d-flex flex-wrap gap-2 mb-1">
<v-chip class="mr-1" v-for="day in DAYS_OF_WEEK" :key="day"
:color="form.hari === day ? 'primary' : 'primary'"
:variant="form.hari === day ? 'flat' : 'outlined'" size="small" style="cursor:pointer;"
@click="form.hari = day">
{{ day }}
</v-chip>
</div>
<div v-if="formErrors.hari" class="text-caption text-error mb-4">{{ formErrors.hari }}</div>
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
<!-- Jam required -->
<div v-for="(shift, index) in form.shifts" :key="index" class="d-flex align-center gap-4 mt-4">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamMulai`]" />
<div v-if="formErrors[`shift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamMulai`] }}
</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`shift_${index}_jamSelesai`]" />
<div v-if="formErrors[`shift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{ formErrors[`shift_${index}_jamSelesai`]
}}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.shifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.shifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.shifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Jadwal {{ form.hari ? `Hari ${form.hari}` : '' }}
</v-btn>
</div>
</template>
<!-- Form Adhoc -->
<template v-else>
<v-row class="mb-2">
<v-col cols="12">
<v-text-field v-model="form.adhocTanggal" label="Tanggal" type="date" variant="outlined"
density="compact" prepend-inner-icon="mdi-calendar" hide-details="auto"
:error-messages="formErrors.adhocTanggal" />
</v-col>
<v-col cols="12">
<MiniCalendarPreview v-if="form.poliId && form.dokter" :tipe="form.tipe" :hari="form.hari"
:adhoc-tanggal="form.adhocTanggal" :jadwal-list="jadwalList" :poli-id="form.poliId"
:dokter="form.dokter" />
</v-col>
</v-row>
<div v-for="(shift, index) in form.adhocShifts" :key="index" class="d-flex align-center gap-4 mt-2">
<div class="flex-grow-1">
<v-row>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Mulai</div>
<v-text-field v-model="shift.jamMulai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamMulai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamMulai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamMulai`] }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-grey mb-1">Jam Selesai</div>
<v-text-field v-model="shift.jamSelesai" type="time" variant="outlined"
density="compact" hide-details required
:error-messages="formErrors[`adhocShift_${index}_jamSelesai`]" />
<div v-if="formErrors[`adhocShift_${index}_jamSelesai`]"
class="text-caption text-error mt-1">{{
formErrors[`adhocShift_${index}_jamSelesai`] }}</div>
</v-col>
</v-row>
</div>
<div class="mt-5" v-if="form.adhocShifts.length > 1">
<v-btn icon="mdi-trash-can-outline" variant="text" color="error" size="small"
@click="form.adhocShifts.splice(index, 1)" />
</div>
</div>
<div class="mt-4">
<v-btn variant="outlined" color="primary" size="small" class="text-none"
@click="form.adhocShifts.push({ jamMulai: '', jamSelesai: '' })">
+ Tambah Shift Adhoc
</v-btn>
</div>
</template>
</v-card-text>
<v-divider />
<v-card-actions class="pa-5 justify-end gap-2">
<v-btn variant="text" color="grey" @click="dialogOpen = false">Batal</v-btn>
<v-btn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="handleSave">
Simpan Jadwal
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,344 @@
<script setup>
/**
* JadwalDokterCalendar — Mode tampilan kalender untuk jadwal dokter.
* Menampilkan filter poli/dokter dan v-calendar Vuetify dengan event
* jadwal rutin (primary) dan adhoc (secondary).
* Menerima data dari parent dan emit interaksi kembali ke parent.
*/
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const props = defineProps({
/** Daftar klinik/poli untuk filter */
clinicList: { type: Array, default: () => [] },
/** Daftar event kalender yang sudah di-generate oleh parent */
calendarEvents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedPoli',
'update:selectedDoctor',
'date-click',
'event-click',
]);
// ── Filter State (local, synced ke parent via emit) ──────────────────────
/** Poli yang dipilih di mode calendar */
const selectedPoli = defineModel('selectedPoli', { type: [String, Number, null], default: null });
/** Dokter yang dipilih di mode calendar */
const selectedDoctor = defineModel('selectedDoctor', { type: [String, null], default: null });
const selectedElement = ref(null)
const selectedOpen = ref(false)
/**
* Daftar dokter berdasarkan poli yang dipilih.
* @returns {string[]} Kosong jika poli belum dipilih
*/
const doctorList = computed(() => {
if (!selectedPoli.value) return [];
const clinic = props.clinicList.find(c => c.id === selectedPoli.value);
return clinic?.doctors ?? [];
});
/** Reset dokter saat poli berubah */
watch(selectedPoli, () => { selectedDoctor.value = null; });
// ── Navigasi Kalender ────────────────────────────────────────────────────
const isDailyEventsOpen = ref(false);
const dailyEvents = ref([]);
const selectedDateFormatted = ref('');
/** Tanggal referensi untuk navigasi bulan */
const viewDate = ref(new Date());
/**
* Key dinamis untuk v-calendar. Vuetify v-calendar punya internal navigation
* state sendiri — mengubah :model-value saja tidak cukup. Dengan mengganti :key,
* kita paksa komponen re-mount sehingga bulan yang tampil ikut berubah.
*/
const calendarKey = computed(() =>
`cal-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
/** Judul bulan & tahun aktif */
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
/** Navigasi ke bulan sebelumnya */
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
/** Navigasi ke bulan berikutnya */
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
/**
* Menangani klik pada tanggal di kalender.
* @param {Date|string|object} info - Payload dari @click:date
*/
const handleDateClick = (info, { date }) => {
emit('date-click', date);
};
/**
* Menangani klik pada event di kalender.
* @param {object} payload - Payload dari @click:event v-calendar
*/
const handleEventClick = (nativeEvent, { event }) => {
const ev = event;
if (!ev?.extendedProps) return;
const d = ev.start instanceof Date ? ev.start : new Date(ev.start);
emit('event-click', {
...ev.extendedProps,
tanggal: d.toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
});
};
/**
* Menangani klik "+X lainnya" pada kalender.
* @param {Date|string|object} info - Payload dari @click:more
*/
const handleMoreClick = (nativeEvent, { date }) => {
selectedDateFormatted.value = date;
console.log("date", date)
console.log("calendarEvents.value", props.calendarEvents)
dailyEvents.value = props.calendarEvents.filter(e =>
e.start.toISOString().split("T")[0] === selectedDateFormatted.value
);
const open = () => {
// selectedEvent.value = event
selectedElement.value = nativeEvent.target
requestAnimationFrame(() => requestAnimationFrame(() => selectedOpen.value = true))
}
if (selectedOpen.value) {
selectedOpen.value = false
requestAnimationFrame(() => requestAnimationFrame(() => open()))
} else {
open()
}
nativeEvent.stopPropagation()
};
/**
* Menangani klik pada event di dalam dialog harian.
*/
const handleDailyEventClick = (ev) => {
isDailyEventsOpen.value = false;
const payload = { event: ev };
handleEventClick(payload);
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-row class="mb-2">
<v-col cols="12">
<v-card class="pa-6" color="white">
<div class="d-flex align-center mb-4">
<v-icon color="primary" class="mr-2">mdi-filter-variant</v-icon>
<h3 class="text-subtitle-1 font-weight-bold text-primary">FILTER JADWAL</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-select v-model="selectedPoli" :items="clinicList" item-title="title" item-value="id"
label="Pilih Poli / Klinik" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="6">
<v-select v-model="selectedDoctor" :items="doctorList" label="Pilih Dokter"
variant="outlined" density="compact" clearable prepend-inner-icon="mdi-doctor"
:disabled="!selectedPoli" hide-details
:placeholder="selectedPoli ? 'Pilih dokter...' : 'Pilih poli terlebih dahulu'" />
</v-col>
</v-row>
<div v-if="selectedPoli || selectedDoctor" class="mt-4 d-flex align-center gap-2 flex-wrap">
<span class="text-caption text-grey-darken-1">Filter aktif:</span>
<v-chip v-if="selectedPoli" size="small" color="primary" variant="tonal" closable
@click:close="selectedPoli = null">
<v-icon start size="x-small">mdi-hospital-building</v-icon>
{{clinicList.find(c => c.id === selectedPoli)?.title}}
</v-chip>
<v-chip v-if="selectedDoctor" size="small" color="secondary" variant="tonal" closable
@click:close="selectedDoctor = null">
<v-icon start size="x-small">mdi-doctor</v-icon>
{{ selectedDoctor }}
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<!-- Calendar Card -->
<v-row v-if="selectedDoctor && selectedPoli">
<v-col cols="12">
<v-card elevation="2" rounded="lg" color="white">
<!-- Toolbar -->
<div class="d-flex align-center justify-space-between px-6 pt-5 pb-4">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="small" @click="nextMonth" />
<span class="text-body-1 font-weight-bold" style="min-width:150px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
<!-- Legend -->
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-1">
<span class="legend-dot bg-primary"></span>
<span class="text-caption ml-1">Rutin</span>
</div>
<div class="d-flex align-center gap-1 ml-1">
<span class="legend-dot bg-secondary"></span>
<span class="text-caption ml-1">Adhoc</span>
</div>
</div>
</div>
<v-divider />
<!-- v-calendar Vuetify -->
<v-calendar :interval-height="100" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month" event-more-text="{0} lainnya" class="jadwal-calendar"
@click:date="handleDateClick" @click:event="handleEventClick" @click:more="handleMoreClick" />
<v-menu v-model="selectedOpen" :activator="selectedElement" :close-on-content-click="false"
location="end">
<v-card color="grey-lighten-4" min-width="350px" flat>
<v-toolbar color="primary" dark>
<v-btn icon>
<v-icon>mdi-calendar</v-icon>
</v-btn>
<v-toolbar-title>Jadwal Lainnya</v-toolbar-title>
</v-toolbar>
<v-card-text style="max-height: 300px; overflow-y: auto;">
<v-list v-if="dailyEvents.length > 0" lines="two" bg-color="transparent">
<v-list-item v-for="(ev, idx) in dailyEvents" :key="idx" class="mb-3 rounded border"
:class="ev.color === 'primary' ? 'border-primary' : 'border-secondary'"
@click="handleDailyEventClick(ev)" hover>
<v-list-item-title class="font-weight-bold">
<v-chip :color="ev.color" size="small" class="mr-2">{{
ev.extendedProps?.tipe }}</v-chip>
{{ ev.name }}
</v-list-item-title>
<v-list-item-subtitle class="mt-1">
<v-icon size="x-small" class="mr-1">mdi-doctor</v-icon>{{
ev.extendedProps?.dokter }}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-icon size="x-small" class="mr-1">mdi-hospital-building</v-icon>{{
ev.extendedProps?.poli }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-center pa-6 text-grey">
Tidak ada jadwal tambahan.
</div>
</v-card-text>
</v-card>
</v-menu>
</v-card>
</v-col>
</v-row>
<template v-else>
<v-card min-height="400px">
<v-card-text>
<v-row>
<v-col cols="12">
<div class="text-center pa-12 d-flex align-center justify-center" style="height: 400px;">
<div>
<v-icon size="48" color="grey-lighten-1"
class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2 text-grey-lighten-1">Silahkan Pilih Poli dan Dokter terlebih
dahulu</div>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
</div>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 125px;
}
.jadwal-calendar {
border-radius: 0 0 8px 8px;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
/* Header hari */
:deep(.v-calendar-month__weekday) {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #283593;
background-color: #e8eaf6;
padding: 6px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-month__day-label) {
font-size: 0.8rem;
font-weight: 500;
color: #424242;
cursor: pointer;
}
:deep(.v-calendar-month__day-label:hover) {
background-color: #e8eaf6;
border-radius: 50%;
}
/* Hari ini */
:deep(.v-calendar-month__day--today .v-calendar-month__day-label) {
background-color: #1a237e;
color: white;
border-radius: 50%;
}
/* Event */
:deep(.v-calendar-month__event) {
font-size: 0.68rem !important;
font-weight: 600;
border-radius: 4px !important;
cursor: pointer;
transition: opacity 0.15s ease;
}
:deep(.v-calendar-month__event:hover) {
opacity: 0.82;
}
</style>
@@ -0,0 +1,313 @@
<script setup>
/**
* JadwalDokterList — Mode tampilan daftar (tabel) untuk jadwal dokter.
* Menampilkan filter (poli klinik, tanggal, nama dokter) dan tabel
* dengan kolom NO, POLI, NAMA DOKTER, JADWAL RUTIN, AKSI.
* Mode ini menjadi tampilan default halaman Jadwal Dokter.
*/
const props = defineProps({
/** Daftar klinik/poli dari store */
clinicList: { type: Array, default: () => [] },
/** Daftar jadwal yang sudah ditambahkan */
jadwalList: { type: Array, default: () => [] },
});
const emit = defineEmits(['edit', 'add']);
// ── Filter State ─────────────────────────────────────────────────────────
/** Poli yang dipilih untuk filter */
const filterPoli = ref(null);
/** Tanggal yang dipilih untuk filter */
const filterDate = ref(null);
/** Keyword pencarian nama dokter */
const filterDokter = ref('');
// ── Pagination ───────────────────────────────────────────────────────────
/** Halaman aktif saat ini */
const currentPage = ref(1);
/** Jumlah baris per halaman */
const itemsPerPage = ref(10);
/** Opsi jumlah baris per halaman */
const perPageOptions = [5, 10, 25, 50];
// ── Mapping Hari ─────────────────────────────────────────────────────────
const DAY_MAP = {
0: 'Minggu', 1: 'Senin', 2: 'Selasa', 3: 'Rabu',
4: 'Kamis', 5: 'Jumat', 6: 'Sabtu'
};
// ── Computed: Data Tabel ─────────────────────────────────────────────────
/**
* Membangun data tabel dari jadwalList, dikelompokkan per dokter + poli.
* Setiap baris menampilkan 1 dokter dengan ringkasan jadwal rutinnya.
* @returns {Array<{ id, poliName, poliId, dokter, jadwalRutin, raw }>}
*/
const tableData = computed(() => {
/** Gabungkan jadwal berdasarkan key unik dokter+poli */
const grouped = {};
props.jadwalList.forEach(j => {
const key = `${j.poliId}-${j.dokter}`;
if (!grouped[key]) {
grouped[key] = {
id: j.id,
poliName: j.poliName,
poliId: j.poliId,
dokter: j.dokter,
hariSet: new Set(),
jamMulai: null,
jamSelesai: null,
raw: [],
};
}
grouped[key].raw.push(j);
if (j.tipe === 'Rutin' && j.hari) {
j.hari.forEach(h => grouped[key].hariSet.add(h));
if (j.jamMulai) grouped[key].jamMulai = j.jamMulai;
if (j.jamSelesai) grouped[key].jamSelesai = j.jamSelesai;
}
});
return Object.values(grouped).map(g => {
const hariArr = Array.from(g.hariSet);
/** Format jadwal rutin: "Senin - Kamis\n08:00 - 14:00 WIB" */
let jadwalRutin = '';
if (hariArr.length > 0) {
jadwalRutin = hariArr.join(', ');
if (g.jamMulai && g.jamSelesai) {
jadwalRutin += `\n${g.jamMulai} - ${g.jamSelesai} WIB`;
}
} else {
jadwalRutin = '-';
}
return {
id: g.id,
poliName: g.poliName,
poliId: g.poliId,
dokter: g.dokter,
jadwalRutin,
raw: g.raw,
};
});
});
/**
* Data tabel yang sudah difilter berdasarkan poli, tanggal, dan keyword nama dokter.
* @returns {Array}
*/
const filteredData = computed(() => {
let data = tableData.value;
// Filter poli
if (filterPoli.value) {
data = data.filter(d => d.poliId === filterPoli.value);
}
// Filter tanggal — cocokkan dengan hari dari jadwal rutin
if (filterDate.value) {
const date = new Date(filterDate.value);
const dayName = DAY_MAP[date.getDay()];
data = data.filter(d =>
d.raw.some(j => j.tipe === 'Rutin' && j.hari?.includes(dayName))
);
}
// Filter nama dokter
if (filterDokter.value) {
const keyword = filterDokter.value.toLowerCase();
data = data.filter(d => d.dokter.toLowerCase().includes(keyword));
}
return data;
});
/** Total data setelah filter */
const totalItems = computed(() => filteredData.value.length);
/** Total halaman */
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value)));
/** Data untuk halaman aktif (sliced) */
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
return filteredData.value.slice(start, start + itemsPerPage.value);
});
/** Label info pagination: "Menampilkan 1-10 dari 124 dokter spesialis" */
const paginationLabel = computed(() => {
if (totalItems.value === 0) return 'Tidak ada data';
const start = (currentPage.value - 1) * itemsPerPage.value + 1;
const end = Math.min(currentPage.value * itemsPerPage.value, totalItems.value);
return `Menampilkan ${start}-${end} dari ${totalItems.value} dokter spesialis`;
});
/** Reset halaman saat filter berubah */
watch([filterPoli, filterDate, filterDokter], () => {
currentPage.value = 1;
});
/**
* Navigasi ke halaman tertentu.
* @param {number} page
*/
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
</script>
<template>
<div>
<!-- Filter Card -->
<v-card class="pa-5 mb-6" elevation="2" rounded="lg" color="white">
<v-row align="center">
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Poli Klinik</div>
<v-select v-model="filterPoli" :items="clinicList" item-title="title" item-value="id"
placeholder="Semua Poli" variant="outlined" density="compact" clearable
prepend-inner-icon="mdi-hospital-building" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Tanggal</div>
<v-text-field v-model="filterDate" type="date" placeholder="Pilih Tanggal" variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-calendar" hide-details />
</v-col>
<v-col cols="12" sm="4">
<div class="text-caption text-grey-darken-1 mb-1 font-weight-medium">Nama Dokter</div>
<v-text-field v-model="filterDokter" placeholder="Cari Nama Dokter..." variant="outlined"
density="compact" clearable prepend-inner-icon="mdi-account-search" hide-details />
</v-col>
</v-row>
</v-card>
<!-- Tabel Jadwal -->
<v-card elevation="2" rounded="lg" color="white">
<v-table class="jadwal-table">
<thead>
<tr>
<th class="text-left" style="width: 60px;">NO</th>
<th class="text-left" style="width: 140px;">POLI</th>
<th class="text-left">NAMA DOKTER</th>
<th class="text-left" style="width: 200px;">JADWAL RUTIN</th>
<th class="text-center" style="width: 100px;">AKSI</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="5" class="text-center pa-8 text-grey">
<v-icon size="48" color="grey-lighten-1" class="mb-2">mdi-calendar-blank-outline</v-icon>
<div class="text-body-2">Belum ada data jadwal dokter</div>
<div class="text-caption text-grey-lighten-1">Tambahkan jadwal dokter melalui mode
kalender</div>
</td>
</tr>
<tr v-for="(item, index) in paginatedData" :key="item.id" class="jadwal-row">
<td class="text-body-2 font-weight-medium">
{{ (currentPage - 1) * itemsPerPage + index + 1 }}
</td>
<td>
<v-chip size="small" color="primary" variant="tonal">
{{ item.poliName }}
</v-chip>
</td>
<td class="text-body-2 font-weight-bold">
{{ item.dokter }}
</td>
<td>
<div class="text-body-2" style="white-space: pre-line; line-height: 1.5;">
{{ item.jadwalRutin }}
</div>
</td>
<td class="text-center">
<v-btn variant="outlined" size="small" color="primary" rounded="lg"
prepend-icon="mdi-pencil" @click="emit('edit', item)">
Edit
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<!-- Pagination Footer -->
<v-divider />
<div class="d-flex align-center justify-space-between px-4 py-3 pagination-footer">
<span class="text-caption text-grey-darken-1">{{ paginationLabel }}</span>
<div class="d-flex align-center gap-3">
<div class="d-flex align-center gap-2">
<span class="text-caption text-grey-darken-1 mr-2">Baris per halaman:</span>
<v-select v-model="itemsPerPage" :items="perPageOptions" variant="plain" density="compact"
hide-details style="max-width: 70px;" />
</div>
<div class="d-flex align-center gap-1">
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1" @click="goToPage(1)">
<v-icon size="16">mdi-page-first</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)">
<v-icon size="16">mdi-chevron-left</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)">
<v-icon size="16">mdi-chevron-right</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" :disabled="currentPage >= totalPages"
@click="goToPage(totalPages)">
<v-icon size="16">mdi-page-last</v-icon>
</v-btn>
</div>
</div>
</div>
</v-card>
</div>
</template>
<style scoped>
.jadwal-table {
border-radius: 12px 12px 0 0;
}
.jadwal-table thead th {
background-color: #f5f6fa !important;
color: #5a607f !important;
font-weight: 700 !important;
font-size: 0.75rem !important;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 2px solid #e8eaf0 !important;
padding: 14px 16px !important;
}
.jadwal-row {
transition: background-color 0.15s ease;
}
.jadwal-row:hover {
background-color: #f8f9fd;
}
.jadwal-row td {
padding: 16px !important;
border-bottom: 1px solid #f0f1f5 !important;
vertical-align: middle;
}
.pagination-footer {
background-color: #fafbfc;
border-radius: 0 0 12px 12px;
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
tipe: { type: String, default: 'Rutin' },
hari: { type: String, default: '' },
adhocTanggal: { type: String, default: '' },
jadwalList: { type: Array, default: () => [] },
poliId: { type: [String, Number, null], default: null },
dokter: { type: String, default: null }
});
const MONTH_NAMES = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const DAY_MAP = {
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
};
const viewDate = ref(new Date());
const calendarKey = computed(() =>
`cal-mini-${viewDate.value.getFullYear()}-${viewDate.value.getMonth()}`
);
const calendarTitle = computed(() =>
`${MONTH_NAMES[viewDate.value.getMonth()]} ${viewDate.value.getFullYear()}`
);
const prevMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() - 1);
viewDate.value = new Date(d);
};
const nextMonth = () => {
const d = new Date(viewDate.value);
d.setMonth(d.getMonth() + 1);
viewDate.value = new Date(d);
};
const toDateStr = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const calendarEvents = computed(() => {
const events = [];
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
// 1. Generate existing events for the selected doctor
const filtered = props.jadwalList.filter(j => {
if (props.poliId && j.poliId !== props.poliId) return false;
if (props.dokter && j.dokter !== props.dokter) return false;
return true;
});
filtered.forEach(jadwal => {
if (jadwal.tipe === 'Rutin') {
for (let mOffset = -1; mOffset <= 1; mOffset++) {
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
const tMonth = (month + mOffset + 12) % 12;
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(tYear, tMonth, d);
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
let jadwalHariArray = [];
if (Array.isArray(jadwal.hari)) {
jadwalHariArray = jadwal.hari;
} else if (typeof jadwal.hari === 'string') {
jadwalHariArray = [jadwal.hari];
}
if (!jadwalHariArray.includes(dayName)) continue;
const dateStr = toDateStr(date);
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
: '';
events.push({
name: `${jamLabel}`,
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
color: 'primary',
allDay: !jadwal.jamMulai,
});
}
}
} else {
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
: '';
events.push({
name: `${adhocJamLabel}`,
start: new Date(jadwal.mulai),
end: new Date(jadwal.selesai),
color: 'secondary',
allDay: false,
});
}
});
return events;
});
const calendarRef = ref(null);
const updateHighlight = () => {
// Wait for calendar to fully render internally
setTimeout(() => {
const calendarEl = calendarRef.value?.$el || document.querySelector('.mini-calendar');
if (!calendarEl) return;
// Remove previous highlights
const previous = calendarEl.querySelectorAll('.adhoc-highlight, .rutin-highlight');
previous.forEach(el => {
el.classList.remove('adhoc-highlight', 'rutin-highlight');
el.style.border = '';
el.style.borderRadius = '';
el.style.color = '';
el.style.fontWeight = '';
});
const allDays = calendarEl.querySelectorAll('.v-calendar-weekly__day');
if (allDays.length === 0) return;
if (props.tipe === 'Rutin' && props.hari) {
const targetCol = DAY_MAP[props.hari];
allDays.forEach((dayEl, index) => {
// Check if index matches column and it's not from adjacent month
if (index % 7 === targetCol && !dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelBtn) {
labelBtn.classList.add('rutin-highlight');
// Fallback inline styles in case CSS scoped issue occurs
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
} else if (props.tipe === 'Adhoc' && props.adhocTanggal) {
const adhocDate = new Date(props.adhocTanggal);
if (adhocDate.getMonth() !== viewDate.value.getMonth() || adhocDate.getFullYear() !== viewDate.value.getFullYear()) {
return;
}
const dayToHighlight = adhocDate.getDate().toString();
allDays.forEach(dayEl => {
if (!dayEl.classList.contains('v-calendar-weekly__day--adjacent')) {
const labelTextEl = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn__content, .v-calendar-weekly__day-label');
const labelBtn = dayEl.querySelector('.v-calendar-weekly__day-label .v-btn, .v-calendar-weekly__day-label');
if (labelTextEl && labelTextEl.textContent.trim() === dayToHighlight) {
labelBtn.classList.add('adhoc-highlight');
labelBtn.style.border = '2px solid rgb(var(--v-theme-primary))';
labelBtn.style.borderRadius = '50%';
labelBtn.style.color = 'rgb(var(--v-theme-primary))';
labelBtn.style.fontWeight = 'bold';
labelBtn.style.width = '28px';
labelBtn.style.height = '28px';
labelBtn.style.display = 'flex';
labelBtn.style.alignItems = 'center';
labelBtn.style.justifyContent = 'center';
labelBtn.style.margin = '2px auto';
}
}
});
}
}, 150);
};
import { watch, nextTick } from 'vue';
watch([() => props.adhocTanggal, () => props.hari, () => props.tipe, viewDate], () => {
nextTick(() => updateHighlight());
}, { immediate: true });
</script>
<template>
<v-card class="mt-4 border border-grey-lighten-3 rounded-lg" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-3 pb-2">
<div class="d-flex align-center">
<v-btn icon="mdi-chevron-left" variant="text" size="x-small" @click="prevMonth" />
<v-btn icon="mdi-chevron-right" variant="text" size="x-small" @click="nextMonth" />
<span class="text-caption font-weight-bold mx-2" style="min-width:100px;text-align:center;">
{{ calendarTitle }}
</span>
</div>
</div>
<!-- We use a custom class to inject CSS for highlighted days if they have preview event -->
<v-calendar ref="calendarRef" :interval-height="40" locale="id" :key="calendarKey" :model-value="viewDate"
:events="calendarEvents" view-mode="month"
:class="['mini-calendar', props.tipe === 'Rutin' && props.hari ? 'preview-hari-' + props.hari : '']" />
</v-card>
</template>
<style scoped>
:deep(.v-calendar-weekly__day) {
height: 70px !important;
}
.mini-calendar {
border-radius: 0 0 8px 8px;
background-color: white !important;
}
/* Header hari */
:deep(.v-calendar-weekly__weekday) {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
color: #555;
background-color: #f5f5f5;
padding: 4px 0;
}
/* Nomor tanggal */
:deep(.v-calendar-weekly__day-label) {
font-size: 0.8rem;
font-weight: 600;
color: #424242;
transition: all 0.2s ease;
}
/* Lingkaran Preview via JS Class */
:deep(.rutin-highlight),
:deep(.adhoc-highlight) {
border: 2px solid rgb(var(--v-theme-primary)) !important;
border-radius: 50% !important;
color: rgb(var(--v-theme-primary)) !important;
font-weight: bold !important;
}
/* Event styles */
:deep(.v-calendar-weekly__event) {
font-size: 0.6rem !important;
font-weight: 600;
border-radius: 4px !important;
padding: 0 4px !important;
margin-bottom: 2px !important;
min-height: 16px !important;
}
</style>
+383
View File
@@ -0,0 +1,383 @@
<script setup>
/**
* Halaman Jadwal Dokter — Orchestrator utama.
* Menyediakan 2 mode tampilan:
* 1. List (default) — tabel dengan filter poli, tanggal, nama dokter
* 2. Calendar — kalender bulanan dengan event jadwal
*
* State management (jadwalList, selectedPoli, selectedDoctor) dikelola di sini
* dan diteruskan ke child component via props/defineModel.
*/
import PageHeader from '@/components/common/PageHeader.vue';
import JadwalDokterList from '@/components/features/master/jadwalDokter/JadwalDokterList.vue';
import JadwalDokterCalendar from '@/components/features/master/jadwalDokter/JadwalDokterCalendar.vue';
import DialogTambahJadwal from '@/components/features/master/jadwalDokter/DialogTambahJadwal.vue';
import DialogDetailEvent from '@/components/features/master/jadwalDokter/DialogDetailEvent.vue';
import { useClinicStore } from '@/stores/clinicStore.js';
// ── Tanggal Header ─────────────────────────────────────────────────────────
/** Mengembalikan string tanggal hari ini dalam format Indonesia */
const currentDate = computed(() => {
const now = new Date();
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
});
// ── Mode Toggle ────────────────────────────────────────────────────────────
/** Mode tampilan aktif: 'list' (default) atau 'calendar' */
const viewMode = ref('list');
// ── Store & Data ───────────────────────────────────────────────────────────
const clinicStore = useClinicStore();
/** Daftar semua klinik/poli dari store */
const clinicList = computed(() =>
clinicStore.clinics.map(c => ({ id: c.id, title: c.name, kode: c.kode, doctors: c.doctors ?? [] }))
);
/** Poli yang dipilih (untuk mode calendar & dialog) */
const selectedPoli = ref(null);
/** Dokter yang dipilih (untuk mode calendar & dialog) */
const selectedDoctor = ref(null);
// ── Konstanta ──────────────────────────────────────────────────────────────
const DAY_MAP = {
'Minggu': 0, 'Senin': 1, 'Selasa': 2, 'Rabu': 3,
'Kamis': 4, 'Jumat': 5, 'Sabtu': 6
};
// ── Storage Jadwal (local state) ───────────────────────────────────────────
/**
* Daftar jadwal yang sudah ditambahkan.
* Setiap entry: { id, poliId, poliName, dokter, tipe, hari?, jamMulai?, jamSelesai?, mulai?, selesai? }
*/
const jadwalList = ref([]);
jadwalList.value = [
{
"id": 1784689277259,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Adhoc",
"mulai": "2026-07-05T07:00:00",
"selesai": "2026-07-05T08:00:00"
},
{
"id": 1784689301842,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Rutin",
"hari": [
"Minggu",
],
"jamMulai": "10:00",
"jamSelesai": "12:00"
},
{
"id": 1784689301842,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Rutin",
"hari": [
"Kamis",
],
"jamMulai": "11:00",
"jamSelesai": "13:00"
},
{
"id": 1784689305888,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Adhoc",
"mulai": "2026-07-05T13:00:00",
"selesai": "2026-07-05T14:00:00"
},
{
"id": 178468930123,
"poliId": 1000,
"poliName": "ANAK",
"dokter": "dr. Sarah Putri, Sp.A",
"tipe": "Adhoc",
"mulai": "2026-07-05T19:00:00",
"selesai": "2026-07-05T20:00:00"
}
]
// ── Generate Events Kalender ───────────────────────────────────────────────
/**
* Format tanggal ke string YYYY-MM-DD.
* @param {Date} date
* @returns {string}
*/
const toDateStr = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
/** Tanggal referensi untuk navigasi bulan (digunakan oleh calendarEvents) */
const viewDate = ref(new Date());
/**
* Menghasilkan event kalender dari jadwalList yang tersimpan,
* difilter berdasarkan selectedPoli dan selectedDoctor.
* @returns {Array}
*/
const calendarEvents = computed(() => {
const events = [];
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
const filtered = jadwalList.value.filter(j => {
if (selectedPoli.value && j.poliId !== selectedPoli.value) return false;
if (selectedDoctor.value && j.dokter !== selectedDoctor.value) return false;
return true;
});
filtered.forEach(jadwal => {
if (jadwal.tipe === 'Rutin') {
for (let mOffset = -1; mOffset <= 1; mOffset++) {
const tYear = month + mOffset < 0 ? year - 1 : month + mOffset > 11 ? year + 1 : year;
const tMonth = (month + mOffset + 12) % 12;
const daysInMonth = new Date(tYear, tMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(tYear, tMonth, d);
const dayName = Object.keys(DAY_MAP).find(k => DAY_MAP[k] === date.getDay());
if (!jadwal.hari.includes(dayName)) continue;
const dateStr = toDateStr(date);
/** Tambahkan jam ke title jika tersedia */
const jamLabel = jadwal.jamMulai && jadwal.jamSelesai
? ` ${jadwal.jamMulai} - ${jadwal.jamSelesai}`
: '';
events.push({
name: `${jamLabel}`,
start: jadwal.jamMulai ? new Date(`${dateStr}T${jadwal.jamMulai}:00`) : date,
end: jadwal.jamSelesai ? new Date(`${dateStr}T${jadwal.jamSelesai}:00`) : date,
color: 'primary',
allDay: !jadwal.jamMulai,
extendedProps: {
id: jadwal.id,
poli: jadwal.poliName,
dokter: jadwal.dokter,
tipe: 'Rutin',
hari: jadwal.hari.join(', '),
}
});
}
}
} else {
/** Format jam adhoc dari datetime string */
const adhocMulaiTime = jadwal.mulai ? jadwal.mulai.split('T')[1]?.slice(0, 5) : '';
const adhocSelesaiTime = jadwal.selesai ? jadwal.selesai.split('T')[1]?.slice(0, 5) : '';
const adhocJamLabel = adhocMulaiTime && adhocSelesaiTime
? ` ${adhocMulaiTime} - ${adhocSelesaiTime}`
: '';
events.push({
name: `${adhocJamLabel}`,
start: new Date(jadwal.mulai),
end: new Date(jadwal.selesai),
color: 'secondary',
allDay: false,
extendedProps: {
id: jadwal.id,
poli: jadwal.poliName,
dokter: jadwal.dokter,
tipe: 'Adhoc',
shift: `${formatDatetime(jadwal.mulai)} ${formatDatetime(jadwal.selesai)}`,
}
});
}
});
return events;
});
watch(jadwalList, () => {
console.log(jadwalList.value)
}, { deep: true })
/**
* Format ISO datetime ke string Indonesia yang ringkas.
* @param {string} isoStr
* @returns {string}
*/
const formatDatetime = (isoStr) => {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleString('id-ID', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
// ── Dialog Tambah Jadwal ───────────────────────────────────────────────────
/** Apakah dialog tambah jadwal terbuka */
const isAddOpen = ref(false);
/** Tanggal yang diklik sebagai default untuk form */
const clickedDate = ref(null);
/**
* Menangani klik pada tanggal di kalender.
* @param {Date} date - Tanggal yang diklik (sudah dinormalisasi oleh child)
*/
const handleDateClick = (date) => {
clickedDate.value = date;
isAddOpen.value = true;
};
/**
* Menyimpan jadwal baru ke jadwalList dari dialog.
* @param {object|Array} jadwalData - Data jadwal dari dialog
*/
const handleSaveJadwal = (jadwalData) => {
if (Array.isArray(jadwalData)) {
jadwalList.value.push(...jadwalData);
} else {
jadwalList.value.push(jadwalData);
}
};
/** Membuka dialog tambah dari tombol header */
const openAddDialog = () => {
clickedDate.value = new Date();
isAddOpen.value = true;
};
// ── Detail Event ───────────────────────────────────────────────────────────
/** Event yang sedang diklik */
const selectedEvent = ref(null);
/** Apakah dialog detail terbuka */
const isDetailOpen = ref(false);
/**
* Menangani klik event dari kalender.
* @param {object} eventData - Data event yang sudah dinormalisasi oleh child
*/
const handleEventClick = (eventData) => {
selectedEvent.value = eventData;
isDetailOpen.value = true;
};
/**
* Menghapus jadwal dari jadwalList berdasarkan ID event.
* @param {number} id - ID jadwal yang akan dihapus
*/
const handleDeleteJadwal = (id) => {
if (!id) return;
jadwalList.value = jadwalList.value.filter(j => j.id !== id);
};
/**
* Menangani klik edit dari mode list.
* @param {object} item - Item tabel yang akan di-edit
*/
const handleEdit = (item) => {
// Untuk saat ini, tampilkan detail event dari item pertama
if (item.raw && item.raw.length > 0) {
const first = item.raw[0];
selectedEvent.value = {
id: first.id,
poli: first.poliName,
dokter: first.dokter,
tipe: first.tipe,
hari: first.hari ? first.hari.join(', ') : null,
tanggal: new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
};
isDetailOpen.value = true;
}
};
</script>
<template>
<div style="background: var(--color-neutral-300); height: 100%;">
<PageHeader icon="mdi-calendar-clock" title="Jadwal Dokter" :subtitle="currentDate" :show-add-button="false"
theme="primary">
<template #actions>
<div class="d-flex align-center gap-3">
<!-- Mode Toggle -->
<v-btn-toggle v-model="viewMode" mandatory density="compact" variant="outlined" color="white"
class="mode-toggle" divided>
<v-btn value="list" size="small">
<v-icon start size="18">mdi-format-list-bulleted</v-icon>
List
</v-btn>
<v-btn value="calendar" size="small">
<v-icon start size="18">mdi-calendar-month</v-icon>
Calendar
</v-btn>
</v-btn-toggle>
<!-- Tombol Tambah Dokter -->
<v-btn color="white" elevation="0" class="add-btn-primary ml-5" @click="openAddDialog">
<v-icon left size="20">mdi-plus-circle</v-icon>
Tambah Jadwal
</v-btn>
</div>
</template>
</PageHeader>
<v-container class="py-6">
<!-- Mode List (default) -->
<JadwalDokterList v-if="viewMode === 'list'" :clinic-list="clinicList" :jadwal-list="jadwalList"
@edit="handleEdit" @add="openAddDialog" />
<!-- Mode Calendar -->
<JadwalDokterCalendar v-else v-model:selected-poli="selectedPoli" v-model:selected-doctor="selectedDoctor"
:clinic-list="clinicList" :calendar-events="calendarEvents" @date-click="handleDateClick"
@event-click="handleEventClick" />
</v-container>
<!-- Dialog Tambah Jadwal (shared) -->
<DialogTambahJadwal v-model="isAddOpen" :view-mode="viewMode" :clinic-list="clinicList"
:selected-poli="selectedPoli" :selected-doctor="selectedDoctor" :clicked-date="clickedDate"
:jadwal-list="jadwalList"
@save="handleSaveJadwal" />
<!-- Dialog Detail Event (shared) -->
<DialogDetailEvent v-model="isDetailOpen" :event="selectedEvent" @delete="handleDeleteJadwal" />
</div>
</template>
<style scoped>
.mode-toggle {
border-radius: 8px !important;
overflow: hidden;
}
.mode-toggle .v-btn {
color: rgba(255, 255, 255, 0.8) !important;
text-transform: none !important;
font-weight: 600;
font-size: 0.8rem;
letter-spacing: 0.02em;
}
.mode-toggle .v-btn--active {
background-color: rgba(255, 255, 255, 0.2) !important;
color: white !important;
}
.add-btn-primary {
text-transform: none !important;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
border-radius: 8px;
}
</style>
+1 -5
View File
@@ -331,11 +331,7 @@ const handleAddMember = () => {
<div class="text-body-2">{{ profileData.birthDate }}</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-uppercase text-grey-darken-1 font-weight-bold mb-1">
Nomor Telepon
</div>
<div class="text-body-2">{{ profileData.registrationNumber }}</div>
<v-col cols="2">
</v-col>
<v-col cols="7">
+17 -16
View File
@@ -16,11 +16,11 @@ interface NavItem {
// Initial default navigation items
const defaultNavItems: NavItem[] = [
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path: "/verifikasiAkun/VerifikasiAkun" },
{
id: 3,
name: "Check In",
icon: "mdi-file-document-edit-outline",
path: "/CheckInPasien/checkIn"
// badge: "3",
},
@@ -38,14 +38,14 @@ const defaultNavItems: NavItem[] = [
// { id: 10, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-circle-small" },
{ id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", 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: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small" },
// { id: 13, name: "Penunjang", path: "/Anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
{id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small"},
{id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small"},
{ id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small" },
{ id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small" },
],
},
{
{
id: 15,
name: "Master Data",
icon: "mdi-cog-outline",
@@ -53,6 +53,7 @@ const defaultNavItems: NavItem[] = [
children: [
{ id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small" },
{ id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small" },
{ id: 25, name: "Master Jadwal Dokter", path: "/Setting/JadwalDokter", icon: "mdi-circle-small" },
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small" },
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small" },
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small" },
@@ -82,7 +83,7 @@ export const useNavItemsStore = defineStore('navItems', () => {
// Jika navItems.value null, undefined, atau bukan array yang valid,
// kembalikan array default.
if (!Array.isArray(navItems.value) || navItems.value === null || navItems.value === undefined) {
return defaultNavItems;
return defaultNavItems;
}
return navItems.value;
});
@@ -106,12 +107,12 @@ export const useNavItemsStore = defineStore('navItems', () => {
async function refreshNavItems() {
const { getAllowedPages } = useHakAkses();
const allowedPages = await getAllowedPages();
if (allowedPages.length === 0) {
// If no hak akses defined (maybe new system not setup yet),
// keep default or clear? Let's keep for now for safety during transition
filteredNavItems.value = defaultNavItems;
return;
// If no hak akses defined (maybe new system not setup yet),
// keep default or clear? Let's keep for now for safety during transition
filteredNavItems.value = defaultNavItems;
return;
}
const filterItems = (items: NavItem[]): NavItem[] => {
@@ -125,7 +126,7 @@ export const useNavItemsStore = defineStore('navItems', () => {
}
return false;
}
// If it's a child or leaf, check if path is in allowedPages
return allowedPages.includes(item.path);
});