feat: Done Rekam Medis Page (Filter, Pagination, Sorting)

This commit is contained in:
yosaphatprs 2025-10-31 16:43:24 +07:00
parent 3e85da0098
commit 3778b555a8
14 changed files with 1331 additions and 77 deletions

View File

@ -27,6 +27,16 @@ export class RekamMedisController {
@Query('orderBy') orderBy: string, @Query('orderBy') orderBy: string,
@Query('no_rm') no_rm: string, @Query('no_rm') no_rm: string,
@Query('order') order: 'asc' | 'desc', @Query('order') order: 'asc' | 'desc',
@Query('id_visit') id_visit: string,
@Query('nama_pasien') nama_pasien: string,
@Query('tanggal_start') tanggal_start: string,
@Query('tanggal_end') tanggal_end: string,
@Query('umur_min') umur_min: string,
@Query('umur_max') umur_max: string,
@Query('jenis_kelamin') jenis_kelamin: string,
@Query('gol_darah') gol_darah: string,
@Query('kode_diagnosa') kode_diagnosa: string,
@Query('tindak_lanjut') tindak_lanjut: string,
) { ) {
return this.rekammedisService.getAllRekamMedis({ return this.rekammedisService.getAllRekamMedis({
take, take,
@ -35,6 +45,16 @@ export class RekamMedisController {
orderBy, orderBy,
no_rm, no_rm,
order, order,
id_visit,
nama_pasien,
tanggal_start,
tanggal_end,
umur_min,
umur_max,
jenis_kelamin,
gol_darah,
kode_diagnosa,
tindak_lanjut,
}); });
} }

View File

