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 { AuthModule } from './modules/auth/auth.module';
|
||||
import { FabricModule } from './modules/fabric/fabric.module';
|
||||
import { AuditModule } from './modules/audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -24,6 +25,7 @@ import { FabricModule } from './modules/fabric/fabric.module';
|
|||
ObatModule,
|
||||
PrismaModule,
|
||||
FabricModule,
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
|
|
|||
|
|
@ -227,6 +227,30 @@ class FabricGateway {
|
|||
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;
|
||||
|
|
|
|||
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');
|
||||
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();
|
||||
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") {
|
||||
const segments = value.split("_");
|
||||
|
||||
if (segments.length >= 3 && segments[segments.length - 1] === "created") {
|
||||
if (segments.length >= 2 && segments[segments.length - 1] === "created") {
|
||||
return "CREATE";
|
||||
}
|
||||
|
||||
if (segments.length >= 3 && segments[segments.length - 1] === "updated") {
|
||||
if (segments.length >= 2 && segments[segments.length - 1] === "updated") {
|
||||
return "UPDATE";
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,10 @@ const handleDeleteCancel = () => {
|
|||
v-else
|
||||
v-for="item in data"
|
||||
: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
|
||||
v-for="column in columns"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Ref } from "vue";
|
|||
interface Props {
|
||||
pageSize: Ref<number>;
|
||||
page: Ref<number>;
|
||||
totalCount: Ref<number>;
|
||||
totalCount?: Ref<number>;
|
||||
startIndex: Ref<number>;
|
||||
endIndex: Ref<number>;
|
||||
canGoNext: Ref<boolean>;
|
||||
|
|
|
|||
|
|
@ -265,6 +265,39 @@ const isActive = (routeName: string) => {
|
|||
</button>
|
||||
</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 -->
|
||||
<li class="mt-auto"></li>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import CreateObatView from "../views/dashboard/CreateObatView.vue";
|
|||
import CreateTindakanDokterView from "../views/dashboard/CreateTindakanDokterView.vue";
|
||||
import TindakanDokterEditView from "../views/dashboard/TindakanDokterEditView.vue";
|
||||
import TindakanDokterDetailsView from "../views/dashboard/TindakanDokterDetailsView.vue";
|
||||
import AuditTrailView from "../views/dashboard/AuditTrailView.vue";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
|
|
@ -108,6 +109,12 @@ const routes = [
|
|||
component: UsersView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/audit-trail",
|
||||
name: "audit-trail",
|
||||
component: AuditTrailView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/:catchAll(.*)*",
|
||||
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,
|
||||
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const sortOrder = ref<"asc" | "desc">(
|
||||
(route.query.order as "asc" | "desc") || "asc"
|
||||
);
|
||||
|
||||
const tableColumns = [
|
||||
{ key: "id" as keyof ObatData, label: "#", class: "text-dark" },
|
||||
|
|
@ -74,6 +77,8 @@ const updateQueryParams = () => {
|
|||
query.sortBy = sortBy.value;
|
||||
}
|
||||
|
||||
query.order = sortOrder.value;
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
|
|
@ -83,6 +88,7 @@ const fetchData = async () => {
|
|||
take: pagination.pageSize.value.toString(),
|
||||
page: pagination.page.value.toString(),
|
||||
orderBy: sortBy.value,
|
||||
order: sortOrder.value,
|
||||
...(searchObat.value && { obat: searchObat.value }),
|
||||
});
|
||||
|
||||
|
|
@ -131,6 +137,10 @@ const handleSortChange = (newSortBy: string) => {
|
|||
fetchData();
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
pagination.setPageSize(newSize);
|
||||
fetchData();
|
||||
|
|
@ -160,6 +170,11 @@ watch([() => pagination.page.value], () => {
|
|||
fetchData();
|
||||
});
|
||||
|
||||
watch(sortOrder, () => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(searchObat, (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
pagination.reset();
|
||||
|
|
@ -174,6 +189,12 @@ onMounted(async () => {
|
|||
if (route.query.sortBy) {
|
||||
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();
|
||||
document.title = "Obat - Hospital Log";
|
||||
|
|
@ -186,25 +207,49 @@ onMounted(async () => {
|
|||
<Sidebar>
|
||||
<PageHeader title="Obat" subtitle="Manajemen Obat" />
|
||||
<div class="bg-white rounded-xl shadow-md">
|
||||
<div class="flex px-4">
|
||||
<div class="flex flex-1 items-center">
|
||||
<RouterLink to="/pemberian-obat-add" class="btn bg-dark btn-sm">
|
||||
Tambah Obat
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 items-end gap-2 pt-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="searchObat"
|
||||
placeholder="Cari berdasarkan Obat"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.OBAT"
|
||||
label="Urut berdasarkan:"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:ml-4">
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.OBAT"
|
||||
label="Urut berdasarkan:"
|
||||
@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>
|
||||
<RouterLink
|
||||
to="/pemberian-obat-add"
|
||||
class="btn bg-dark btn-sm self-start md:self-auto"
|
||||
>
|
||||
Tambah Obat
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ const pagination = usePagination({
|
|||
initialPage: Number(route.query.page) || 1,
|
||||
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 ageSliderRef = ref<HTMLElement | null>(null);
|
||||
const ageRange = ref<[number, number]>([0, 100]);
|
||||
|
|
@ -83,6 +86,8 @@ const updateQueryParams = () => {
|
|||
query.sortBy = sortBy.value;
|
||||
}
|
||||
|
||||
query.order = sortOrder.value;
|
||||
|
||||
if (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(),
|
||||
page: pagination.page.value.toString(),
|
||||
orderBy: sortBy.value,
|
||||
order: sortOrder.value,
|
||||
...(searchRekamMedis.value && { no_rm: searchRekamMedis.value }),
|
||||
...(filter.value.id_visit && { id_visit: filter.value.id_visit }),
|
||||
...(filter.value.nama_pasien && {
|
||||
|
|
@ -250,6 +256,10 @@ const handleSortChange = (newSortBy: string) => {
|
|||
fetchData();
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
pagination.setPageSize(newSize);
|
||||
fetchData();
|
||||
|
|
@ -281,6 +291,11 @@ watch([() => pagination.page.value], () => {
|
|||
fetchData();
|
||||
});
|
||||
|
||||
watch(sortOrder, () => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(searchRekamMedis, (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
pagination.reset();
|
||||
|
|
@ -295,6 +310,12 @@ onMounted(async () => {
|
|||
if (route.query.sortBy) {
|
||||
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);
|
||||
filter.value.rentang_umur = [ageRange.value[0], ageRange.value[1]];
|
||||
|
|
@ -540,30 +561,49 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
</div>
|
||||
<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
|
||||
class="flex flex-col items-end flex-1 px-4 pt-4 pb-2 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="searchRekamMedis"
|
||||
placeholder="Cari berdasarkan Nomor Rekam Medis"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.REKAM_MEDIS"
|
||||
label="Urut berdasarkan:"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:ml-4">
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.REKAM_MEDIS"
|
||||
label="Urut berdasarkan:"
|
||||
@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>
|
||||
<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 -->
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ const pagination = usePagination({
|
|||
initialPage: Number(route.query.page) || 1,
|
||||
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const sortOrder = ref<"asc" | "desc">(
|
||||
(route.query.order as "asc" | "desc") || "asc"
|
||||
);
|
||||
const filter = ref<{
|
||||
tindakan: string | null;
|
||||
kategori: string[];
|
||||
|
|
@ -62,6 +65,8 @@ const updateQueryParams = () => {
|
|||
query.sortBy = sortBy.value;
|
||||
}
|
||||
|
||||
query.order = sortOrder.value;
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
|
|
@ -70,6 +75,7 @@ const fetchData = async () => {
|
|||
take: pagination.pageSize.value.toString(),
|
||||
page: pagination.page.value.toString(),
|
||||
orderBy: sortBy.value,
|
||||
order: sortOrder.value,
|
||||
...(searchIdVisit.value && { id_visit: searchIdVisit.value }),
|
||||
...(filter.value.tindakan && { tindakan: filter.value.tindakan }),
|
||||
...(filter.value.kategori.length > 0
|
||||
|
|
@ -158,6 +164,10 @@ const handleSortChange = (newSortBy: string) => {
|
|||
fetchData();
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
pagination.setPageSize(newSize);
|
||||
fetchData();
|
||||
|
|
@ -189,6 +199,11 @@ watch([() => pagination.page.value], () => {
|
|||
fetchData();
|
||||
});
|
||||
|
||||
watch(sortOrder, () => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(searchIdVisit, (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
pagination.reset();
|
||||
|
|
@ -203,6 +218,12 @@ onMounted(async () => {
|
|||
if (route.query.sortBy) {
|
||||
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();
|
||||
document.title = "Tindakan Dokter - Hospital Log";
|
||||
|
|
@ -307,30 +328,49 @@ onMounted(async () => {
|
|||
</div>
|
||||
</div>
|
||||
<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
|
||||
class="flex flex-col items-end flex-1 px-4 pt-4 pb-2 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="searchIdVisit"
|
||||
placeholder="Cari berdasarkan ID Visit"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.TINDAKAN"
|
||||
label="Urut berdasarkan:"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:ml-4">
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.TINDAKAN"
|
||||
label="Urut berdasarkan:"
|
||||
@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>
|
||||
<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 -->
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ const pagination = usePagination({
|
|||
initialPage: Number(route.query.page) || 1,
|
||||
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const sortOrder = ref<"asc" | "desc">(
|
||||
(route.query.order as "asc" | "desc") || "asc"
|
||||
);
|
||||
|
||||
const updateQueryParams = () => {
|
||||
const query: Record<string, string> = {
|
||||
|
|
@ -52,6 +55,8 @@ const updateQueryParams = () => {
|
|||
query.sortBy = sortBy.value;
|
||||
}
|
||||
|
||||
query.order = sortOrder.value;
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
|
|
@ -61,6 +66,7 @@ const fetchData = async () => {
|
|||
take: pagination.pageSize.value.toString(),
|
||||
page: pagination.page.value.toString(),
|
||||
orderBy: sortBy.value,
|
||||
order: sortOrder.value,
|
||||
...(searchUsername.value ? { username: searchUsername.value } : {}),
|
||||
});
|
||||
|
||||
|
|
@ -106,6 +112,10 @@ const handleSortChange = (newSortBy: string) => {
|
|||
fetchData();
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
pagination.setPageSize(newSize);
|
||||
fetchData();
|
||||
|
|
@ -137,6 +147,11 @@ watch([() => pagination.page.value], () => {
|
|||
fetchData();
|
||||
});
|
||||
|
||||
watch(sortOrder, () => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(searchUsername, (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
pagination.reset();
|
||||
|
|
@ -145,12 +160,18 @@ watch(searchUsername, (newValue, oldValue) => {
|
|||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.query.search) {
|
||||
searchUsername.value = route.query.search as string;
|
||||
if (route.query.username) {
|
||||
searchUsername.value = route.query.username as string;
|
||||
}
|
||||
if (route.query.sortBy) {
|
||||
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();
|
||||
document.title = "Users - Hospital Log";
|
||||
|
|
@ -164,19 +185,49 @@ onMounted(async () => {
|
|||
<PageHeader title="Users" subtitle="Manajemen Pengguna" />
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md">
|
||||
<div class="flex items-center px-4 py-4 justify-between gap-4">
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.USERS"
|
||||
label="Urut berdasarkan:"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
|
||||
<SearchInput
|
||||
v-model="searchUsername"
|
||||
placeholder="Cari berdasarkan username"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<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
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.USERS"
|
||||
label="Urut berdasarkan:"
|
||||
@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>
|
||||
<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>
|
||||
|
||||
<!-- Data Table -->
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user