feat: audit trail menu

This commit is contained in:
yosaphatprs 2025-11-10 14:28:08 +07:00
parent 7da6568a12
commit 37c3676c59
18 changed files with 993 additions and 68 deletions

View File

@ -10,6 +10,7 @@ import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './modules/prisma/prisma.module'; import { PrismaModule } from './modules/prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { FabricModule } from './modules/fabric/fabric.module'; import { FabricModule } from './modules/fabric/fabric.module';
import { AuditModule } from './modules/audit/audit.module';
@Module({ @Module({
imports: [ imports: [
@ -24,6 +25,7 @@ import { FabricModule } from './modules/fabric/fabric.module';
ObatModule, ObatModule,
PrismaModule, PrismaModule,
FabricModule, FabricModule,
AuditModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -227,6 +227,30 @@ class FabricGateway {
throw error; throw error;
} }
} }
async getLogsWithPagination(pageSize: number, bookmark: string) {
try {
if (!this.contract) {
throw new Error('Not connected to network. Call connect() first.');
}
console.log(
`Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`,
);
const resultBytes = await this.contract.evaluateTransaction(
'getLogsWithPagination',
pageSize.toString(),
bookmark,
);
const resultJson = new TextDecoder().decode(resultBytes);
const result = JSON.parse(resultJson);
return result;
} catch (error) {
console.error('Failed to get logs with pagination:', error);
throw error;
}
}
} }
export default FabricGateway; export default FabricGateway;

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditController } from './audit.controller';
describe('AuditController', () => {
let controller: AuditController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuditController],
}).compile();
controller = module.get<AuditController>(AuditController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,19 @@
import { Body, Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/guard/auth.guard';
import { AuditService } from './audit.service';
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get('/trail')
@UseGuards(AuthGuard)
async getAuditTrail(
@Query('pageSize') pageSize: number,
@Query('bookmark') bookmark: string,
) {
console.log('Audit trail accessed');
const result = await this.auditService.getAuditTrails(pageSize, bookmark);
return result;
}
}

View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';
import { FabricModule } from '../fabric/fabric.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ObatModule } from '../obat/obat.module';
import { RekamMedisModule } from '../rekammedis/rekammedis.module';
import { TindakanDokterModule } from '../tindakandokter/tindakandokter.module';
import { LogModule } from '../log/log.module';
@Module({
imports: [
LogModule,
FabricModule,
PrismaModule,
ObatModule,
RekamMedisModule,
TindakanDokterModule,
],
providers: [AuditService],
controllers: [AuditController],
})
export class AuditModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditService } from './audit.service';
describe('AuditService', () => {
let service: AuditService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuditService],
}).compile();
service = module.get<AuditService>(AuditService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { LogService } from '../log/log.service';
import { ObatService } from '../obat/obat.service';
import { RekammedisService } from '../rekammedis/rekammedis.service';
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
import { sha256 } from '@api/common/crypto/hash';
@Injectable()
export class AuditService {
constructor(
// private readonly prisma: PrismaService,
private readonly logService: LogService,
private readonly obatService: ObatService,
private readonly rekamMedisService: RekammedisService,
private readonly tindakanDokterService: TindakanDokterService,
) {}
async getAuditTrails(pageSize: number, bookmark: string) {
if (!pageSize || pageSize <= 0) {
pageSize = 10;
}
if (!bookmark) {
bookmark = '';
}
const logs = await this.logService.getLogsWithPagination(
pageSize,
bookmark,
);
console.log('Fetched logs:', logs);
const flattenLogs = logs.logs.map((log: { value: any }) => {
return {
...log.value,
bookmark: logs.bookmark,
};
});
const formattedLogs = await Promise.all(
flattenLogs.map(async (log: any) => {
let relatedData = null;
let payloadData = null;
let payloadHash = null;
const id = log.id.split('_')[1];
if (log.event === 'obat_created' || log.event === 'obat_updated') {
relatedData = await this.obatService.getObatById(parseInt(id));
payloadData = {
id: relatedData?.id_visit,
obat: relatedData?.obat,
jumlah_obat: relatedData?.jumlah_obat,
aturan_pakai: relatedData?.aturan_pakai,
};
payloadHash = sha256(JSON.stringify(payloadData));
} else if (
log.event === 'rekam_medis_created' ||
log.event === 'rekam_medis_updated'
) {
relatedData = await this.rekamMedisService.getRekamMedisById(id);
payloadData = {
dokter_id: 123,
visit_id: relatedData?.id_visit,
anamnese: relatedData?.anamnese,
jenis_kasus: relatedData?.jenis_kasus,
tindak_lanjut: relatedData?.tindak_lanjut,
};
payloadHash = sha256(JSON.stringify(payloadData));
} else if (
log.event === 'tindakan_dokter_created' ||
log.event === 'tindakan_dokter_updated'
) {
relatedData = await this.tindakanDokterService.getTindakanDokterById(
parseInt(id),
);
payloadData = {
id_visit: relatedData?.id_visit,
tindakan: relatedData?.tindakan,
kategori_tindakan: relatedData?.kategori_tindakan,
kelompok_tindakan: relatedData?.kelompok_tindakan,
};
payloadHash = sha256(JSON.stringify(payloadData));
}
return {
...log,
isTampered: payloadHash === log.payload,
};
}),
);
return formattedLogs;
}
}

View File

@ -41,4 +41,11 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown {
this.logger.log('Retrieving all logs from Fabric network'); this.logger.log('Retrieving all logs from Fabric network');
return this.gateway.getAllLogs(); return this.gateway.getAllLogs();
} }
async getLogsWithPagination(pageSize: number, bookmark: string) {
this.logger.log(
`Retrieving logs with pagination - Page Size: ${pageSize}, Bookmark: ${bookmark}`,
);
return this.gateway.getLogsWithPagination(pageSize, bookmark);
}
} }