@ -6,6 +6,26 @@ import { CreateLogDto } from '../log/dto/create-log.dto';
@Injectable() @Injectable()
export class RekammedisService { export class RekammedisService {
// Define known values as constants to avoid hardcoding everywhere
private readonly KNOWN_BLOOD_TYPES = ['A', 'B', 'AB', 'O'];
private readonly KNOWN_TINDAK_LANJUT = [
'Dipulangkan untuk Kontrol',
'Dirawat',
'Dirujuk ke RS',
'Konsul Ke Poli Lain',
'Konsultasi Dokter Spesialis',
'Kontrol',
'Kontrol Ulang',
'Masuk Rawat Inap',
'Meninggal Dunia Sebelum Dirawat',
'Meninggal Dunia Setelah Dirawat',
'Pulang',
'Rencana Operasi',
'Rujuk Balik',
'Selesai Pelayanan IGD',
'Selesai Pelayanan Rawat Jalan',
];
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async getAllRekamMedis(params: { async getAllRekamMedis(params: {
@ -15,8 +35,36 @@ export class RekammedisService {
orderBy?: any; orderBy?: any;
no_rm?: string; no_rm?: string;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
id_visit?: string;
nama_pasien?: string;
tanggal_start?: string;
tanggal_end?: string;
umur_min?: string;
umur_max?: string;
jenis_kelamin?: string;
gol_darah?: string;
kode_diagnosa?: string;
tindak_lanjut?: string;
}) { }) {
const { skip, page, orderBy, order, no_rm } = params; const {
skip,
page,
orderBy,
order,
no_rm,
id_visit,
nama_pasien,
tanggal_start,
tanggal_end,
umur_min,
umur_max,
jenis_kelamin,
kode_diagnosa,
} = params;
const golDarahArray = params.gol_darah?.split(',') || [];
const tindakLanjutArray = params.tindak_lanjut?.split(',') || [];
console.log('Params Received:', params);
const take = params.take ? parseInt(params.take.toString()) : 10; const take = params.take ? parseInt(params.take.toString()) : 10;
const skipValue = skip const skipValue = skip
? parseInt(skip.toString()) ? parseInt(skip.toString())
@ -24,27 +72,140 @@ export class RekammedisService {
? (parseInt(page.toString()) - 1) * take ? (parseInt(page.toString()) - 1) * take
: 0; : 0;
const buildMultiSelectFilter = (
fieldName: string,
selectedValues: string[],
knownValues: string[],
unknownLabel: string,
includesDash: boolean = false,
) => {
if (selectedValues.length === 0) {
return undefined;
}
const hasKnownValues = selectedValues.some((val) =>
knownValues.includes(val),
);
const hasUnknown = selectedValues.includes(unknownLabel);
const totalOptions = knownValues.length + 1;
if (selectedValues.length === totalOptions) {
return undefined;
}
if (hasUnknown && !hasKnownValues) {
const conditions: any[] = [{ [fieldName]: { equals: null } }];
if (includesDash) {
conditions.push({ [fieldName]: { equals: '-' } });
conditions.push({ [fieldName]: { notIn: knownValues } });
}
return conditions.length > 1 ? { OR: conditions } : conditions[0];
}
if (hasKnownValues && hasUnknown) {
const knownSelected = selectedValues.filter(
(val) => val !== unknownLabel,
);
const conditions: any[] = [
{ [fieldName]: { in: knownSelected } },
{ [fieldName]: { equals: null } },
];
if (includesDash) {
conditions.push({ [fieldName]: { equals: '-' } });
conditions.push({ [fieldName]: { notIn: knownValues } });
}
return { OR: conditions };
}
return { [fieldName]: { in: selectedValues } };
};
const golDarahFilter = buildMultiSelectFilter(
'gol_darah',
golDarahArray,
this.KNOWN_BLOOD_TYPES,
'Tidak Tahu',
true,
);
const tindakLanjutFilter = buildMultiSelectFilter(
'tindak_lanjut',
tindakLanjutArray,
this.KNOWN_TINDAK_LANJUT,
'Belum Ada Keterangan',
false,
);
const whereClause = {
no_rm: no_rm ? { startsWith: no_rm } : undefined,
id_visit: id_visit ? { contains: id_visit } : undefined,
nama_pasien: nama_pasien ? { contains: nama_pasien } : undefined,
waktu_visit:
tanggal_start && tanggal_end
? {
gte: new Date(tanggal_start),
lte: new Date(tanggal_end),
}
: undefined,
umur:
umur_min && umur_max
? {
gte: parseInt(umur_min, 10),
lte: parseInt(umur_max, 10),
}
: undefined,
jenis_kelamin: jenis_kelamin ? { equals: jenis_kelamin } : undefined,
kode_diagnosa: kode_diagnosa ? { contains: kode_diagnosa } : undefined,
...golDarahFilter,
...tindakLanjutFilter,
};
const results = await this.prisma.rekam_medis.findMany({ const results = await this.prisma.rekam_medis.findMany({
skip: skipValue, skip: skipValue,
take: take, take: take,
where: { where: whereClause,
no_rm: no_rm ? no_rm : undefined,
},
orderBy: orderBy orderBy: orderBy
? { [orderBy]: order || 'asc' } ? { [orderBy]: order || 'asc' }
: { waktu_visit: order ? order : 'asc' }, : { waktu_visit: order ? order : 'asc' },
}); });
const count = await this.prisma.rekam_medis.count({ const count = await this.prisma.rekam_medis.count({
where: { where: whereClause,
no_rm: no_rm ? { contains: no_rm } : undefined, });
const umurMin = await this.prisma.rekam_medis.findMany({
distinct: ['umur'],
orderBy: {
umur: 'asc',
},
select: {
umur: true,
}, },
}); });
// console.log('Fetched Rekam Medis:', count); const umurMax = await this.prisma.rekam_medis.findMany({
distinct: ['umur'],
orderBy: {
umur: 'desc',
},
select: {
umur: true,
},
});
const rangeUmur = {
min: umurMin.length > 0 ? umurMin[0].umur : null,
max: umurMax.length > 0 ? umurMax[0].umur : null,
};
return { return {
...results, ...results,
totalCount: count, totalCount: count,
rangeUmur: rangeUmur,
}; };
} }

View File

@ -10,7 +10,9 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"cally": "^0.8.0",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"nouislider": "^15.8.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "4", "vue-router": "4",
@ -638,6 +640,12 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/atomico": {
"version": "1.79.2",
"resolved": "https://registry.npmjs.org/atomico/-/atomico-1.79.2.tgz",
"integrity": "sha512-mshhLRMeIltNYbnQnqgnrvJ/uDa8XDfTQcjw3ymOygQqwHIQ4Sp0LcNYMCbACkV3DtV+eDXb9szwU4qMUuGwYQ==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -729,6 +737,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/cally": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/cally/-/cally-0.8.0.tgz",
"integrity": "sha512-jvQ2QMrsZM/ZPG/LWTkJEUPrp/ew1uS2KjKA/E6ru7mVvTMY2JgSagci9IghLmuamFh1pDajrxXAX4Qgo4FHbw==",
"license": "MIT",
"dependencies": {
"atomico": "^1.76.1"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001751", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
@ -1005,6 +1022,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/nouislider": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.8.1.tgz",
"integrity": "sha512-93TweAi8kqntHJSPiSWQ1o/uZ29VWOmal9YKb6KKGGlCkugaNfAupT7o1qTHqdJvNQ7S0su5rO6qRFCjP8fxtw==",
"license": "MIT"
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,

View File

@ -11,7 +11,9 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"cally": "^0.8.0",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"nouislider": "^15.8.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "4", "vue-router": "4",

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
interface Props {
modelValue: string[];
label?: string;
options: Array<{ label: string; value: string }>;
name?: string;
}
interface Emits {
(e: "update:modelValue", value: string[]): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const handleChange = (value: string, checked: boolean) => {
const currentValue = [...(props.modelValue || [])];
if (checked && !currentValue.includes(value)) {
currentValue.push(value);
} else if (!checked) {
const index = currentValue.indexOf(value);
if (index > -1) {
currentValue.splice(index, 1);
}
}
emit("update:modelValue", currentValue);
};
const isChecked = (value: string) => {
return props.modelValue?.includes(value) || false;
};
</script>
<template>
<div class="w-full">
<label v-if="label" class="text-sm font-medium text-dark mb-2 block">
{{ label }}
</label>
<div class="flex flex-wrap gap-4">
<label
v-for="option in options"
:key="option.value"
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
>
<input
type="checkbox"
:name="name"
:value="option.value"
:checked="isChecked(option.value)"
@change="(e) => handleChange(option.value, (e.target as HTMLInputElement).checked)"
class="checkbox checkbox-sm checkbox-primary"
/>
<span class="text-sm text-dark">{{ option.label }}</span>
</label>
</div>
</div>
</template>
<style scoped>
/* Ensure DaisyUI checkbox styling is applied */
:deep(.checkbox) {
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,218 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
// Import Cally web component
import "cally";
// Declare custom element for TypeScript
declare global {
namespace JSX {
interface IntrinsicElements {
"calendar-range": any;
"calendar-month": any;
}
}
}
interface Props {
modelValue: { start: string; end: string };
label?: string;
max?: string;
}
interface Emits {
(e: "update:modelValue", value: { start: string; end: string }): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const calendarRef = ref<HTMLElement | null>(null);
onMounted(() => {
if (calendarRef.value) {
calendarRef.value.addEventListener("change", (e: Event) => {
const target = e.target as any;
const start = target.startValue || props.modelValue.start;
const end = target.endValue || props.modelValue.end;
emit("update:modelValue", { start, end });
});
}
});
</script>
<template>
<div class="w-full">
<label v-if="label" class="text-sm font-medium text-dark mb-2 block">
{{ label }}
</label>
<calendar-range
ref="calendarRef"
:value="
modelValue.start && modelValue.end
? `${modelValue.start} ${modelValue.end}`
: ''
"
:max="max"
class="calendar-custom"
>
<!-- Navigation icons -->
<svg
aria-label="Previous"
class="fill-current size-4"
slot="previous"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M15.75 19.5 8.25 12l7.5-7.5"></path>
</svg>
<svg
aria-label="Next"
class="fill-current size-4"
slot="next"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
</svg>
<calendar-month></calendar-month>
</calendar-range>
</div>
</template>
<style scoped>
/* Calendar button styling */
.calendar-custom::part(button) {
border: 1px solid #ed7979;
background-color: #ed7979;
border-radius: 8px;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.15s;
}
.calendar-custom::part(button):hover {
border: 1px solid #b34242;
background-color: #b34242;
}
.calendar-custom::part(button):focus-visible {
outline: 2px solid #1a2a4f;
outline-offset: 2px;
}
/* Dialog styling */
.calendar-custom::part(dialog) {
border: 1px solid #d1d5db;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
padding: 1rem;
background: white;
}
/* Calendar base */
.calendar-custom::part(calendar) {
--color-accent: #1a2a4f;
--color-text-on-accent: #fff2ef;
border-radius: 0.375rem;
}
/* Second calendar (end date) */
.calendar-custom::part(calendar):nth-of-type(2) {
--color-accent: #ed7979;
}
/* Month container */
.calendar-custom::part(month) {
background: white;
border-radius: 0.375rem;
padding: 0.5rem;
}
/* Header styling */
.calendar-custom::part(header) {
padding: 0.5rem;
font-weight: 600;
color: #1a2a4f;
}
/* Navigation buttons */
.calendar-custom::part(prev-button),
.calendar-custom::part(next-button) {
color: #1a2a4f;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background 0.15s;
}
.calendar-custom::part(prev-button):hover,
.calendar-custom::part(next-button):hover {
background: #f3f4f6;
}
/* Day cells */
.calendar-custom::part(day) {
border-radius: 0.25rem;
transition: all 0.15s;
}
.calendar-custom::part(day):hover {
background: #f3f4f6;
}
/* Today styling */
.calendar-custom::part(today) {
background-color: #ed7979;
font-weight: 600;
color: white;
}
/* Selected date */
.calendar-custom::part(selected) {
background-color: var(--color-accent);
color: var(--color-text-on-accent);
}
/* Range styling for date ranges */
.calendar-custom::part(range-inner) {
background-color: var(--color-accent);
color: var(--color-text-on-accent);
border-radius: 0;
}
.calendar-custom::part(range-start) {
border-start-start-radius: 8px;
border-end-start-radius: 8px;
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.calendar-custom::part(range-end) {
border-start-end-radius: 8px;
border-end-end-radius: 8px;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.calendar-custom::part(range-start range-end) {
border-radius: 8px;
}
/* Disabled dates */
.calendar-custom::part(disabled) {
opacity: 0.3;
cursor: not-allowed;
}
/* Weekday headers */
.calendar-custom::part(weekday) {
color: var(--color-accent);
font-size: 0.875rem;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,119 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import noUiSlider from "nouislider";
import "nouislider/dist/nouislider.css";
interface Props {
modelValue: [number, number];
min?: number;
max?: number;
step?: number;
label?: string;
unit?: string;
}
interface Emits {
(e: "update:modelValue", value: [number, number]): void;
}
const props = withDefaults(defineProps<Props>(), {
min: 0,
max: 100,
step: 1,
unit: "",
});
const emit = defineEmits<Emits>();
const sliderRef = ref<HTMLElement | null>(null);
onMounted(() => {
if (sliderRef.value) {
const slider = noUiSlider.create(sliderRef.value, {
start: props.modelValue,
connect: true,
step: props.step,
range: {
min: props.min,
max: props.max,
},
tooltips: [
{ to: (value: number) => Math.round(value).toString() },
{ to: (value: number) => Math.round(value).toString() },
],
});
slider.on("update", (values: (string | number)[]) => {
const range: [number, number] = [
Math.round(Number(values[0])),
Math.round(Number(values[1])),
];
emit("update:modelValue", range);
});
}
});
onUnmounted(() => {
if (sliderRef.value && (sliderRef.value as any).noUiSlider) {
(sliderRef.value as any).noUiSlider.destroy();
}
});
</script>
<template>
<div class="w-full px-2">
<label v-if="label" class="text-sm font-medium text-dark mb-2 block">
{{ label }}: {{ modelValue[0] }} - {{ modelValue[1] }} {{ unit }}
</label>
<div ref="sliderRef" class="mb-4"></div>
</div>
</template>
<style scoped>
/* noUiSlider customization */
:deep(.noUi-target) {
background: #e5e7eb;
border: none;
box-shadow: none;
height: 8px;
border-radius: 4px;
}
:deep(.noUi-connect) {
background: #1a2a4f;
}
:deep(.noUi-handle) {
width: 20px;
height: 20px;
border-radius: 50%;
background: #1a2a4f;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.15s ease;
}
:deep(.noUi-handle):hover {
background: #ed7979;
transform: scale(1.1);
}
:deep(.noUi-handle):before,
:deep(.noUi-handle):after {
display: none;
}
:deep(.noUi-tooltip) {
background: #1a2a4f;
border: none;
color: #fff;
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
}
:deep(.noUi-horizontal .noUi-handle) {
top: -6px;
right: -10px;
}
</style>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
interface Props {
modelValue: string;
label?: string;
options: Array<{ label: string; value: string }>;
placeholder?: string;
name?: string;
}
interface Emits {
(e: "update:modelValue", value: string): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const handleChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
emit("update:modelValue", target.value);
};
</script>
<template>
<div class="form-control w-full">
<label v-if="label" class="label">
<span class="label-text text-dark text-sm font-medium">{{ label }}</span>
</label>
<select
:value="modelValue"
@change="handleChange"
:name="name"
class="select select-bordered w-full bg-white border-gray-300"
>
<option v-if="placeholder" value="">{{ placeholder }}</option>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</template>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
interface Props {
modelValue: string;
label: string;
placeholder?: string;
name: string;
}
interface Emits {
(e: "update:modelValue", value: string): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const handleInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value;
emit("update:modelValue", value);
};
</script>
<template>
<div class="form-control w-full">
<label v-if="label" class="label">
<span class="label-text text-dark text-sm font-medium">{{ label }}</span>
</label>
<input
:value="modelValue"
@input="handleInput"
type="text"
:name="name"
:placeholder="placeholder"
class="input input-bordered w-full bg-white border-gray-300"
/>
</div>
</template>

View File

@ -0,0 +1,5 @@
export { default as TextInput } from "./TextInput.vue";
export { default as DateRangePicker } from "./DateRangePicker.vue";
export { default as RangeSlider } from "./RangeSlider.vue";
export { default as SelectInput } from "./SelectInput.vue";
export { default as CheckboxGroup } from "./CheckboxGroup.vue";

View File

@ -0,0 +1,11 @@
interface RekamMedis {
id_visit: string;
no_rm: string;
nama_pasien: string;
waktu_visit: Date;
umur: number;
jenis_kelamin: string;
gol_darah: string;
kode_diagnosa: string;
tindak_lanjut: string;
}

View File

@ -1,9 +1,39 @@
import type { RekamMedis } from "./interfaces";
export const ITEMS_PER_PAGE_OPTIONS = [5, 10, 25, 50, 100] as const; export const ITEMS_PER_PAGE_OPTIONS = [5, 10, 25, 50, 100] as const;
export const DEFAULT_PAGE_SIZE = 10; export const DEFAULT_PAGE_SIZE = 10;
export const DEBOUNCE_DELAY = 500; // milliseconds export const DEBOUNCE_DELAY = 500; // milliseconds
export const FILTER = {
TINDAK_LANJUT: {
dipulangkan: "Dipulangkan untuk Kontrol",
dirawat: "Dirawat",
dirujuk: "Dirujuk ke RS",
konsul_poli_lain: "Konsul Ke Poli Lain",
konsul_spesialis: "Konsultasi Dokter Spesialis",
kontrol: "Kontrol",
kontrol_ulang: "Kontrol Ulang",
rawat_inap: "Masuk Rawat Inap",
meninggal_sebelum_dirawat: "Meninggal Dunia Sebelum Dirawat",
meninggal_setelah_dirawat: "Meninggal Dunia Setelah Dirawat",
pulang: "Pulang",
rencana_operasi: "Rencana Operasi",
rujuk_balik: "Rujuk Balik",
selesai_igd: "Selesai Pelayanan IGD",
selesai_rawat_jalan: "Selesai Pelayanan Rawat Jalan",
belum_ada_keterangan: "Belum Ada Keterangan",
},
GOLONGAN_DARAH: {
A: "A",
B: "B",
AB: "AB",
O: "O",
"Tidak Tahu": "Tidak Tahu",
},
};
export const SORT_OPTIONS = { export const SORT_OPTIONS = {
OBAT: { OBAT: {
id: "ID", id: "ID",
@ -17,3 +47,51 @@ export const SORT_OPTIONS = {
umur: "Umur", umur: "Umur",
}, },
} as const; } as const;
export const REKAM_MEDIS_TABLE_COLUMNS = [
{
key: "id_visit" as keyof RekamMedis,
label: "ID Visit",
class: "text-dark",
},
{
key: "no_rm" as keyof RekamMedis,
label: "No RM",
class: "text-dark",
},
{
key: "nama_pasien" as keyof RekamMedis,
label: "Nama Pasien",
class: "text-dark",
},
{
key: "waktu_visit" as keyof RekamMedis,
label: "Waktu Visit",
class: "text-dark",
},
{
key: "umur" as keyof RekamMedis,
label: "Umur",
class: "text-dark",
},
{
key: "jenis_kelamin" as keyof RekamMedis,
label: "Jenis Kelamin",
class: "text-dark",
},
{
key: "gol_darah" as keyof RekamMedis,
label: "Golongan Darah",
class: "text-dark",
},
{
key: "kode_diagnosa" as keyof RekamMedis,
label: "Kode Diagnosa",
class: "text-dark",
},
{
key: "tindak_lanjut" as keyof RekamMedis,
label: "Tindak Lanjut",
class: "text-dark",
},
];

View File

@ -16,23 +16,21 @@ import {
DEBOUNCE_DELAY, DEBOUNCE_DELAY,
ITEMS_PER_PAGE_OPTIONS, ITEMS_PER_PAGE_OPTIONS,
SORT_OPTIONS, SORT_OPTIONS,
FILTER,
REKAM_MEDIS_TABLE_COLUMNS,
} from "../../constants/pagination"; } from "../../constants/pagination";
import "cally";
interface RekamMedis { import noUiSlider from "nouislider";
id_visit: string; import "nouislider/dist/nouislider.css";
no_rm: string; import type { RekamMedis } from "../../constants/interfaces";
nama_pasien: string;
waktu_visit: Date;
umur: number;
jenis_kelamin: string;
gol_darah: string;
kode_diagnosa: string;
tindak_lanjut: string;
}
interface ApiResponse { interface ApiResponse {
data: RekamMedis[]; data: RekamMedis[];
totalCount: number; totalCount: number;
rangeUmur: {
min: number;
max: number;
};
} }
const data = ref<RekamMedis[]>([]); const data = ref<RekamMedis[]>([]);
@ -47,54 +45,29 @@ const pagination = usePagination({
initialPage: Number(route.query.page) || 1, initialPage: Number(route.query.page) || 1,
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE, initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
}); });
const today: string = new Date().toISOString().split("T")[0] || "";
const ageSliderRef = ref<HTMLElement | null>(null);
const ageRange = ref<[number, number]>([0, 100]);
const tableColumns = [ const filter = ref<{
{ id_visit: string | null;
key: "id_visit" as keyof RekamMedis, nama_pasien: string | null;
label: "ID Visit", rentang_tanggal: { start: Date | null; end: Date | null };
class: "text-dark", rentang_umur: [number, number];
}, jenis_kelamin: string | null;
{ kode_diagnosa: string;
key: "no_rm" as keyof RekamMedis, gol_darah: string[];
label: "No RM", tindak_lanjut: string[];
class: "text-dark", }>({
}, id_visit: null,
{ nama_pasien: null,
key: "nama_pasien" as keyof RekamMedis, rentang_tanggal: { start: null, end: null },
label: "Nama Pasien", rentang_umur: [0, 100],
class: "text-dark", jenis_kelamin: "initial",
}, kode_diagnosa: "",
{ gol_darah: [],
key: "waktu_visit" as keyof RekamMedis, tindak_lanjut: [],
label: "Waktu Visit", });
class: "text-dark",
},
{
key: "umur" as keyof RekamMedis,
label: "Umur",
class: "text-dark",
},
{
key: "jenis_kelamin" as keyof RekamMedis,
label: "Jenis Kelamin",
class: "text-dark",
},
{
key: "gol_darah" as keyof RekamMedis,
label: "Golongan Darah",
class: "text-dark",
},
{
key: "kode_diagnosa" as keyof RekamMedis,
label: "Kode Diagnosa",
class: "text-dark",
},
{
key: "tindak_lanjut" as keyof RekamMedis,
label: "Tindak Lanjut",
class: "text-dark",
},
];
const updateQueryParams = () => { const updateQueryParams = () => {
const query: Record<string, string> = { const query: Record<string, string> = {
@ -110,18 +83,126 @@ const updateQueryParams = () => {
query.sortBy = sortBy.value; query.sortBy = sortBy.value;
} }
if (filter.value.id_visit) {
query.id_visit = filter.value.id_visit;
}
if (filter.value.nama_pasien) {
query.nama_pasien = filter.value.nama_pasien;
}
if (filter.value.rentang_tanggal.end && filter.value.rentang_tanggal.start) {
query.tanggal_start = filter.value.rentang_tanggal.start.toString();
query.tanggal_end = filter.value.rentang_tanggal.end.toString();
}
console.log(filter.value.rentang_umur[0], filter.value.rentang_umur[1]);
console.log(ageRange.value[0], ageRange.value[1]);
if (
filter.value.rentang_umur[0] !== ageRange.value[0] &&
filter.value.rentang_umur[1] !== ageRange.value[1]
) {
query.umur_min = filter.value.rentang_umur[0].toString();
query.umur_max = filter.value.rentang_umur[1].toString();
}
if (filter.value.jenis_kelamin && filter.value.jenis_kelamin !== "initial") {
query.jenis_kelamin = filter.value.jenis_kelamin;
}
if (filter.value.gol_darah.length > 0) {
query.gol_darah = filter.value.gol_darah.join(",");
}
if (filter.value.kode_diagnosa) {
query.kode_diagnosa = filter.value.kode_diagnosa;
}
if (filter.value.tindak_lanjut.length > 0) {
query.tindak_lanjut = filter.value.tindak_lanjut.join(",");
}
router.replace({ query }); router.replace({ query });
}; };
const fetchData = async () => { const handleCalendarChange = (date: any) => {
filter.value.rentang_tanggal.start = date.target.value.split("/")[0];
filter.value.rentang_tanggal.end = date.target.value.split("/")[1];
console.log("Range Date:", filter.value.rentang_tanggal);
};
const handleResetGolonganDarah = () => {
filter.value.gol_darah = [];
};
const handleResetTindakLanjut = () => {
filter.value.tindak_lanjut = [];
};
const handleResetFilter = () => {
if (ageSliderRef.value && (ageSliderRef.value as any).noUiSlider) {
(ageSliderRef.value as any).noUiSlider.set([
ageRange.value[0],
ageRange.value[1],
]);
}
filter.value = {
id_visit: null,
nama_pasien: null,
rentang_tanggal: { start: null, end: null },
rentang_umur: [ageRange.value[0], ageRange.value[1]],
jenis_kelamin: "initial",
kode_diagnosa: "",
gol_darah: [],
tindak_lanjut: [],
};
};
const handleApplyFilter = () => {
pagination.reset();
fetchData();
};
const fetchData = async (isFirst?: boolean) => {
try { try {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
take: pagination.pageSize.value.toString(), take: pagination.pageSize.value.toString(),
page: pagination.page.value.toString(), page: pagination.page.value.toString(),
orderBy: sortBy.value, orderBy: sortBy.value,
...(searchRekamMedis.value && { obat: searchRekamMedis.value }), ...(searchRekamMedis.value && { no_rm: searchRekamMedis.value }),
...(filter.value.id_visit && { id_visit: filter.value.id_visit }),
...(filter.value.nama_pasien && {
nama_pasien: filter.value.nama_pasien,
}),
...(filter.value.rentang_tanggal.start &&
filter.value.rentang_tanggal.end && {
tanggal_start: filter.value.rentang_tanggal.start.toString(),
tanggal_end: filter.value.rentang_tanggal.end.toString(),
}),
...(filter.value.rentang_umur &&
!isFirst && {
umur_min: filter.value.rentang_umur[0].toString(),
umur_max: filter.value.rentang_umur[1].toString(),
}),
...(filter.value.jenis_kelamin &&
filter.value.jenis_kelamin !== "initial" && {
jenis_kelamin: filter.value.jenis_kelamin,
}),
...(filter.value.kode_diagnosa && {
kode_diagnosa: filter.value.kode_diagnosa,
}),
...(filter.value.gol_darah.length > 0 && {
gol_darah: filter.value.gol_darah.join(","),
}),
...(filter.value.tindak_lanjut.length > 0 && {
tindak_lanjut: filter.value.tindak_lanjut.join(","),
}),
}); });
console.log("Fetching with params:", queryParams.toString());
const result = await api.get<ApiResponse>( const result = await api.get<ApiResponse>(
`/rekammedis?${queryParams.toString()}` `/rekammedis?${queryParams.toString()}`
); );
@ -129,9 +210,12 @@ const fetchData = async () => {
if ("data" in result && Array.isArray(result.data)) { if ("data" in result && Array.isArray(result.data)) {
data.value = result.data; data.value = result.data;
pagination.totalCount.value = result.totalCount; pagination.totalCount.value = result.totalCount;
ageRange.value = [result.rangeUmur.min, result.rangeUmur.max];
} else { } else {
const apiResponse = result as any; const apiResponse = result as any;
pagination.totalCount.value = apiResponse.totalCount; pagination.totalCount.value = apiResponse.totalCount;
ageRange.value = [apiResponse.rangeUmur.min, apiResponse.rangeUmur.max];
console.log("API Response:", ageRange.value);
const dataArray: RekamMedis[] = []; const dataArray: RekamMedis[] = [];
Object.keys(apiResponse).forEach((key) => { Object.keys(apiResponse).forEach((key) => {
@ -143,12 +227,14 @@ const fetchData = async () => {
} }
}); });
// console.log("Fetched data array:", dataArray); // console.log("Fetched data array:", dataArray);
data.value = dataArray;
}
data.value = dataArray;
if (!isFirst) {
updateQueryParams(); updateQueryParams();
}
}
} catch (error) { } catch (error) {
console.error("Error fetching obat data:", error); console.error("Error fetching rekam medis data:", error);
data.value = []; data.value = [];
} }
}; };
@ -162,6 +248,8 @@ const handleSearch = () => {
const handleSortChange = (newSortBy: string) => { const handleSortChange = (newSortBy: string) => {
sortBy.value = newSortBy; sortBy.value = newSortBy;
const element = document.activeElement;
if (element && element instanceof HTMLElement) element.blur();
pagination.reset(); pagination.reset();
fetchData(); fetchData();
}; };
@ -212,8 +300,35 @@ onMounted(async () => {
sortBy.value = route.query.sortBy as string; sortBy.value = route.query.sortBy as string;
} }
await fetchData(); await fetchData(true);
filter.value.rentang_umur = [ageRange.value[0], ageRange.value[1]];
document.title = "RekamMedis - Hospital Log"; document.title = "RekamMedis - Hospital Log";
// Initialize noUiSlider
if (ageSliderRef.value) {
const slider = noUiSlider.create(ageSliderRef.value, {
start: [filter.value.rentang_umur[0], filter.value.rentang_umur[1]],
connect: true,
step: 1,
range: {
min: filter.value.rentang_umur[0],
max: filter.value.rentang_umur[1],
},
animate: true,
animationDuration: 300,
tooltips: [
{ to: (value: number) => Math.round(value).toString() },
{ to: (value: number) => Math.round(value).toString() },
],
});
slider.on("update", (values: (string | number)[]) => {
filter.value.rentang_umur = [
Math.round(Number(values[0])),
Math.round(Number(values[1])),
];
});
}
}); });
</script> </script>
@ -222,8 +337,207 @@ onMounted(async () => {
<div class="flex h-full p-2"> <div class="flex h-full p-2">
<Sidebar> <Sidebar>
<PageHeader title="Rekam Medis" subtitle="Manajemen Rekam Medis" /> <PageHeader title="Rekam Medis" subtitle="Manajemen Rekam Medis" />
<div
class="collapse collapse-arrow bg-white border-white border shadow-sm mb-2"
>
<input type="checkbox" />
<div
class="collapse-title font-semibold after:start-5 after:end-auto pe-4 ps-12"
>
Filter
</div>
<div class="collapse-content text-sm flex flex-col">
<div class="flex gap-x-4 items-end">
<div>
<label for="id_visit" class="font-bold">ID Visit</label>
<input
class="mt-1 border border-gray-200 bg-gray-50 px-3 py-2 rounded-md shadow-sm focus:border-gray-300 focus:bg-gray-100 focus:outline-0 inset-shadow-gray-300 focus:inset-shadow-xs/80"
type="text"
name="id_visit"
id="id_visit"
placeholder="Masukkan ID Visit"
v-model="filter.id_visit"
/>
</div>
<div>
<label for="nama_pasien" class="font-bold">Nama Pasien</label>
<input
class="mt-1 border border-gray-200 bg-gray-50 px-3 py-2 rounded-md shadow-sm focus:border-gray-300 focus:bg-gray-100 focus:outline-0 inset-shadow-gray-300 focus:inset-shadow-xs/80"
type="text"
name="nama_pasien"
id="nama_pasien"
placeholder="Masukkan Nama Pasien"
v-model="filter.nama_pasien"
/>
</div>
<div class="bg-white rounded-xl shadow-md"> <div class="w-6/12">
<label for="range_tanggal" class="font-bold"
>Rentang Tanggal</label
>
<button
popoverTarget="cally-popover1"
:class="[
'btn border w-full mt-1 text-light bg-dark border-dark',
filter.rentang_tanggal.start && filter.rentang_tanggal.end
? 'inset-shadow-sm inset-shadow-black/70'
: '',
]"
id="cally1"
style="anchor-name: --cally1"
>
{{
filter.rentang_tanggal.start && filter.rentang_tanggal.end
? `${new Date(
filter.rentang_tanggal.start
).toLocaleDateString("id-ID")} - ${new Date(
filter.rentang_tanggal.end
).toLocaleDateString("id-ID")}`
: "Pilih rentang tanggal"
}}
</button>
</div>
<div
popover
id="cally-popover1"
class="dropdown bg-light rounded-box shadow-xl"
style="position-anchor: --cally1"
>
<calendar-range
:max="today"
locale="id-ID"
class="cally"
@change="handleCalendarChange"
>
<svg
aria-label="Previous"
class="fill-current size-4"
slot="previous"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M15.75 19.5 8.25 12l7.5-7.5"></path>
</svg>
<svg
aria-label="Next"
class="fill-current size-4"
slot="next"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
</svg>
<calendar-month></calendar-month>
</calendar-range>
</div>
<div class="w-full px-2 h-full">
<label class="font-bold block mb-4"
>Rentang Umur: {{ filter.rentang_umur[0] }} -
{{ filter.rentang_umur[1] }} tahun</label
>
<div ref="ageSliderRef" class="mb-4"></div>
</div>
</div>
<div class="flex gap-x-4 items-end mt-4">
<div class="h-full">
<label for="jenis_kelamin" class="font-bold"
>Jenis Kelamin</label
>
<select
v-model="filter.jenis_kelamin"
class="select bg-white border border-gray-300 mt-1"
>
<option disabled selected value="initial">
Pilih Jenis Kelamin
</option>
<option value="laki-laki">Laki-laki</option>
<option value="perempuan">Perempuan</option>
<option value="semua">Semua</option>
</select>
</div>
<div class="h-full">
<label for="golongan_darah" class="font-bold"
>Golongan Darah</label
>
<form
class="mt-1 flex flex-wrap gap-1 justify-center items-center"
>
<input
v-for="(value, index) in FILTER.GOLONGAN_DARAH"
:key="index"
v-model="filter.gol_darah"
class="btn btn-sm bg-light text-dark checked:bg-dark checked:border-dark checked:inset-shadow-sm checked:inset-shadow-black/70 checked:text-light"
type="checkbox"
name="golongan_darah"
:aria-label="value"
:value="value"
/>
<input
@click="handleResetGolonganDarah"
id="reset-golongan-darah"
class="btn btn-sm bg-dark"
type="reset"
value="×"
/>
</form>
</div>
<div class="flex flex-col">
<label for="kode_diagnosa" class="font-bold"
>Kode Diagnosa</label
>
<input
class="mt-1 border border-gray-200 bg-gray-50 px-3 py-2 rounded-md shadow-sm focus:border-gray-300 focus:bg-gray-100 focus:outline-0 inset-shadow-gray-300 focus:inset-shadow-xs/80"
type="text"
name="kode_diagnosa"
id="kode_diagnosa"
placeholder="Masukkan Kode Diagnosa"
v-model="filter.kode_diagnosa"
/>
</div>
</div>
<fieldset class="fieldset mt-4">
<label for="tindak_lanjut" class="font-bold text-sm"
>Tindak Lanjut</label
>
<form class="flex flex-wrap gap-2 items-center">
<input
v-for="(value, index) in FILTER.TINDAK_LANJUT"
:key="index"
v-model="filter.tindak_lanjut"
class="btn btn-xs bg-light text-dark checked:bg-dark checked:border-dark checked:inset-shadow-sm checked:inset-shadow-black/70 checked:text-light"
type="checkbox"
name="tindak_lanjut"
:aria-label="value"
:value="value"
/>
<input
@click="handleResetTindakLanjut"
id="reset-tindak-lanjut"
class="btn btn-xs bg-dark btn-square"
type="reset"
value="×"
/>
</form>
</fieldset>
<div class="divider divider-neutral"></div>
<div class="flex justify-end">
<button
@click="handleResetFilter"
class="btn btn-sm btn-outline btn-dark mr-2 hover:bg-dark hover:text-light active:inset-shadow-sm active:inset-shadow-black/50"
>
Reset Filter
</button>
<button
@click="handleApplyFilter"
class="btn btn-sm bg-dark hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
>
Terapkan
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm">
<div class="flex items-center px-4 py-4 justify-between gap-4"> <div class="flex items-center px-4 py-4 justify-between gap-4">
<SortDropdown <SortDropdown
v-model="sortBy" v-model="sortBy"
@ -242,7 +556,7 @@ onMounted(async () => {
<!-- Data Table --> <!-- Data Table -->
<DataTable <DataTable
:data="data" :data="data"
:columns="tableColumns" :columns="REKAM_MEDIS_TABLE_COLUMNS"
:is-loading="api.isLoading.value" :is-loading="api.isLoading.value"
empty-message="Tidak ada data rekam medis" empty-message="Tidak ada data rekam medis"
@details="handleDetails" @details="handleDetails"
@ -274,4 +588,152 @@ onMounted(async () => {
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
#cally1 {
cursor: pointer;
}
/* noUiSlider customization */
:deep(.noUi-target) {
background: #e5e7eb;
border: none;
box-shadow: none;
height: 8px;
border-radius: 4px;
}
:deep(.noUi-connect) {
background: #1a2a4f;
}
:deep(.noUi-handle) {
width: 20px;
height: 20px;
border-radius: 50%;
background: #1a2a4f;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.15s ease;
}
:deep(.noUi-handle):hover {
background: #ed7979;
transform: scale(1.1);
.noUi-tooltip {
display: block;
transition: all 0.15s ease;
}
}
:deep(.noUi-handle):before,
:deep(.noUi-handle):after {
display: none;
}
:deep(.noUi-tooltip) {
background: #1a2a4f;
border: none;
color: #fff;
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
display: none;
}
:deep(.noUi-active .noUi-tooltip) {
display: block;
}
:deep(.noUi-horizontal .noUi-handle) {
top: -6px;
right: -10px;
}
calendar-range {
&::part(button) {
border: 1px solid #ed7979;
background-color: #ed7979;
border-radius: 8px;
color: #1a2a4f;
}
&::part(button):hover {
transition: all 0.15s;
border: 1px solid #b34242;
background-color: #b34242;
}
&::part(button):focus-visible {
outline: 2px solid #1a2a4f;
outline-offset: 2px;
}
}
calendar-month {
--color-accent: #1a2a4f;
--color-text-on-accent: #fff2ef;
--color-bg-today: #ed7979;
--color-text-today: #1a2a4f;
/* Day buttons */
&::part(button) {
border-radius: 8px;
color: var(--color-accent);
}
&::part(button):hover {
transition: all 0.15s;
border: 1px solid #b34242;
background-color: #b34242;
}
/* Today button */
&::part(today) {
background-color: var(--color-bg-today);
font-weight: 600;
}
/* Selected date */
&::part(selected) {
background-color: var(--color-accent);
color: var(--color-text-on-accent);
}
/* Range styling for date ranges */
&::part(range-inner) {
background-color: var(--color-accent);
color: var(--color-text-on-accent);
border-radius: 0;
}
&::part(range-start) {
border-start-start-radius: 8px;
border-end-start-radius: 8px;
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&::part(range-end) {
border-start-end-radius: 8px;
border-end-end-radius: 8px;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
&::part(range-start range-end) {
border-radius: 8px;
}
&::part(disabled) {
opacity: 0.3;
cursor: not-allowed;
}
&::part(weekday) {
color: var(--color-accent);
font-size: 0.875rem;
font-weight: 500;
}
}
</style>

View File

@ -5,7 +5,16 @@ import { fileURLToPath, URL } from "node:url";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(), tailwindcss()], plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith("calendar-"),
},
},
}),
tailwindcss(),
],
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),