feat: audit trail menu
This commit is contained in:
parent
7da6568a12
commit
37c3676c59
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
18
backend/api/src/modules/audit/audit.controller.spec.ts
Normal file
18
backend/api/src/modules/audit/audit.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
backend/api/src/modules/audit/audit.controller.ts
Normal file
19
backend/api/src/modules/audit/audit.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/api/src/modules/audit/audit.module.ts
Normal file
23
backend/api/src/modules/audit/audit.module.ts
Normal 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 {}
|
||||||
18
backend/api/src/modules/audit/audit.service.spec.ts
Normal file
18
backend/api/src/modules/audit/audit.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
backend/api/src/modules/audit/audit.service.ts
Normal file
92
backend/api/src/modules/audit/audit.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
499
frontend/hospital-log/src/views/dashboard/AuditTrailView.vue
Normal file
499
frontend/hospital-log/src/views/dashboard/AuditTrailView.vue
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user