View File

@ -22,4 +22,8 @@ export class LogService {
const countLogs = await this.fabricService.getAllLogs(); const countLogs = await this.fabricService.getAllLogs();
return countLogs.length; return countLogs.length;
} }
async getLogsWithPagination(pageSize: number, bookmark: string) {
return this.fabricService.getLogsWithPagination(pageSize, bookmark);
}
} }

View File

@ -33,11 +33,11 @@ const formatCellValue = (item: T, columnKey: keyof T) => {
if (columnKey === "event" && typeof value === "string") { if (columnKey === "event" && typeof value === "string") {
const segments = value.split("_"); const segments = value.split("_");
if (segments.length >= 3 && segments[segments.length - 1] === "created") { if (segments.length >= 2 && segments[segments.length - 1] === "created") {
return "CREATE"; return "CREATE";
} }
if (segments.length >= 3 && segments[segments.length - 1] === "updated") { if (segments.length >= 2 && segments[segments.length - 1] === "updated") {
return "UPDATE"; return "UPDATE";
} }
} }
@ -115,7 +115,10 @@ const handleDeleteCancel = () => {
v-else v-else
v-for="item in data" v-for="item in data"
:key="item.id" :key="item.id"
:class="'hover:bg-dark hover:text-light transition-colors'" :class="[
'hover:bg-dark hover:text-light transition-colors',
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : ''
]"
> >
<td <td
v-for="column in columns" v-for="column in columns"

View File

@ -4,7 +4,7 @@ import type { Ref } from "vue";
interface Props { interface Props {
pageSize: Ref<number>; pageSize: Ref<number>;
page: Ref<number>; page: Ref<number>;
totalCount: Ref<number>; totalCount?: Ref<number>;
startIndex: Ref<number>; startIndex: Ref<number>;
endIndex: Ref<number>; endIndex: Ref<number>;
canGoNext: Ref<boolean>; canGoNext: Ref<boolean>;

View File

@ -265,6 +265,39 @@ const isActive = (routeName: string) => {
</button> </button>
</li> </li>
<!-- Audit Trail -->
<li>
<button
:class="[
'is-drawer-close:tooltip is-drawer-close:tooltip-right active:bg-dark',
isActive('audit-trail')
? 'bg-dark text-white hover:bg-dark'
: '',
]"
data-tip="Audit Trail"
@click="navigateTo('audit-trail')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2"
fill="none"
stroke="currentColor"
class="inline-block size-4 my-1.5 shrink-0"
>
<path d="M3.05 11a9 9 0 1 1 2.13 5.84"></path>
<path d="M3 11h4"></path>
<path d="M12 7v5l3 3"></path>
</svg>
<span
class="is-drawer-close:hidden is-drawer-open:opacity-100 transition-opacity is-drawer-open:duration-300 is-drawer-open:delay-300"
>Audit Trail</span
>
</button>
</li>
<!-- Spacer to push logout to bottom --> <!-- Spacer to push logout to bottom -->
<li class="mt-auto"></li> <li class="mt-auto"></li>

View File

@ -16,6 +16,7 @@ import CreateObatView from "../views/dashboard/CreateObatView.vue";
import CreateTindakanDokterView from "../views/dashboard/CreateTindakanDokterView.vue"; import CreateTindakanDokterView from "../views/dashboard/CreateTindakanDokterView.vue";
import TindakanDokterEditView from "../views/dashboard/TindakanDokterEditView.vue"; import TindakanDokterEditView from "../views/dashboard/TindakanDokterEditView.vue";
import TindakanDokterDetailsView from "../views/dashboard/TindakanDokterDetailsView.vue"; import TindakanDokterDetailsView from "../views/dashboard/TindakanDokterDetailsView.vue";
import AuditTrailView from "../views/dashboard/AuditTrailView.vue";
const routes = [ const routes = [
{ {
@ -108,6 +109,12 @@ const routes = [
component: UsersView, component: UsersView,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "/audit-trail",
name: "audit-trail",
component: AuditTrailView,
meta: { requiresAuth: true },
},
{ {
path: "/:catchAll(.*)*", path: "/:catchAll(.*)*",
name: "NotFound", name: "NotFound",

View File

@ -0,0 +1,499 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Sidebar from "../../components/dashboard/Sidebar.vue";
import Footer from "../../components/dashboard/Footer.vue";
import PageHeader from "../../components/dashboard/PageHeader.vue";
import SearchInput from "../../components/dashboard/SearchInput.vue";
import DataTable from "../../components/dashboard/DataTable.vue";
import { useApi } from "../../composables/useApi";
import { usePagination } from "../../composables/usePagination";
import { useDebounce } from "../../composables/useDebounce";
import {
DEFAULT_PAGE_SIZE,
DEBOUNCE_DELAY,
ITEMS_PER_PAGE_OPTIONS,
} from "../../constants/pagination";
import type { BlockchainLog } from "../../constants/interfaces";
type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "unknown";
interface AuditLogEntry extends BlockchainLog {
type: AuditLogType;
typeLabel: string;
tamperedLabel: string;
isTampered: boolean;
txId?: string;
}
interface AuditLogResponse {
data: AuditLogEntry[];
totalCount: number;
}
const bookmark = ref("");
const prevBookmark = ref("");
const router = useRouter();
const route = useRoute();
const api = useApi();
const { debounce } = useDebounce();
const pagination = usePagination({
initialPage: Number(route.query.page) || 1,
initialPageSize: Number(route.query.take) || DEFAULT_PAGE_SIZE,
});
const pageSize = ref<number>(Number(route.query.take) || DEFAULT_PAGE_SIZE);
const logs = ref<AuditLogEntry[]>([]);
const searchId = ref("");
const filters = ref({
type: (route.query.type as string) || "all",
tampered: (route.query.tampered as string) || "all",
});
const TYPE_OPTIONS = [
{ value: "all", label: "Semua Tipe" },
{ value: "rekam_medis", label: "Rekam Medis" },
{ value: "tindakan", label: "Tindakan" },
{ value: "obat", label: "Obat" },
];
const TAMPER_OPTIONS = [
{ value: "all", label: "Semua Data" },
{ value: "tampered", label: "Termanipulasi" },
{ value: "clean", label: "Tidak Termanipulasi" },
];
const AUDIT_TABLE_COLUMNS = [
{ key: "id", label: "ID Log", class: "text-dark" },
{ key: "typeLabel", label: "Tipe Data", class: "text-dark" },
{ key: "event", label: "Event", class: "text-dark" },
{ key: "timestamp", label: "Timestamp", class: "text-dark" },
{ key: "userId", label: "User ID", class: "text-dark" },
{ key: "status", label: "Status Data", class: "text-dark" },
] satisfies Array<{
key: keyof AuditLogEntry;
label: string;
class?: string;
}>;
const formatTimestamp = (rawValue?: string) => {
if (!rawValue) {
return "-";
}
const date = new Date(rawValue);
if (Number.isNaN(date.getTime())) {
return rawValue;
}
const options: Intl.DateTimeFormatOptions = {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
return date.toLocaleString("id-ID", options).replace(/\./g, ":");
};
const deriveType = (entry: Partial<AuditLogEntry>): AuditLogType => {
const { type, event = "", id = "" } = entry;
if (type && type !== "unknown") {
return type;
}
if (event.startsWith("rekam")) {
return "rekam_medis" as const;
}
if (event.startsWith("tindakan") || event.includes("tindakan")) {
return "tindakan" as const;
}
if (event.startsWith("obat")) {
return "obat" as const;
}
if (id.startsWith("REKAM_")) {
return "rekam_medis" as const;
}
if (id.startsWith("TINDAKAN_")) {
return "tindakan" as const;
}
if (id.startsWith("OBAT_")) {
return "obat" as const;
}
return "unknown";
};
const typeLabelMap: Record<AuditLogType, string> = {
rekam_medis: "Rekam Medis",
tindakan: "Tindakan",
obat: "Obat",
unknown: "Tidak Diketahui",
};
const normalizeEntry = (entry: any): AuditLogEntry => {
const flattened = {
...entry,
...(entry?.value ?? {}),
} as Record<string, any>;
const detectedType = deriveType({
type: flattened.type,
event: flattened.event,
id: flattened.id,
});
const isTampered = Boolean(
flattened.isTampered ||
(typeof flattened.status === "string" &&
flattened.status.toLowerCase().includes("tamper"))
);
const statusLabel = flattened.status
? flattened.status
: isTampered
? "TAMPERED"
: "VALID";
const rawUserId =
flattened.userId ?? flattened.user_id ?? entry.userId ?? entry.user_id;
const numericUserId = Number(rawUserId);
return {
id: flattened.id ?? entry.id ?? "",
txId: flattened.txId ?? entry.txId ?? "",
event: flattened.event ?? entry.event ?? "-",
timestamp: formatTimestamp(flattened.timestamp ?? entry.timestamp),
hash: String(flattened.hash ?? flattened.payload ?? entry.hash ?? "-"),
userId: Number.isFinite(numericUserId) ? numericUserId : 0,
status: statusLabel,
type: detectedType,
typeLabel: typeLabelMap[detectedType],
tamperedLabel: isTampered ? "Termanipulasi" : "Tidak Termanipulasi",
isTampered,
};
};
const handleNextBookmark = () => {
if (bookmark.value) {
fetchData(bookmark.value);
}
};
const updateQueryParams = () => {
const query: Record<string, string> = {
pageSize: pageSize.value.toString(),
};
if (searchId.value) {
query.search = searchId.value;
}
if (filters.value.type !== "all") {
query.type = filters.value.type;
}
if (filters.value.tampered !== "all") {
query.tampered = filters.value.tampered;
}
router.replace({ query });
};
const fetchData = async (bookmarkParam?: string, isInitial?: boolean) => {
try {
const params = new URLSearchParams({
pageSize: pageSize.value.toString(),
});
if (searchId.value) {
params.append("search", searchId.value.trim());
}
if (bookmarkParam) {
params.append("bookmark", bookmarkParam);
}
if (filters.value.type !== "all") {
params.append("type", filters.value.type);
}
if (filters.value.tampered !== "all") {
params.append(
"tampered",
filters.value.tampered === "tampered" ? "true" : "false"
);
}
const response = await api.get<AuditLogResponse | AuditLogEntry[]>(
`/audit/trail${params.toString() ? `?${params.toString()}` : ""}`
);
console.log(response);
const payload = Array.isArray(response)
? { data: response, totalCount: response.length }
: response;
const bookmarkValue = Array.isArray(response)
? (response[0] as any)?.bookmark
: (response as any).bookmark;
// console.log(
// "Bookmark:",
// Array.isArray(response)
// ? (response[0] as any)?.bookmark
// : (response as any).bookmark
// );
const normalized = Array.isArray(payload.data)
? payload.data.map((item) => normalizeEntry(item))
: [];
bookmark.value = bookmarkValue;
localStorage.setItem("bookmark-page-audit", bookmark.value || "");
logs.value = normalized;
console.log(logs.value);
pagination.totalCount.value = payload.totalCount ?? normalized.length;
if (!isInitial) {
updateQueryParams();
}
} catch (error) {
console.error("Error fetching audit logs:", error);
logs.value = [];
pagination.totalCount.value = 0;
}
};
const debouncedFetch = debounce(fetchData, DEBOUNCE_DELAY);
const handleSearch = () => {
pagination.reset();
debouncedFetch();
};
const handlePageSizeClick = (size: number) => {
handlePageSizeChange(size); // your existing logic
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const handlePageSizeChange = (size: number) => {
pageSize.value = size;
fetchData();
};
const handleResetFilters = () => {
filters.value.type = "all";
filters.value.tampered = "all";
searchId.value = "";
pagination.reset();
fetchData();
};
// const filteredTamperedLabel = computed(() =>
// filters.value.tampered === "tampered"
// ? "Termanipulasi"
// : filters.value.tampered === "clean"
// ? "Tidak Termanipulasi"
// : "Semua Data"
// );
watch(
() => pagination.page.value,
() => {
fetchData();
}
);
watch(
() => filters.value.type,
() => {
pagination.reset();
fetchData();
}
);
watch(
() => filters.value.tampered,
() => {
pagination.reset();
fetchData();
}
);
watch(searchId, (newValue, oldValue) => {
if (oldValue && !newValue) {
pagination.reset();
fetchData();
}
});
onMounted(async () => {
if (route.query.search) {
searchId.value = route.query.search as string;
}
await fetchData("", true);
document.title = "Audit Trail - Hospital Log";
});
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader title="Audit Trail" subtitle="Riwayat Log Blockchain" />
<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 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="form-control w-full">
<span class="label-text font-semibold">Tipe Data</span>
<select
v-model="filters.type"
class="select select-bordered bg-white"
>
<option
v-for="option in TYPE_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label class="form-control w-full">
<span class="label-text font-semibold">Status Manipulasi</span>
<select
v-model="filters.tampered"
class="select select-bordered bg-white"
>
<option
v-for="option in TAMPER_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div>
<div class="flex justify-end">
<button
@click="handleResetFilters"
class="btn btn-sm btn-outline btn-dark hover:bg-dark hover:text-light"
>
Reset Filter
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm">
<div
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
>
<SearchInput
v-model="searchId"
placeholder="Cari berdasarkan ID Log"
@search="handleSearch"
/>
</div>
<DataTable
:data="logs"
:columns="AUDIT_TABLE_COLUMNS"
:is-loading="api.isLoading.value"
empty-message="Tidak ada log audit"
:is-aksi="false"
/>
<div>
<!-- Pagination Info -->
<div class="text-sm text-gray-600 px-4 py-4">
Menampilkan data {{ 1 }} - {{ logs.length }}
</div>
<!-- Pagination Controls -->
<div class="flex justify-between items-center px-4 pb-4">
<!-- Items per page selector -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600">Data per halaman:</span>
<div class="dropdown dropdown-top">
<div
tabindex="0"
role="button"
class="btn btn-xs shadow-none rounded-full bg-white text-dark font-bold border border-gray-200 hover:bg-gray-50"
>
{{ pageSize }}
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-white rounded-box z-10 w-16 p-2 shadow-lg border border-gray-100"
>
<li v-for="size in ITEMS_PER_PAGE_OPTIONS" :key="size">
<a
@click="(event) => handlePageSizeClick(size)"
:class="{
'bg-gray-100': pageSize === size,
}"
>
{{ size }}
</a>
</li>
</ul>
</div>
</div>
<!-- Page navigation -->
<div class="join text-sm space-x-1">
<button
@click=""
class="join-item btn btn-sm bg-white text-dark border-none shadow-none hover:bg-gray-100"
>
«
</button>
{{ console.log("") }}
<button
@click="handleNextBookmark"
:disabled="bookmark === ''"
:class="
bookmark === '' ? 'opacity-50 cursor-not-allowed' : ''
"
class="join-item btn btn-sm bg-white text-dark border-none shadow-none hover:bg-gray-100"
>
»
</button>
</div>
</div>
</div>
</div>
</Sidebar>
</div>
<Footer />
</div>
</template>
<style scoped></style>

View File

@ -43,6 +43,9 @@ 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 sortOrder = ref<"asc" | "desc">(
(route.query.order as "asc" | "desc") || "asc"
);
const tableColumns = [ const tableColumns = [
{ key: "id" as keyof ObatData, label: "#", class: "text-dark" }, { key: "id" as keyof ObatData, label: "#", class: "text-dark" },
@ -74,6 +77,8 @@ const updateQueryParams = () => {
query.sortBy = sortBy.value; query.sortBy = sortBy.value;
} }
query.order = sortOrder.value;
router.replace({ query }); router.replace({ query });
}; };
@ -83,6 +88,7 @@ const fetchData = async () => {
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,
order: sortOrder.value,
...(searchObat.value && { obat: searchObat.value }), ...(searchObat.value && { obat: searchObat.value }),
}); });
@ -131,6 +137,10 @@ const handleSortChange = (newSortBy: string) => {
fetchData(); fetchData();
}; };
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
};
const handlePageSizeChange = (newSize: number) => { const handlePageSizeChange = (newSize: number) => {
pagination.setPageSize(newSize); pagination.setPageSize(newSize);
fetchData(); fetchData();
@ -160,6 +170,11 @@ watch([() => pagination.page.value], () => {
fetchData(); fetchData();
}); });
watch(sortOrder, () => {
pagination.reset();
fetchData();
});
watch(searchObat, (newValue, oldValue) => { watch(searchObat, (newValue, oldValue) => {
if (oldValue && !newValue) { if (oldValue && !newValue) {
pagination.reset(); pagination.reset();
@ -174,6 +189,12 @@ onMounted(async () => {
if (route.query.sortBy) { if (route.query.sortBy) {
sortBy.value = route.query.sortBy as string; sortBy.value = route.query.sortBy as string;
} }
if (route.query.order) {
const incomingOrder = route.query.order as string;
if (incomingOrder === "asc" || incomingOrder === "desc") {
sortOrder.value = incomingOrder;
}
}
await fetchData(); await fetchData();
document.title = "Obat - Hospital Log"; document.title = "Obat - Hospital Log";
@ -186,26 +207,50 @@ onMounted(async () => {
<Sidebar> <Sidebar>
<PageHeader title="Obat" subtitle="Manajemen Obat" /> <PageHeader title="Obat" subtitle="Manajemen Obat" />
<div class="bg-white rounded-xl shadow-md"> <div class="bg-white rounded-xl shadow-md">
<div class="flex px-4"> <div
<div class="flex flex-1 items-center"> class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
<RouterLink to="/pemberian-obat-add" class="btn bg-dark btn-sm"> >
Tambah Obat <div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
</RouterLink>
</div>
<div class="flex flex-col flex-1 items-end gap-2 pt-4">
<SearchInput <SearchInput
v-model="searchObat" v-model="searchObat"
placeholder="Cari berdasarkan Obat" placeholder="Cari berdasarkan Obat"
@search="handleSearch" @search="handleSearch"
/> />
<div class="flex items-center gap-2 md:ml-4">
<SortDropdown <SortDropdown
v-model="sortBy" v-model="sortBy"
:options="SORT_OPTIONS.OBAT" :options="SORT_OPTIONS.OBAT"
label="Urut berdasarkan:" label="Urut berdasarkan:"
@change="handleSortChange" @change="handleSortChange"
/> />
<button
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
@click="toggleSortOrder"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
stroke="currentColor"
class="inline-block size-4"
>
<path d="M7 7l3 -3l3 3"></path>
<path d="M10 4v16"></path>
<path d="M17 17l-3 3l-3 -3"></path>
<path d="M14 20v-16"></path>
</svg>
<span class="ml-2 uppercase">{{ sortOrder }}</span>
</button>
</div> </div>
</div> </div>
<RouterLink
to="/pemberian-obat-add"
class="btn bg-dark btn-sm self-start md:self-auto"
>
Tambah Obat
</RouterLink>
</div>
<!-- Data Table --> <!-- Data Table -->
<DataTable <DataTable

View File

@ -45,6 +45,9 @@ 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 sortOrder = ref<"asc" | "desc">(
(route.query.order as "asc" | "desc") || "desc"
);
const today: string = new Date().toISOString().split("T")[0] || ""; const today: string = new Date().toISOString().split("T")[0] || "";
const ageSliderRef = ref<HTMLElement | null>(null); const ageSliderRef = ref<HTMLElement | null>(null);
const ageRange = ref<[number, number]>([0, 100]); const ageRange = ref<[number, number]>([0, 100]);
@ -83,6 +86,8 @@ const updateQueryParams = () => {
query.sortBy = sortBy.value; query.sortBy = sortBy.value;
} }
query.order = sortOrder.value;
if (filter.value.id_visit) { if (filter.value.id_visit) {
query.id_visit = filter.value.id_visit; query.id_visit = filter.value.id_visit;
} }
@ -169,6 +174,7 @@ const fetchData = async (isFirst?: boolean) => {
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,
order: sortOrder.value,
...(searchRekamMedis.value && { no_rm: searchRekamMedis.value }), ...(searchRekamMedis.value && { no_rm: searchRekamMedis.value }),
...(filter.value.id_visit && { id_visit: filter.value.id_visit }), ...(filter.value.id_visit && { id_visit: filter.value.id_visit }),
...(filter.value.nama_pasien && { ...(filter.value.nama_pasien && {
@ -250,6 +256,10 @@ const handleSortChange = (newSortBy: string) => {
fetchData(); fetchData();
}; };
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
};
const handlePageSizeChange = (newSize: number) => { const handlePageSizeChange = (newSize: number) => {
pagination.setPageSize(newSize); pagination.setPageSize(newSize);
fetchData(); fetchData();
@ -281,6 +291,11 @@ watch([() => pagination.page.value], () => {
fetchData(); fetchData();
}); });
watch(sortOrder, () => {
pagination.reset();
fetchData();
});
watch(searchRekamMedis, (newValue, oldValue) => { watch(searchRekamMedis, (newValue, oldValue) => {
if (oldValue && !newValue) { if (oldValue && !newValue) {
pagination.reset(); pagination.reset();
@ -295,6 +310,12 @@ onMounted(async () => {
if (route.query.sortBy) { if (route.query.sortBy) {
sortBy.value = route.query.sortBy as string; sortBy.value = route.query.sortBy as string;
} }
if (route.query.order) {
const incomingOrder = route.query.order as string;
if (incomingOrder === "asc" || incomingOrder === "desc") {
sortOrder.value = incomingOrder;
}
}
await fetchData(true); await fetchData(true);
filter.value.rentang_umur = [ageRange.value[0], ageRange.value[1]]; filter.value.rentang_umur = [ageRange.value[0], ageRange.value[1]];
@ -540,31 +561,50 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div class="bg-white rounded-xl shadow-sm"> <div class="bg-white rounded-xl shadow-sm">
<div class="flex">
<div class="flex items-center pl-4 pb-4 flex-1">
<RouterLink
to="/rekam-medis/tambah"
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
>
Tambah Rekam Medis
</RouterLink>
</div>
<div <div
class="flex flex-col items-end flex-1 px-4 pt-4 pb-2 justify-between gap-4" class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
> >
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
<SearchInput <SearchInput
v-model="searchRekamMedis" v-model="searchRekamMedis"
placeholder="Cari berdasarkan Nomor Rekam Medis" placeholder="Cari berdasarkan Nomor Rekam Medis"
@search="handleSearch" @search="handleSearch"
/> />
<div class="flex items-center gap-2 md:ml-4">
<SortDropdown <SortDropdown
v-model="sortBy" v-model="sortBy"
:options="SORT_OPTIONS.REKAM_MEDIS" :options="SORT_OPTIONS.REKAM_MEDIS"
label="Urut berdasarkan:" label="Urut berdasarkan:"
@change="handleSortChange" @change="handleSortChange"
/> />
<button
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
@click="toggleSortOrder"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
stroke="currentColor"
class="inline-block size-4"
>
<path d="M7 7l3 -3l3 3"></path>
<path d="M10 4v16"></path>
<path d="M17 17l-3 3l-3 -3"></path>
<path d="M14 20v-16"></path>
</svg>
<span class="ml-2 uppercase">{{ sortOrder }}</span>
</button>
</div> </div>
</div> </div>
<RouterLink
to="/rekam-medis/tambah"
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50 self-start md:self-auto"
>
Tambah Rekam Medis
</RouterLink>
</div>
<!-- Data Table --> <!-- Data Table -->
<DataTable <DataTable

View File

@ -38,6 +38,9 @@ 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 sortOrder = ref<"asc" | "desc">(
(route.query.order as "asc" | "desc") || "asc"
);
const filter = ref<{ const filter = ref<{
tindakan: string | null; tindakan: string | null;
kategori: string[]; kategori: string[];
@ -62,6 +65,8 @@ const updateQueryParams = () => {
query.sortBy = sortBy.value; query.sortBy = sortBy.value;
} }
query.order = sortOrder.value;
router.replace({ query }); router.replace({ query });
}; };
@ -70,6 +75,7 @@ const fetchData = async () => {
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,
order: sortOrder.value,
...(searchIdVisit.value && { id_visit: searchIdVisit.value }), ...(searchIdVisit.value && { id_visit: searchIdVisit.value }),
...(filter.value.tindakan && { tindakan: filter.value.tindakan }), ...(filter.value.tindakan && { tindakan: filter.value.tindakan }),
...(filter.value.kategori.length > 0 ...(filter.value.kategori.length > 0
@ -158,6 +164,10 @@ const handleSortChange = (newSortBy: string) => {
fetchData(); fetchData();
}; };
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
};
const handlePageSizeChange = (newSize: number) => { const handlePageSizeChange = (newSize: number) => {
pagination.setPageSize(newSize); pagination.setPageSize(newSize);
fetchData(); fetchData();
@ -189,6 +199,11 @@ watch([() => pagination.page.value], () => {
fetchData(); fetchData();
}); });
watch(sortOrder, () => {
pagination.reset();
fetchData();
});
watch(searchIdVisit, (newValue, oldValue) => { watch(searchIdVisit, (newValue, oldValue) => {
if (oldValue && !newValue) { if (oldValue && !newValue) {
pagination.reset(); pagination.reset();
@ -203,6 +218,12 @@ onMounted(async () => {
if (route.query.sortBy) { if (route.query.sortBy) {
sortBy.value = route.query.sortBy as string; sortBy.value = route.query.sortBy as string;
} }
if (route.query.order) {
const incomingOrder = route.query.order as string;
if (incomingOrder === "asc" || incomingOrder === "desc") {
sortOrder.value = incomingOrder;
}
}
await fetchData(); await fetchData();
document.title = "Tindakan Dokter - Hospital Log"; document.title = "Tindakan Dokter - Hospital Log";
@ -307,31 +328,50 @@ onMounted(async () => {
</div> </div>
</div> </div>
<div class="bg-white rounded-xl shadow-md"> <div class="bg-white rounded-xl shadow-md">
<div class="flex">
<div class="flex items-center pl-4 pb-4 flex-1">
<RouterLink
to="/pemberian-tindakan/add"
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
>
Tambah Pemberian Tindakan
</RouterLink>
</div>
<div <div
class="flex flex-col items-end flex-1 px-4 pt-4 pb-2 justify-between gap-4" class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
> >
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
<SearchInput <SearchInput
v-model="searchIdVisit" v-model="searchIdVisit"
placeholder="Cari berdasarkan ID Visit" placeholder="Cari berdasarkan ID Visit"
@search="handleSearch" @search="handleSearch"
/> />
<div class="flex items-center gap-2 md:ml-4">
<SortDropdown <SortDropdown
v-model="sortBy" v-model="sortBy"
:options="SORT_OPTIONS.TINDAKAN" :options="SORT_OPTIONS.TINDAKAN"
label="Urut berdasarkan:" label="Urut berdasarkan:"
@change="handleSortChange" @change="handleSortChange"
/> />
<button
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
@click="toggleSortOrder"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
stroke="currentColor"
class="inline-block size-4"
>
<path d="M7 7l3 -3l3 3"></path>
<path d="M10 4v16"></path>
<path d="M17 17l-3 3l-3 -3"></path>
<path d="M14 20v-16"></path>
</svg>
<span class="ml-2 uppercase">{{ sortOrder }}</span>
</button>
</div> </div>
</div> </div>
<RouterLink
to="/pemberian-tindakan/add"
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50 self-start md:self-auto"
>
Tambah Pemberian Tindakan
</RouterLink>
</div>
<!-- Data Table --> <!-- Data Table -->
<DataTable <DataTable

View File

@ -37,6 +37,9 @@ 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 sortOrder = ref<"asc" | "desc">(
(route.query.order as "asc" | "desc") || "asc"
);
const updateQueryParams = () => { const updateQueryParams = () => {
const query: Record<string, string> = { const query: Record<string, string> = {
@ -52,6 +55,8 @@ const updateQueryParams = () => {
query.sortBy = sortBy.value; query.sortBy = sortBy.value;
} }
query.order = sortOrder.value;
router.replace({ query }); router.replace({ query });
}; };
@ -61,6 +66,7 @@ const fetchData = async () => {
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,
order: sortOrder.value,
...(searchUsername.value ? { username: searchUsername.value } : {}), ...(searchUsername.value ? { username: searchUsername.value } : {}),
}); });
@ -106,6 +112,10 @@ const handleSortChange = (newSortBy: string) => {
fetchData(); fetchData();
}; };
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
};
const handlePageSizeChange = (newSize: number) => { const handlePageSizeChange = (newSize: number) => {
pagination.setPageSize(newSize); pagination.setPageSize(newSize);
fetchData(); fetchData();
@ -137,6 +147,11 @@ watch([() => pagination.page.value], () => {
fetchData(); fetchData();
}); });
watch(sortOrder, () => {
pagination.reset();
fetchData();
});
watch(searchUsername, (newValue, oldValue) => { watch(searchUsername, (newValue, oldValue) => {
if (oldValue && !newValue) { if (oldValue && !newValue) {
pagination.reset(); pagination.reset();
@ -145,12 +160,18 @@ watch(searchUsername, (newValue, oldValue) => {
}); });
onMounted(async () => { onMounted(async () => {
if (route.query.search) { if (route.query.username) {
searchUsername.value = route.query.search as string; searchUsername.value = route.query.username as string;
} }
if (route.query.sortBy) { if (route.query.sortBy) {
sortBy.value = route.query.sortBy as string; sortBy.value = route.query.sortBy as string;
} }
if (route.query.order) {
const incomingOrder = route.query.order as string;
if (incomingOrder === "asc" || incomingOrder === "desc") {
sortOrder.value = incomingOrder;
}
}
await fetchData(); await fetchData();
document.title = "Users - Hospital Log"; document.title = "Users - Hospital Log";
@ -164,19 +185,49 @@ onMounted(async () => {
<PageHeader title="Users" subtitle="Manajemen Pengguna" /> <PageHeader title="Users" subtitle="Manajemen Pengguna" />
<div class="bg-white rounded-xl shadow-md"> <div class="bg-white rounded-xl shadow-md">
<div class="flex items-center px-4 py-4 justify-between gap-4"> <div
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
>
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
<SearchInput
v-model="searchUsername"
placeholder="Cari berdasarkan username"
@search="handleSearch"
/>
<div class="flex items-center gap-2 md:ml-4">
<SortDropdown <SortDropdown
v-model="sortBy" v-model="sortBy"
:options="SORT_OPTIONS.USERS" :options="SORT_OPTIONS.USERS"
label="Urut berdasarkan:" label="Urut berdasarkan:"
@change="handleSortChange" @change="handleSortChange"
/> />
<button
<SearchInput class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
v-model="searchUsername" @click="toggleSortOrder"
placeholder="Cari berdasarkan username" >
@search="handleSearch" <svg
/> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
stroke="currentColor"
class="inline-block size-4"
>
<path d="M7 7l3 -3l3 3"></path>
<path d="M10 4v16"></path>
<path d="M17 17l-3 3l-3 -3"></path>
<path d="M14 20v-16"></path>
</svg>
<span class="ml-2 uppercase">{{ sortOrder }}</span>
</button>
</div>
</div>
<RouterLink
to="/users/add"
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50 self-start md:self-auto"
>
Tambah Pengguna
</RouterLink>
</div> </div>
<!-- Data Table --> <!-- Data Table -->