first commit

This commit is contained in:
Yusron alamsyah
2026-03-13 10:45:28 +07:00
commit 6bb6a1d430
568 changed files with 51753 additions and 0 deletions
@@ -0,0 +1,257 @@
<template>
<div class="odontogram-container">
<AppsMedicalOdontogramToolbar
:currentMode="mode"
@modeChange="setMode"
@clearAll="clearAll"
/>
<canvas
ref="canvas"
:width="width"
:height="height"
class="odontogram-canvas"
@mousemove="onMouseMove"
@click="onMouseClick"
>
Browser anda tidak support canvas, silahkan update browser anda.
</canvas>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch, defineEmits } from "vue";
import {
useOdontogram,
convertGeom
} from "~/composables/apps/medical/useOdontogram";
import { useToothRenderer } from "~/composables/apps/medical/useToothRenderer";
import { useOdontogramStore } from "~/store/apps/medical/odontogram";
const emit = defineEmits(["update:geometry"]);
const canvas = ref<HTMLCanvasElement | null>(null);
const width = 1500;
const height = 675;
const store = useOdontogramStore();
const {
odontogramInstance,
mode,
initialize,
setMode,
onMouseMove,
onMouseClick,
geometry,
clearAll
} = useOdontogram();
const { renderCondition } = useToothRenderer();
function initializeGeometryFromConditions() {
if (odontogramInstance.value && store.conditions.length > 0) {
const newGeometry: Record<string, any[]> = {};
const teeth = odontogramInstance.value.teeth;
// Helper to get polygons for a given surface on a tooth
function getPolygonsForSurface(teeth: any, surfaceKey: string) {
const polygons = [];
for (const key in teeth) {
if (key === surfaceKey) {
const polygonOpt = {
fillStyle: "rgba(55, 55, 55, 0.2)"
};
const vertices = [];
for (const vertexKey in teeth[key]) {
vertices.push(teeth[key][vertexKey]);
}
const pol = new Polygon(vertices, polygonOpt);
pol.name = key;
polygons.push(pol);
}
}
return polygons;
}
// Build a map from tooth.num to toothKey for quick lookup
const toothNumToKeyMap = new Map<string, string>();
Object.keys(teeth).forEach((key) => {
const tooth = teeth[key];
if (tooth && tooth.num) {
toothNumToKeyMap.set(tooth.num, key);
}
});
for (const condition of store.conditions) {
if (!teeth) continue;
const toothKey = toothNumToKeyMap.get(String(condition.toothNumber));
if (!toothKey) {
console.warn(
`No tooth key found for toothNumber: ${condition.toothNumber}`
);
continue;
}
// Map short surface codes to full property names in teeth object
const surfaceMap: Record<string, string> = {
T: "top",
R: "right",
B: "bottom",
L: "left",
M: "middle"
};
let surfaceKey = "middle";
if (
condition.surface &&
["T", "R", "B", "L", "M"].includes(condition.surface)
) {
surfaceKey = surfaceMap[condition.surface];
} else if (condition.position && typeof condition.position === "string") {
// Try to infer surface from position string (pos)
const parts = condition.position.split("-");
if (parts.length > 1) {
const surf = parts[1].toUpperCase();
if (["T", "R", "B", "L", "M"].includes(surf)) {
surfaceKey = surfaceMap[surf];
}
}
}
// For AMF, COF, FIS, CARIES modes, use polygons for surface
if (
condition.mode === 1 || // AMF
condition.mode === 2 || // COF
condition.mode === 3 || // FIS
condition.mode === 10 // CARIES
) {
const polygons = getPolygonsForSurface(teeth, surfaceKey);
if (polygons.length > 0) {
if (!newGeometry[toothKey]) {
newGeometry[toothKey] = [];
}
for (const pol of polygons) {
pol.pos =
condition.toothNumber + "-" + pol.name.charAt(0).toUpperCase();
const geom = convertGeom(pol, condition.mode);
newGeometry[toothKey].push(geom);
}
continue;
}
}
// Fallback: use tooth rectangle vertices and pos as tooth number
const coord = odontogramInstance.value.teeth[toothKey];
if (!newGeometry[toothKey]) {
newGeometry[toothKey] = [];
}
if (coord) {
// Append start/finish suffix to pos for bridge mode
let posValue = condition.toothNumber;
if (
condition.mode === 18 &&
condition.position &&
typeof condition.position === "string"
) {
if (condition.position.toLowerCase().includes("start")) {
posValue = condition.toothNumber + "-start";
} else if (condition.position.toLowerCase().includes("finish")) {
posValue = condition.toothNumber + "-finish";
}
}
const geom = convertGeom(
{
vertices: [
{ x: coord.x1, y: coord.y1 },
{ x: coord.x2, y: coord.y2 }
],
pos: posValue
},
condition.mode
);
newGeometry[toothKey].push(geom);
}
}
odontogramInstance.value.geometry = newGeometry;
odontogramInstance.value.redraw();
console.log("Geometry initialized and canvas redrawn.");
} else {
console.log("No conditions found or odontogram instance missing.");
}
console.log("Odontogram instance initialized:", odontogramInstance.value);
}
onMounted(() => {
if (canvas.value) {
console.log(
"Initializing odontogram canvas with width:",
width,
"height:",
height
);
initialize(canvas.value, width, height);
// Load data synchronously
store.loadStoredData();
console.log("Data loaded from store:", store.conditions);
// Set mode from store or default
// if (store.currentMode) {
// setMode(store.currentMode);
// } else {
// setMode(mode.value);
// }
// Initialize geometry from conditions
// initializeGeometryFromConditions();
} else {
console.warn("Canvas ref is null on mounted");
}
});
// watch(
// () => store.conditions,
// () => {
// initializeGeometryFromConditions();
// }
// );
// Watch store currentMode and update local mode and odontogram instance
watch(
() => store.currentMode,
(newMode) => {
if (newMode !== mode.value) {
mode.value = newMode;
odontogramInstance.value?.setMode(newMode);
}
}
);
// Keep existing watch on local mode to update odontogram instance and store mode
watch(mode, (newMode) => {
console.log("Mode changed to:", newMode);
odontogramInstance.value?.setMode(newMode);
store.setMode(newMode);
});
</script>
<style scoped>
.odontogram-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
overflow-x: auto;
}
.odontogram-canvas {
border: 1px solid #a9a9a9;
margin-top: 15px;
max-width: 100%;
height: auto;
}
@media (max-width: 900px) {
.odontogram-canvas {
max-width: auto;
height: auto;
}
}
</style>
@@ -0,0 +1,170 @@
<template>
<SharedWidgetCard title="Alat Kondisi Gigi" :icon="mdi - tooth">
<v-row>
<v-col cols="12" md="8">
<div class="mode-grid">
<v-btn
v-for="mode in odontogramModes"
:key="mode.value"
:color="props.currentMode === mode.value ? 'primary' : 'default'"
:variant="
props.currentMode === mode.value ? 'elevated' : 'outlined'
"
size="small"
flat
class="mode-btn"
@click="setMode(mode.value)"
>
<v-icon size="small" :color="mode.color">{{ mode.icon }}</v-icon>
{{ mode.label }}
</v-btn>
</div>
</v-col>
<v-col cols="12" md="4">
<v-btn color="error" variant="elevated" block @click="clearAll">
<v-icon left>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</SharedWidgetCard>
</template>
<script setup lang="ts">
import { SharedWidgetCard } from "#components";
import { defineEmits, defineProps } from "vue";
import { OdontogramMode } from "~/types/apps/medical/odontogram";
const props = defineProps({
currentMode: {
type: Number as () => OdontogramMode,
required: true
}
});
const emit = defineEmits(["modeChange", "clearAll"]);
const odontogramModes = [
{
value: OdontogramMode.DEFAULT,
label: "Normal",
icon: "mdi-cursor-default"
},
{
value: OdontogramMode.AMF,
label: "Amalgam",
icon: "mdi-circle",
color: "#222"
},
{
value: OdontogramMode.COF,
label: "Composite",
icon: "mdi-circle",
color: "#29b522"
},
{
value: OdontogramMode.FIS,
label: "Sealant",
icon: "mdi-circle",
color: "#ed3bed"
},
{ value: OdontogramMode.NVT, label: "Non-vital", icon: "mdi-triangle" },
{ value: OdontogramMode.RCT, label: "Saluran Akar", icon: "mdi-triangle" },
{ value: OdontogramMode.NON, label: "Tidak Ada", icon: "mdi-text" },
{ value: OdontogramMode.UNE, label: "Un-Erupted", icon: "mdi-text" },
{ value: OdontogramMode.PRE, label: "Partial-Erupt", icon: "mdi-text" },
{ value: OdontogramMode.ANO, label: "Anomali", icon: "mdi-text" },
{ value: OdontogramMode.CARIES, label: "Caries", icon: "mdi-border-color" },
{ value: OdontogramMode.CFR, label: "Fraktur", icon: "mdi-pound" },
{
value: OdontogramMode.FMC,
label: "Metal Crown",
icon: "mdi-square-outline"
},
{ value: OdontogramMode.POC, label: "Porcelain Crown", icon: "mdi-square" },
{ value: OdontogramMode.RRX, label: "Sisa Akar", icon: "mdi-tilde" },
{ value: OdontogramMode.MIS, label: "Hilang", icon: "mdi-close" },
{ value: OdontogramMode.IPX, label: "Implant", icon: "mdi-text" },
{ value: OdontogramMode.FRM_ACR, label: "Denture", icon: "mdi-text" },
{ value: OdontogramMode.BRIDGE, label: "Bridge", icon: "mdi-bridge" },
{
value: OdontogramMode.ARROW_TOP_LEFT,
label: "Top Left Arrow",
icon: "mdi-arrow-top-left"
},
{
value: OdontogramMode.ARROW_TOP_RIGHT,
label: "Top Right Arrow",
icon: "mdi-arrow-top-right"
},
{
value: OdontogramMode.ARROW_BOTTOM_LEFT,
label: "Bottom Left Arrow",
icon: "mdi-arrow-bottom-left"
},
{
value: OdontogramMode.ARROW_BOTTOM_RIGHT,
label: "Bottom Right Arrow",
icon: "mdi-arrow-bottom-right"
},
{
value: OdontogramMode.ARROW_TOP_TURN_LEFT,
label: "Top Turn Left Arrow",
icon: "mdi-arrow-top-left-bold"
},
{
value: OdontogramMode.ARROW_TOP_TURN_RIGHT,
label: "Top Turn Right Arrow",
icon: "mdi-arrow-top-right-bold"
},
{
value: OdontogramMode.ARROW_BOTTOM_TURN_LEFT,
label: "Bottom Turn Left Arrow",
icon: "mdi-arrow-bottom-left-bold"
},
{
value: OdontogramMode.ARROW_BOTTOM_TURN_RIGHT,
label: "Bottom Turn Right Arrow",
icon: "mdi-arrow-bottom-right-bold"
},
{ value: OdontogramMode.HAPUS, label: "Hapus", icon: "mdi-eraser" }
];
const setMode = (mode: OdontogramMode) => {
emit("modeChange", mode);
};
const clearAll = () => {
if (confirm("Apakah Anda yakin ingin menghapus semua kondisi gigi?")) {
emit("clearAll", true);
}
};
</script>
<style scoped>
.toolbar-card {
margin-bottom: 16px;
}
.mode-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.mode-btn {
text-transform: none;
font-size: 0.75rem;
}
@media (max-width: 600px) {
.mode-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.mode-btn {
font-size: 0.7rem;
}
}
</style>
@@ -0,0 +1,18 @@
<template>
<div class="tooth-element">
<!-- Placeholder for individual tooth element -->
<slot></slot>
</div>
</template>
<script lang="ts" setup>
// This component can be extended to handle individual tooth rendering or interaction
</script>
<style scoped>
.tooth-element {
/* Basic styling */
display: inline-block;
padding: 4px;
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<template>
<v-card class="data-manager">
<v-card-title>
<v-icon left>mdi-database</v-icon>
Manajemen Data
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="store.metadata.patientId"
label="ID Pasien"
prepend-icon="mdi-account"
variant="outlined"
density="compact"
@input="store.saveCurrentData"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="store.metadata.dentist"
label="Dokter Gigi"
prepend-icon="mdi-doctor"
variant="outlined"
density="compact"
@input="store.saveCurrentData"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-btn color="success" variant="elevated" block @click="exportData">
<v-icon left>mdi-download</v-icon>
Simpan Data
</v-btn>
</v-col>
<v-col cols="12" md="4">
<v-file-input
ref="fileInput"
accept=".json"
style="display: none"
@change="handleFileImport"
/>
<v-btn color="info" variant="elevated" block @click="importData">
<v-icon left>mdi-upload</v-icon>
Load Data
</v-btn>
</v-col>
<v-col cols="12" md="4">
<v-btn color="error" variant="elevated" block @click="clearAllData">
<v-icon left>mdi-delete-forever</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="conditions-summary">
<h4>Ringkasan Kondisi Gigi ({{ store.conditions.length }})</h4>
<div class="d-flex flex-wrap align-center gap-3">
<v-chip
v-for="condition in store.conditions"
:key="`${condition.toothNumber}-${condition.surface}`"
size="small"
closable
:link="false"
@click:close="removeCondition(condition)"
:color="getSurfaceColor(condition.surface)"
>
{{ condition.toothNumber
}}{{ condition.surface ? "-" + condition.surface : " " }}
<span v-if="condition.mode === 18">
{{
condition.position &&
condition.position.toLowerCase().includes("start")
? "Start(Bridge)"
: ""
}}
{{
condition.position &&
condition.position.toLowerCase().includes("finish")
? "Finish(Bridge)"
: ""
}}
</span>
<span v-else> ({{ getModeLabel(condition.mode) }}) </span>
<!-- <CircleXIcon class="ml-2" start size="20" /> -->
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import {
MoodSmileIcon,
ChecksIcon,
UserCircleIcon,
CircleXIcon
} from "vue-tabler-icons";
import type { ToothCondition } from "~/types/apps/medical/odontogram";
import { OdontogramMode } from "~/types/apps/medical/odontogram";
import { useOdontogramStore } from "~/store/apps/medical/odontogram";
import { useDataStorage } from "~/composables/apps/medical/useDataStorage";
const store = useOdontogramStore();
const { exportData: exportDataUtil, importData: importDataUtil } =
useDataStorage();
const fileInput = ref();
const modeLabels = {
[OdontogramMode.DEFAULT]: "Normal",
[OdontogramMode.AMF]: "Amalgam",
[OdontogramMode.COF]: "Composite",
[OdontogramMode.FIS]: "Sealant",
[OdontogramMode.NVT]: "Non-vital",
[OdontogramMode.RCT]: "Saluran Akar",
[OdontogramMode.NON]: "Tidak Ada",
[OdontogramMode.UNE]: "Un-Erupted",
[OdontogramMode.PRE]: "Partial-Erupt",
[OdontogramMode.ANO]: "Anomali",
[OdontogramMode.CARIES]: "Caries",
[OdontogramMode.CFR]: "Fraktur",
[OdontogramMode.FMC]: "Metal Crown",
[OdontogramMode.POC]: "Porcelain Crown",
[OdontogramMode.RRX]: "Sisa Akar",
[OdontogramMode.MIS]: "Hilang",
[OdontogramMode.IPX]: "Implant",
[OdontogramMode.FRM_ACR]: "Denture",
[OdontogramMode.BRIDGE]: "Bridge",
[OdontogramMode.ARROW_TOP_LEFT]: "Top Left Arrow",
[OdontogramMode.ARROW_TOP_RIGHT]: "Top Right Arrow",
[OdontogramMode.ARROW_BOTTOM_LEFT]: "Bottom Left Arrow",
[OdontogramMode.ARROW_BOTTOM_RIGHT]: "Bottom Right Arrow",
[OdontogramMode.ARROW_TOP_TURN_LEFT]: "Top Turn Left Arrow",
[OdontogramMode.ARROW_TOP_TURN_RIGHT]: "Top Turn Right Arrow",
[OdontogramMode.ARROW_BOTTOM_TURN_LEFT]: "Bottom Turn Left Arrow",
[OdontogramMode.ARROW_BOTTOM_TURN_RIGHT]: "Bottom Turn Right Arrow",
[OdontogramMode.HAPUS]: "Hapus"
};
const getModeLabel = (mode: OdontogramMode) => {
return modeLabels[mode] || "Unknown";
};
const exportData = () => {
const data = store.exportCurrentData();
exportDataUtil(data);
};
const importData = () => {
fileInput.value?.$el.querySelector("input").click();
};
const handleFileImport = async (event: any) => {
const file = event.target.files?.[0];
if (!file) return;
const data = await importDataUtil(file);
if (data) {
store.importData(data);
// Clear file input
event.target.value = "";
} else {
alert("Error importing data. Please check the file format.");
}
};
const clearAllData = () => {
if (
confirm("Apakah Anda yakin ingin menghapus semua data termasuk metadata?")
) {
store.clearAllConditions();
store.metadata.patientId = "";
store.metadata.dentist = "";
store.metadata.date = new Date().toISOString().split("T")[0];
}
};
const removeCondition = (condition: ToothCondition) => {
store.removeCondition(condition.toothNumber, condition.surface);
};
const getSurfaceColor = (surface: string | undefined) => {
switch (surface) {
case "T":
return "primary"; // Vuetify blue
case "R":
return "success"; // Vuetify green
case "B":
return "error"; // Vuetify red
case "L":
return "info"; // Vuetify yellow
case "M":
return "secondary"; // Custom purple, will add style below
default:
return "secondary";
}
};
</script>
<style scoped>
.data-manager {
margin-bottom: 16px;
}
.conditions-summary {
margin-top: 16px;
}
.conditions-summary h4 {
margin-bottom: 12px;
color: #666;
}
</style>
@@ -0,0 +1,236 @@
<template>
<div>
<!-- Alamat Identitas -->
<v-card-subtitle
class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2"
>
ALAMAT IDENTITAS
</v-card-subtitle>
<v-card-text>
<v-row>
<!-- Desa/Kelurahan -->
<v-col cols="12">
<v-text-field
v-model="localAlamatIdentitas.desa"
label="Desa/Kelurahan"
placeholder="Cari desa atau kelurahan..."
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
/>
</v-col>
<!-- Alamat Lengkap -->
<v-col cols="12">
<v-textarea
v-model="localAlamatIdentitas.alamatLengkap"
label="Alamat Lengkap"
placeholder="Alamat"
variant="outlined"
density="compact"
rows="3"
:rules="[rules.required]"
required
/>
</v-col>
<!-- RT -->
<v-col cols="12" md="4">
<v-text-field
v-model="localAlamatIdentitas.rt"
label="RT"
placeholder="RT"
variant="outlined"
density="compact"
counter="3"
maxlength="3"
/>
</v-col>
<!-- RW -->
<v-col cols="12" md="4">
<v-text-field
v-model="localAlamatIdentitas.rw"
label="RW"
placeholder="RW"
variant="outlined"
density="compact"
counter="3"
maxlength="3"
/>
</v-col>
<!-- Kode Pos -->
<v-col cols="12" md="4">
<v-text-field
v-model="localAlamatIdentitas.kodePos"
label="Kode Pos"
placeholder="Kode Pos"
variant="outlined"
density="compact"
counter="5"
maxlength="5"
/>
</v-col>
</v-row>
</v-card-text>
<!-- Alamat Domisili -->
<v-card-subtitle
class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2"
>
ALAMAT DOMISILI
</v-card-subtitle>
<v-card-text>
<!-- Checkbox Samakan dengan alamat identitas -->
<v-row>
<v-col cols="12">
<v-checkbox
v-model="samaDenganIdentitas"
label="Samakan dengan alamat identitas"
density="compact"
@change="handleSamaDenganIdentitas"
/>
</v-col>
</v-row>
<v-row v-if="!samaDenganIdentitas">
<!-- Desa/Kelurahan -->
<v-col cols="12">
<v-text-field
v-model="localAlamatDomisili.desa"
label="Desa/Kelurahan"
placeholder="Cari desa atau kelurahan..."
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
/>
</v-col>
<!-- Alamat Lengkap -->
<v-col cols="12">
<v-textarea
v-model="localAlamatDomisili.alamatLengkap"
label="Alamat Lengkap"
placeholder="Alamat"
variant="outlined"
density="compact"
rows="3"
/>
</v-col>
<!-- RT -->
<v-col cols="12" md="4">
<v-text-field
v-model="localAlamatDomisili.rt"
label="RT"
placeholder="RT"
variant="outlined"
density="compact"
counter="3"
maxlength="3"
/>
</v-col>
<!-- RW -->
<v-col cols="12" md="4">
<v-text-field
v-model="localAlamatDomisili.rw"
label="RW"
placeholder="RW"
variant="outlined"
density="compact"
counter="3"
maxlength="3"
/>
</v-col>
<!-- Kode Pos -->
<v-col cols="12" md="4">
<v-text-field
v-model="localAlamatDomisili.kodePos"
label="Kode Pos"
placeholder="Kode Pos"
variant="outlined"
density="compact"
counter="5"
maxlength="5"
/>
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
alamatIdentitas: {
type: Object,
required: true
},
alamatDomisili: {
type: Object,
required: true
},
samaDenganIdentitas: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
"update:alamatIdentitas",
"update:alamatDomisili",
"update:samaDenganIdentitas"
]);
const localAlamatIdentitas = ref({ ...props.alamatIdentitas });
const localAlamatDomisili = ref({ ...props.alamatDomisili });
const samaDenganIdentitas = ref(props.samaDenganIdentitas);
const rules = {
required: (v) => !!v || "Field ini wajib diisi"
};
const handleSamaDenganIdentitas = () => {
if (samaDenganIdentitas.value) {
localAlamatDomisili.value = { ...localAlamatIdentitas.value };
} else {
localAlamatDomisili.value = {
desa: "",
alamatLengkap: "",
rt: "",
rw: "",
kodePos: ""
};
}
};
// Watch for changes
watch(
localAlamatIdentitas,
(newVal) => {
emit("update:alamatIdentitas", newVal);
if (samaDenganIdentitas.value) {
localAlamatDomisili.value = { ...newVal };
}
},
{ deep: true }
);
watch(
localAlamatDomisili,
(newVal) => {
emit("update:alamatDomisili", newVal);
},
{ deep: true }
);
watch(samaDenganIdentitas, (newVal) => {
emit("update:samaDenganIdentitas", newVal);
});
</script>
@@ -0,0 +1,516 @@
<template>
<div>
<!-- Section Header -->
<v-card-subtitle
class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2"
>
DATA DIRI
</v-card-subtitle>
<v-card-text>
<!-- Checkbox Bayi Baru Lahir -->
<v-row>
<v-col cols="12" class="text-right py-0">
<v-checkbox
v-model="isBayiBaru"
label="Bayi baru lahir"
density="compact"
hide-details
/>
</v-col>
</v-row>
<v-row>
<!-- Photo Upload -->
<v-col cols="12" md="2" class="text-center">
<v-avatar size="100" color="grey-lighten-3">
<v-icon size="50" color="grey">mdi-account</v-icon>
</v-avatar>
<v-btn
variant="text"
color="primary"
size="small"
class="mt-2"
@click="uploadPhoto"
>
Upload Foto
</v-btn>
</v-col>
<!-- Form Fields -->
<v-col cols="12" md="10">
<v-row>
<!-- Nama Lengkap -->
<v-col cols="12">
<v-text-field
v-model="localData.namaLengkap"
label="Nama Lengkap"
placeholder="Nama Lengkap"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
@input="debouncedParseNameToFhir"
@paste="handlePaste"
/>
<!-- FHIR Name Preview - Auto shows when typing -->
<v-expand-transition>
<v-alert
v-if="fhirName && localData.namaLengkap && isTypingComplete"
density="compact"
type="info"
variant="tonal"
class="mt-2"
closable
@click:close="hideFhirPreview"
>
<template v-slot:title>
<span class="text-body-2">FHIR Name Structure</span>
</template>
<div class="text-caption mt-2">
<v-row dense>
<v-col
v-if="fhirName.prefix && fhirName.prefix.length > 0"
cols="12"
>
<v-chip
size="x-small"
color="primary"
variant="outlined"
class="mr-1"
>
Prefix
</v-chip>
<span class="font-weight-medium">{{
fhirName.prefix.join(", ")
}}</span>
</v-col>
<v-col
v-if="fhirName.given && fhirName.given.length > 0"
cols="12"
>
<v-chip
size="x-small"
color="success"
variant="outlined"
class="mr-1"
>
Given
</v-chip>
<span class="font-weight-medium">{{
fhirName.given.join(" ")
}}</span>
</v-col>
<v-col v-if="fhirName.family" cols="12">
<v-chip
size="x-small"
color="warning"
variant="outlined"
class="mr-1"
>
Family
</v-chip>
<span class="font-weight-medium">{{
fhirName.family
}}</span>
</v-col>
<v-col
v-if="fhirName.suffix && fhirName.suffix.length > 0"
cols="12"
>
<v-chip
size="x-small"
color="info"
variant="outlined"
class="mr-1"
>
Suffix
</v-chip>
<span class="font-weight-medium">{{
fhirName.suffix.join(", ")
}}</span>
</v-col>
</v-row>
</div>
</v-alert>
</v-expand-transition>
<!-- Optional: Inline chips preview -->
<div
v-if="
fhirName &&
localData.namaLengkap &&
isTypingComplete &&
!showDetailedPreview
"
class="mt-2"
>
<v-chip-group>
<v-chip
v-for="(prefix, index) in fhirName.prefix || []"
:key="`prefix-${index}`"
size="x-small"
color="primary"
>
{{ prefix }}
</v-chip>
<v-chip
v-for="(given, index) in fhirName.given || []"
:key="`given-${index}`"
size="x-small"
color="success"
>
{{ given }}
</v-chip>
<v-chip v-if="fhirName.family" size="x-small" color="warning">
{{ fhirName.family }}
</v-chip>
<v-chip
v-for="(suffix, index) in fhirName.suffix || []"
:key="`suffix-${index}`"
size="x-small"
color="info"
>
{{ suffix }}
</v-chip>
</v-chip-group>
</div>
</v-col>
<!-- Nomor Telepon Selular -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.nomorTeleponSelular"
label="Nomor Telepon Selular"
placeholder="Nomor Telepon Selular"
variant="outlined"
density="compact"
:rules="[rules.required, rules.phone]"
required
>
<template v-slot:prepend>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
variant="outlined"
density="compact"
v-bind="props"
class="mr-2"
>
+62
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item value="+62">+62</v-list-item>
</v-list>
</v-menu>
</template>
</v-text-field>
</v-col>
<!-- Nomor Telepon Rumah -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.nomorTeleponRumah"
label="Nomor Telepon Rumah"
placeholder="Nomor Telepon Rumah"
variant="outlined"
density="compact"
>
<template v-slot:prepend>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
variant="outlined"
density="compact"
v-bind="props"
class="mr-2"
>
+62
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item value="+62">+62</v-list-item>
</v-list>
</v-menu>
</template>
</v-text-field>
</v-col>
<!-- Email -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.email"
label="Email"
placeholder="Email"
type="email"
variant="outlined"
density="compact"
:rules="[rules.email]"
/>
</v-col>
<!-- Jenis Kelamin -->
<v-col cols="12" md="6">
<v-select
v-model="localData.jenisKelamin"
label="Jenis Kelamin"
:items="jenisKelaminOptions"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Tempat Lahir -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.tempatLahir"
label="Tempat Lahir"
placeholder="Tempat Lahir"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Tanggal Lahir -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.tanggalLahir"
label="Tanggal Lahir"
placeholder="Tanggal Lahir (DD/MM/YYYY)"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
>
<template v-slot:append-inner>
<v-icon>mdi-calendar</v-icon>
</template>
</v-text-field>
</v-col>
<!-- Jenis Identitas -->
<v-col cols="12" md="6">
<v-select
v-model="localData.jenisIdentitas"
label="Jenis Identitas"
:items="jenisIdentitasOptions"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Nomor Identitas -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.nomorIdentitas"
label="Nomor Identitas"
placeholder="Nomor Identitas"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Nama Ibu Kandung -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.namaIbuKandung"
label="Nama Ibu Kandung"
placeholder="Nama Lengkap Ibu Kandung"
variant="outlined"
density="compact"
/>
</v-col>
<!-- No. Rekam Medis -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.nomorRekamMedis"
label="No. Rekam Medis Lama / Manual"
placeholder="No. Rekam medis"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { debounce } from "lodash-es"; // or use VueUse's useDebounceFn
import {
parseFhirHumanName,
validateFhirHumanName
} from "~/utils/module/fhirNameParser";
import type { FhirHumanName } from "~/types/fhir/humanName";
// Types
interface DataDiriForm {
photo: File | null;
namaLengkap: string;
nomorTeleponSelular: string;
nomorTeleponRumah: string;
email: string;
jenisKelamin: string;
tempatLahir: string;
tanggalLahir: string;
jenisIdentitas: string;
nomorIdentitas: string;
namaIbuKandung: string;
nomorRekamMedis: string;
fhirName?: FhirHumanName | null;
}
interface Props {
formData: DataDiriForm;
isBayiBaru: boolean;
}
// Props & Emits
const props = defineProps<Props>();
const emit = defineEmits<{
"update:formData": [value: DataDiriForm];
"update:isBayiBaru": [value: boolean];
}>();
// Reactive data
const localData = ref<DataDiriForm>({ ...props.formData });
const isBayiBaru = ref(props.isBayiBaru);
const fhirName = ref<FhirHumanName | null>(null);
const isTypingComplete = ref(false);
const showDetailedPreview = ref(true); // Toggle between detailed and chip view
// Rules
const rules = {
required: (v: string) => !!v || "Field ini wajib diisi",
email: (v: string) => !v || /.+@.+\..+/.test(v) || "Format email tidak valid",
phone: (v: string) =>
!v || /^[0-9+\-() ]+$/.test(v) || "Format nomor telepon tidak valid"
};
// Options
interface SelectOption {
title: string;
value: string;
}
const jenisKelaminOptions: SelectOption[] = [
{ title: "Laki-laki", value: "L" },
{ title: "Perempuan", value: "P" }
];
const jenisIdentitasOptions: SelectOption[] = [
{ title: "KTP", value: "KTP" },
{ title: "SIM", value: "SIM" },
{ title: "Paspor", value: "PASSPORT" }
];
// Methods
import { formatFhirName } from "~/utils/module/fhirNameParser";
const parseNameToFhir = (): void => {
if (localData.value.namaLengkap && localData.value.namaLengkap.trim()) {
fhirName.value = parseFhirHumanName(localData.value.namaLengkap);
if (fhirName.value) {
const errors = validateFhirHumanName(fhirName.value);
if (errors.length > 0) {
console.warn("FHIR Name validation errors:", errors);
}
// Store FHIR name in form data
localData.value.fhirName = fhirName.value;
// Format the FHIR name back to a string and update namaLengkap
const formattedName = formatFhirName(fhirName.value);
if (formattedName !== localData.value.namaLengkap) {
localData.value.namaLengkap = formattedName;
}
// Show preview after parsing
isTypingComplete.value = true;
}
} else {
// Clear FHIR name if input is empty
fhirName.value = null;
localData.value.fhirName = null;
isTypingComplete.value = false;
}
};
// Debounced version of parseNameToFhir (wait 500ms after user stops typing)
const debouncedParseNameToFhir = debounce(() => {
parseNameToFhir();
}, 500);
// Handle paste event
const handlePaste = (event: ClipboardEvent): void => {
// Reset typing complete flag
isTypingComplete.value = false;
// Parse after a short delay to ensure v-model is updated
setTimeout(() => {
parseNameToFhir();
}, 100);
};
// Hide FHIR preview
const hideFhirPreview = (): void => {
isTypingComplete.value = false;
};
const uploadPhoto = (): void => {
// Implementation for photo upload
console.log("Upload photo clicked");
};
// Watchers
watch(
localData,
(newVal) => {
emit("update:formData", newVal);
},
{ deep: true }
);
watch(isBayiBaru, (newVal) => {
emit("update:isBayiBaru", newVal);
});
// Reset typing complete when name is cleared
watch(
() => localData.value.namaLengkap,
(newVal) => {
if (!newVal) {
isTypingComplete.value = false;
}
}
);
</script>
<style scoped>
/* Optional: Add smooth transitions */
.v-chip-group {
gap: 4px;
}
</style>
@@ -0,0 +1,100 @@
<template>
<div>
<v-card-subtitle
class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2"
>
KESEHATAN
</v-card-subtitle>
<v-card-text>
<v-row>
<!-- Golongan Darah -->
<v-col cols="12" md="6">
<v-select
v-model="localData.golonganDarah"
label="Golongan Darah"
:items="golonganDarahOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Status Perokok -->
<v-col cols="12" md="6">
<v-select
v-model="localData.statusPerokok"
label="Status Perokok"
:items="statusPerokokOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Jenis Penyakit -->
<v-col cols="12">
<v-select
v-model="localData.jenisPenyakit"
label="Jenis Penyakit"
:items="jenisPenyakitOptions"
variant="outlined"
density="compact"
multiple
chips
closable-chips
placeholder="Pilih Jenis Penyakit"
/>
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true
}
});
const emit = defineEmits(["update:formData"]);
const localData = ref({ ...props.formData });
const golonganDarahOptions = [
{ title: "A", value: "A" },
{ title: "B", value: "B" },
{ title: "AB", value: "AB" },
{ title: "O", value: "O" }
];
const statusPerokokOptions = [
{ title: "Tidak Merokok", value: "tidak" },
{ title: "Perokok Aktif", value: "aktif" },
{ title: "Perokok Pasif", value: "pasif" },
{ title: "Mantan Perokok", value: "mantan" }
];
const jenisPenyakitOptions = [
"Diabetes",
"Hipertensi",
"Jantung",
"Asma",
"TBC",
"Hepatitis",
"HIV/AIDS",
"Kanker",
"Ginjal",
"Stroke"
];
watch(
localData,
(newVal) => {
emit("update:formData", newVal);
},
{ deep: true }
);
</script>
@@ -0,0 +1,53 @@
<template>
<div>
<v-card-subtitle class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2">
PEMBAYARAN
</v-card-subtitle>
<v-card-text>
<v-row>
<v-col cols="12">
<v-select
v-model="localData.jenisPembayaran"
label="Pembayaran"
:items="pembayaranOptions"
variant="outlined"
density="compact"
:rules="[rules.required]"
required
/>
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
formData: {
type: Object,
required: true
}
})
const emit = defineEmits(['update:formData'])
const localData = ref({ ...props.formData })
const rules = {
required: v => !!v || 'Field ini wajib diisi'
}
const pembayaranOptions = [
{ title: 'BPJS', value: 'BPJS' },
{ title: 'Umum', value: 'UMUM' },
{ title: 'Asuransi', value: 'ASURANSI' },
{ title: 'Perusahaan', value: 'PERUSAHAAN' }
]
watch(localData, (newVal) => {
emit('update:formData', newVal)
}, { deep: true })
</script>
@@ -0,0 +1,167 @@
<template>
<div>
<v-card-subtitle
class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2"
>
PENANGGUNG JAWAB / WALI PASIEN
</v-card-subtitle>
<v-card-text>
<v-row>
<!-- Nama Lengkap -->
<v-col cols="12">
<v-text-field
v-model="localData.namaLengkap"
label="Nama Lengkap"
placeholder="Nama Lengkap"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Jenis Kelamin -->
<v-col cols="12" md="6">
<v-select
v-model="localData.jenisKelamin"
label="Jenis Kelamin"
:items="jenisKelaminOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Hubungan dengan Pasien -->
<v-col cols="12" md="6">
<v-select
v-model="localData.hubunganDenganPasien"
label="Hubungan dengan Pasien"
:items="hubunganOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Nomor Telepon -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.nomorTelepon"
label="Nomor Telepon Selular / Rumah"
placeholder="Nomor Telepon"
variant="outlined"
density="compact"
>
<template v-slot:prepend>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
variant="outlined"
density="compact"
v-bind="props"
class="mr-2"
>
+62
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item value="+62">+62</v-list-item>
</v-list>
</v-menu>
</template>
</v-text-field>
</v-col>
<!-- Pekerjaan -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.pekerjaan"
label="Pekerjaan"
placeholder="Pekerjaan"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Alamat -->
<v-col cols="12">
<label class="text-subtitle-2 font-weight-medium">Alamat</label>
<v-checkbox
v-model="samaDenganPasien"
label="Samakan dengan alamat pasien"
density="compact"
@change="handleSamakanAlamat"
/>
</v-col>
<v-col cols="12" v-if="!samaDenganPasien">
<v-textarea
v-model="localData.alamat"
placeholder="Alamat"
variant="outlined"
density="compact"
rows="3"
/>
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true
},
samaDenganPasien: {
type: Boolean,
default: false
},
alamatPasien: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(["update:formData", "update:samaDenganPasien"]);
const localData = ref({ ...props.formData });
const samaDenganPasien = ref(props.samaDenganPasien);
const jenisKelaminOptions = [
{ title: "Laki-laki", value: "L" },
{ title: "Perempuan", value: "P" }
];
const hubunganOptions = [
"Suami",
"Istri",
"Anak",
"Orang Tua",
"Saudara",
"Kerabat",
"Lainnya"
];
const handleSamakanAlamat = () => {
if (samaDenganPasien.value && props.alamatPasien) {
localData.value.alamat = props.alamatPasien.alamatLengkap || "";
} else {
localData.value.alamat = "";
}
};
watch(
localData,
(newVal) => {
emit("update:formData", newVal);
},
{ deep: true }
);
watch(samaDenganPasien, (newVal) => {
emit("update:samaDenganPasien", newVal);
});
</script>
@@ -0,0 +1,145 @@
<template>
<div>
<v-card-subtitle
class="bg-grey-lighten-4 text-subtitle-2 font-weight-medium py-2"
>
SOSIAL
</v-card-subtitle>
<v-card-text>
<v-row>
<!-- Agama -->
<v-col cols="12" md="6">
<v-select
v-model="localData.agama"
label="Agama"
:items="agamaOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Status Pernikahan -->
<v-col cols="12" md="6">
<v-select
v-model="localData.statusPernikahan"
label="Status Pernikahan"
:items="statusPernikahanOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Pendidikan Terakhir -->
<v-col cols="12" md="6">
<v-select
v-model="localData.pendidikanTerakhir"
label="Pendidikan Terakhir"
:items="pendidikanOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Pekerjaan -->
<v-col cols="12" md="6">
<v-select
v-model="localData.pekerjaan"
label="Pekerjaan"
:items="pekerjaanOptions"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Bahasa yang Dikuasai -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.bahasaDikuasai"
label="Bahasa yang Dikuasai"
placeholder="Bahasa yang Dikuasai"
variant="outlined"
density="compact"
/>
</v-col>
<!-- Suku/Etnis -->
<v-col cols="12" md="6">
<v-text-field
v-model="localData.sukuEtnis"
label="Suku/Etnis"
placeholder="Suku/Etnis"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true
}
});
const emit = defineEmits(["update:formData"]);
const localData = ref({ ...props.formData });
const agamaOptions = [
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya"
];
const statusPernikahanOptions = [
{ title: "Belum Menikah", value: "belum_menikah" },
{ title: "Menikah", value: "menikah" },
{ title: "Cerai Hidup", value: "cerai_hidup" },
{ title: "Cerai Mati", value: "cerai_mati" }
];
const pendidikanOptions = [
"Tidak Sekolah",
"SD",
"SMP",
"SMA/SMK",
"D1",
"D3",
"D4/S1",
"S2",
"S3"
];
const pekerjaanOptions = [
"PNS",
"TNI/Polri",
"Pegawai Swasta",
"Wiraswasta",
"Petani",
"Nelayan",
"Buruh",
"Ibu Rumah Tangga",
"Pelajar/Mahasiswa",
"Tidak Bekerja",
"Lainnya"
];
watch(
localData,
(newVal) => {
emit("update:formData", newVal);
},
{ deep: true }
);
</script>
@@ -0,0 +1,227 @@
<template>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6">NAKES & JADWAL</span>
<v-btn variant="text" color="primary" size="small" @click="resetSection">
Atur Ulang
</v-btn>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="localData.paymentMethod"
:items="paymentMethods"
label="Pembiayaan"
variant="outlined"
density="compact"
:rules="[rules.required]"
append-inner-icon="mdi-chevron-down"
>
<template v-slot:label>
Pembiayaan <span class="text-red">*</span>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="localData.referralNumber"
label="Nomor Rujukan (Opsional)"
variant="outlined"
density="compact"
placeholder="Nomor Rujukan"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-select
v-model="localData.polyclinicName"
:items="polyclinics"
label="Nama Poliklinik"
variant="outlined"
density="compact"
placeholder="Pilih"
:rules="[rules.required]"
@update:modelValue="onPolyclinicChange"
append-inner-icon="mdi-chevron-down"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-select
v-model="localData.doctorName"
:items="doctors"
label="Nama Nakes"
variant="outlined"
density="compact"
placeholder="Pilih"
:rules="[rules.required]"
:disabled="!localData.polyclinicName"
@update:modelValue="onDoctorChange"
append-inner-icon="mdi-chevron-down"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="localData.consultationDate"
label="Tanggal Konsultasi"
variant="outlined"
density="compact"
type="date"
:rules="[rules.required]"
:min="minDate"
>
<template v-slot:label>
Tanggal Konsultasi <span class="text-red">*</span>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="localData.consultationTime"
:items="timeSlots"
label="Jam Konsultasi"
variant="outlined"
density="compact"
placeholder="Pilih Jam Konsultasi"
:rules="[rules.required]"
:disabled="!localData.consultationDate || !localData.doctorName"
append-inner-icon="mdi-clock-outline"
>
<template v-slot:label>
Jam Konsultasi <span class="text-red">*</span>
</template>
</v-select>
</v-col>
</v-row>
<v-row v-if="localData.doctorName && localData.consultationDate">
<v-col cols="12">
<v-alert type="info" variant="tonal" density="compact">
Slot Nakes: {{ availableSlots }} slot tersedia
</v-alert>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref, watch, computed } from "vue";
const props = defineProps({
scheduleData: {
type: Object,
required: true
}
});
const emit = defineEmits(["update:scheduleData", "update"]);
const localData = ref({ ...props.scheduleData });
const paymentMethods = ["Pribadi", "BPJS", "Asuransi Swasta", "Perusahaan"];
const polyclinics = [
"Poli Umum",
"Poli Gigi",
"Poli Anak",
"Poli Kandungan",
"Poli Penyakit Dalam",
"Poli THT"
];
const doctors = ref([]);
const timeSlots = ref([]);
const availableSlots = ref(0);
const minDate = computed(() => {
const today = new Date();
return today.toISOString().split("T")[0];
});
const rules = {
required: (value) => !!value || "Field ini wajib diisi"
};
const onPolyclinicChange = (value) => {
// Reset doctor and time when polyclinic changes
localData.value.doctorName = "";
localData.value.consultationTime = "";
// Simulate loading doctors based on polyclinic
if (value) {
doctors.value = [
"dr. Ahmad Subhan, Sp.A",
"dr. Siti Nurhaliza",
"dr. Budi Santoso, Sp.PD",
"dr. Maria Christina"
];
}
};
const onDoctorChange = (value) => {
// Reset time when doctor changes
localData.value.consultationTime = "";
// Simulate loading time slots
if (value && localData.value.consultationDate) {
loadTimeSlots();
}
};
const loadTimeSlots = () => {
// Simulate available time slots
timeSlots.value = [
{ title: "08:00 - 08:30", value: "08:00" },
{ title: "08:30 - 09:00", value: "08:30" },
{ title: "09:00 - 09:30", value: "09:00" },
{ title: "09:30 - 10:00", value: "09:30" },
{ title: "10:00 - 10:30", value: "10:00" },
{ title: "10:30 - 11:00", value: "10:30" }
];
availableSlots.value = 6;
};
const resetSection = () => {
localData.value = {
paymentMethod: "Pribadi",
referralNumber: "",
polyclinicName: "",
doctorName: "",
consultationDate: "",
consultationTime: "",
slotAvailable: true
};
doctors.value = [];
timeSlots.value = [];
};
watch(
localData,
(newVal) => {
emit("update:scheduleData", newVal);
emit("update");
},
{ deep: true }
);
watch(
() => localData.value.consultationDate,
(newVal) => {
if (newVal && localData.value.doctorName) {
loadTimeSlots();
}
}
);
</script>
@@ -0,0 +1,249 @@
<template>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6">DATA PASIEN</span>
<v-btn variant="text" color="primary" size="small" @click="resetSection">
Atur Ulang
</v-btn>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-text-field
v-model="localData.name"
label="Nama Pasien"
variant="outlined"
density="compact"
placeholder="Masukkan Nama Pasien"
:rules="[rules.required]"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="localData.medicalRecordNumber"
label="No. Rekam medis"
variant="outlined"
density="compact"
placeholder="MRN Pasien"
:rules="[rules.required]"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="localData.category"
:items="patientCategories"
label="Kategori Pasien"
variant="outlined"
density="compact"
placeholder="Pilih Kategori Pasien"
append-inner-icon="mdi-chevron-down"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="localData.birthDate"
label="Tanggal Lahir"
variant="outlined"
density="compact"
type="date"
placeholder="Tanggal Lahir (DD/MM/YYYY)"
:rules="[rules.required]"
>
<template v-slot:label>
Tanggal Lahir <span class="text-red">*</span>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="localData.gender"
:items="genderOptions"
label="Jenis Kelamin"
variant="outlined"
density="compact"
placeholder="Pilih"
:rules="[rules.required]"
append-inner-icon="mdi-chevron-down"
>
<template v-slot:label>
Jenis Kelamin <span class="text-red">*</span>
</template>
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="localData.address"
label="Alamat"
variant="outlined"
density="compact"
rows="3"
placeholder="Alamat"
:rules="[rules.required]"
counter="100"
>
<template v-slot:label>
Alamat <span class="text-red">*</span>
</template>
</v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="2">
<v-select
v-model="localData.phoneCode"
:items="phoneCodes"
label="Kode"
variant="outlined"
density="compact"
:rules="[rules.required]"
>
<template v-slot:label>
Kode <span class="text-red">*</span>
</template>
</v-select>
</v-col>
<v-col cols="12" md="5">
<v-text-field
v-model="localData.phoneNumber"
label="Nomor Telepon"
variant="outlined"
density="compact"
placeholder="Nomor Telepon"
:rules="[rules.required, rules.phone]"
>
<template v-slot:label>
Nomor Telepon <span class="text-red">*</span>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="5">
<v-text-field
v-model="localData.email"
label="Email"
variant="outlined"
density="compact"
placeholder="Email"
:rules="[rules.email]"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="localData.identityType"
:items="identityTypes"
label="Jenis Identitas"
variant="outlined"
density="compact"
placeholder="Pilih"
:rules="[rules.required]"
append-inner-icon="mdi-chevron-down"
>
<template v-slot:label>
Jenis Identitas <span class="text-red">*</span>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="localData.identityNumber"
label="Nomor Identitas"
variant="outlined"
density="compact"
placeholder="Nomor Identitas"
:rules="[rules.required]"
>
<template v-slot:label>
Nomor Identitas <span class="text-red">*</span>
</template>
</v-text-field>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
patientData: {
type: Object,
required: true
}
});
const emit = defineEmits(["update:patientData", "update"]);
const localData = ref({ ...props.patientData });
const patientCategories = ["Umum", "BPJS", "Asuransi Lain"];
const genderOptions = [
{ title: "Laki-laki", value: "L" },
{ title: "Perempuan", value: "P" }
];
const phoneCodes = ["+62", "+65", "+60", "+1"];
const identityTypes = [
{ title: "KTP", value: "ktp" },
{ title: "SIM", value: "sim" },
{ title: "Paspor", value: "paspor" },
{ title: "Kartu Pelajar", value: "kartu_pelajar" }
];
const rules = {
required: (value) => !!value || "Field ini wajib diisi",
email: (value) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !value || pattern.test(value) || "Email tidak valid";
},
phone: (value) => {
const pattern = /^[0-9]{8,15}$/;
return pattern.test(value) || "Nomor telepon tidak valid";
}
};
const resetSection = () => {
localData.value = {
name: "",
medicalRecordNumber: "",
category: "",
birthDate: "",
gender: "",
address: "",
phoneCode: "+62",
phoneNumber: "",
email: "",
identityType: "",
identityNumber: ""
};
};
watch(
localData,
(newVal) => {
emit("update:patientData", newVal);
emit("update");
},
{ deep: true }
);
</script>
@@ -0,0 +1,106 @@
<template>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h6">JENIS PENDAFTARAN</span>
<v-btn variant="text" color="primary" size="small" @click="resetSection">
Atur Ulang
</v-btn>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<div class="mb-2">
<span class="text-subtitle-2">
Jenis Kunjungan <span class="text-red">*</span>
</span>
</div>
<v-radio-group
v-model="localData.visitType"
:rules="[rules.required]"
inline
>
<v-radio label="Kunjungan Sakit" value="sakit" />
<v-radio label="Kunjungan Sehat" value="sehat" />
</v-radio-group>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="localData.treatmentType"
:items="treatmentTypes"
label="Jenis Perawatan"
variant="outlined"
density="compact"
:rules="[rules.required]"
append-inner-icon="mdi-chevron-down"
>
<template v-slot:label>
Jenis Perawatan <span class="text-red">*</span>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="localData.triage"
:items="triageOptions"
label="Triase"
variant="outlined"
density="compact"
placeholder="Pilih"
clearable
append-inner-icon="mdi-chevron-down"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
registrationData: {
type: Object,
required: true
}
});
const emit = defineEmits(["update:registrationData", "update"]);
const localData = ref({ ...props.registrationData });
const treatmentTypes = ["Rawat Jalan", "Rawat Inap", "IGD"];
const triageOptions = [
{ title: "Merah (Emergency)", value: "merah" },
{ title: "Kuning (Urgent)", value: "kuning" },
{ title: "Hijau (Less Urgent)", value: "hijau" },
{ title: "Hitam (Non Urgent)", value: "hitam" }
];
const rules = {
required: (value) => !!value || "Field ini wajib diisi"
};
const resetSection = () => {
localData.value = {
visitType: "",
treatmentType: "Rawat Jalan",
triage: ""
};
};
watch(
localData,
(newVal) => {
emit("update:registrationData", newVal);
emit("update");
},
{ deep: true }
);
</script>
@@ -0,0 +1,159 @@
<template>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center gap-2">
<span class="text-h6">SKALA RISIKO JATUH (OPSIONAL)</span>
<v-icon color="warning" size="small">mdi-alert-box</v-icon>
</div>
<v-btn variant="text" color="primary" size="small" @click="resetSection">
Atur Ulang
</v-btn>
</v-card-title>
<v-expansion-panels v-model="panel" class="mt-2">
<v-expansion-panel>
<v-expansion-panel-title>
<span class="font-weight-medium"
>SKALA RISIKO JATUH - GET UP AND GO</span
>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<!-- Walking Ability Question -->
<div class="mb-6">
<div class="mb-3">
<p class="text-subtitle-1 font-weight-medium mb-1">
Cara berjalan pasien:
</p>
<ul class="ml-4">
<li>Jalan tidak seimbang, sempoyongan, atau limbung</li>
<li>
Menggunakan alat bantu (kruk, tripot, kursi roda, orang
lain)
</li>
</ul>
</div>
<v-radio-group
v-model="localData.walkingAbility"
inline
@update:modelValue="calculateScore"
>
<v-radio label="Ya" :value="true" />
<v-radio label="Tidak" :value="false" />
</v-radio-group>
</div>
<!-- Sitting Support Question -->
<div class="mb-6">
<div class="mb-3">
<p class="text-subtitle-1 font-weight-medium mb-1">
Menopang saat akan duduk
</p>
<p class="text-body-2 text-grey-darken-1">
Tampak memegang sandaran kursi atau meja / benda lain
sebagai penopang saat akan duduk
</p>
</div>
<v-radio-group
v-model="localData.supportWhileSitting"
inline
@update:modelValue="calculateScore"
>
<v-radio label="Ya" :value="true" />
<v-radio label="Tidak" :value="false" />
</v-radio-group>
</div>
<!-- Score Display -->
<v-divider class="my-4" />
<div class="d-flex justify-space-between align-center">
<span class="text-subtitle-1 font-weight-medium">Skor</span>
<v-chip :color="scoreColor" variant="tonal" size="large">
{{ localData.score }}
</v-chip>
</div>
<!-- Risk Level Alert -->
<v-alert
v-if="riskLevel"
:type="riskLevel.type"
variant="tonal"
density="compact"
class="mt-4"
>
{{ riskLevel.message }}
</v-alert>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
</template>
<script setup>
import { ref, watch, computed } from "vue";
const props = defineProps({
riskData: {
type: Object,
required: true
}
});
const emit = defineEmits(["update:riskData", "update"]);
const localData = ref({ ...props.riskData });
const panel = ref(0);
const calculateScore = () => {
let score = 0;
if (localData.value.walkingAbility === true) score += 1;
if (localData.value.supportWhileSitting === true) score += 1;
localData.value.score = score;
};
const scoreColor = computed(() => {
if (localData.value.score === 0) return "success";
if (localData.value.score === 1) return "warning";
return "error";
});
const riskLevel = computed(() => {
if (localData.value.score === 0) {
return {
type: "success",
message: "Risiko Rendah: Tidak berisiko jatuh"
};
} else if (localData.value.score === 1) {
return {
type: "warning",
message: "Risiko Sedang: Memerlukan pengawasan"
};
} else if (localData.value.score === 2) {
return {
type: "error",
message: "Risiko Tinggi: Memerlukan bantuan dan pengawasan ketat"
};
}
return null;
});
const resetSection = () => {
localData.value = {
walkingAbility: null,
supportWhileSitting: null,
score: 0
};
};
watch(
localData,
(newVal) => {
emit("update:riskData", newVal);
emit("update");
},
{ deep: true }
);
</script>