feat: (+) add total audit data, jumlah rekam medis 7 hari terakhir, data audit trail (tampered & not tampered), data yang perlu divalidasi, tamtampered data per kelompok data pada dashboard. (+) fix validation payload on rekam medis, add validation on rekam medis create, update, and delete

This commit is contained in:
yosaphatprs 2025-11-25 16:26:00 +07:00
parent 3903191cdc
commit 433e6889cd
45 changed files with 2280 additions and 254 deletions

View File

@ -1,25 +1,25 @@
{ {
"cursors": { "cursors": {
"pemberian_obat": "5", "pemberian_obat": "10",
"rekam_medis": "100079", "rekam_medis": "1001724",
"pemberian_tindakan": "5" "pemberian_tindakan": "10"
}, },
"failures": {}, "failures": {},
"metadata": { "metadata": {
"pemberian_obat": { "pemberian_obat": {
"lastRunAt": "2025-11-14T03:35:57.788Z", "lastRunAt": "2025-11-21T02:36:13.102Z",
"processed": 5, "processed": 5,
"success": 5, "success": 5,
"failed": 0 "failed": 0
}, },
"rekam_medis": { "rekam_medis": {
"lastRunAt": "2025-11-14T03:35:57.788Z", "lastRunAt": "2025-11-21T02:36:13.102Z",
"processed": 5, "processed": 5,
"success": 5, "success": 5,
"failed": 0 "failed": 0
}, },
"pemberian_tindakan": { "pemberian_tindakan": {
"lastRunAt": "2025-11-14T03:35:57.788Z", "lastRunAt": "2025-11-21T02:36:13.102Z",
"processed": 5, "processed": 5,
"success": 5, "success": 5,
"failed": 0 "failed": 0

View File

@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "TableName" AS ENUM ('pemberian_obat', 'pemberian_tindakan', 'rekam_medis');
-- CreateEnum
CREATE TYPE "ValidationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ValidationAction" AS ENUM ('CREATE', 'UPDATE', 'DELETE');
-- CreateTable
CREATE TABLE "validation_queue" (
"id" BIGSERIAL NOT NULL,
"table_name" "TableName" NOT NULL,
"record_id" VARCHAR(50) NOT NULL,
"action" "ValidationAction" NOT NULL,
"dataPayload" JSONB NOT NULL,
"user_id_request" BIGINT NOT NULL,
"user_id_process" BIGINT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processed_at" TIMESTAMPTZ(6),
CONSTRAINT "validation_queue_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_user" FOREIGN KEY ("user_id_request") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_user_process" FOREIGN KEY ("user_id_process") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "validation_queue" ADD COLUMN "status" "ValidationStatus" NOT NULL DEFAULT 'PENDING';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "validation_queue" ALTER COLUMN "record_id" SET DEFAULT '';

View File

@ -74,6 +74,8 @@ model users {
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6) updated_at DateTime? @default(now()) @db.Timestamptz(6)
blockchain_log_queue blockchain_log_queue[] blockchain_log_queue blockchain_log_queue[]
validation_queue_requested validation_queue[] @relation("ValidationRequester")
validation_queue_processed validation_queue[] @relation("ValidationProcessor")
} }
enum AuditEvent { enum AuditEvent {
@ -102,3 +104,36 @@ model audit {
last_sync DateTime @default(now()) @db.Timestamptz(6) last_sync DateTime @default(now()) @db.Timestamptz(6)
result resultStatus result resultStatus
} }
enum TableName {
pemberian_obat
pemberian_tindakan
rekam_medis
}
enum ValidationStatus {
PENDING
APPROVED
REJECTED
}
enum ValidationAction {
CREATE
UPDATE
DELETE
}
model validation_queue {
id BigInt @id @default(autoincrement())
table_name TableName
record_id String @db.VarChar(50) @default("")
action ValidationAction
dataPayload Json
user_id_request BigInt
user_id_process BigInt?
status ValidationStatus @default(PENDING)
created_at DateTime @default(now()) @db.Timestamptz(6)
processed_at DateTime? @db.Timestamptz(6)
users users @relation("ValidationRequester", fields: [user_id_request], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_validation_user")
users_process users? @relation("ValidationProcessor", fields: [user_id_process], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_validation_user_process")
}

View File

@ -12,7 +12,7 @@ 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'; import { AuditModule } from './modules/audit/audit.module';
import { ProofModule } from './modules/proof/proof.module'; import { ProofModule } from './modules/proof/proof.module';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { ValidationModule } from './modules/validation/validation.module';
@Module({ @Module({
imports: [ imports: [
@ -29,7 +29,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
FabricModule, FabricModule,
AuditModule, AuditModule,
ProofModule, ProofModule,
EventEmitterModule.forRoot(), ValidationModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -4,6 +4,8 @@ import { TindakanDokterService } from './modules/tindakandokter/tindakandokter.s
import { RekammedisService } from './modules/rekammedis/rekammedis.service'; import { RekammedisService } from './modules/rekammedis/rekammedis.service';
import { ObatService } from './modules/obat/obat.service'; import { ObatService } from './modules/obat/obat.service';
import { LogService } from './modules/log/log.service'; import { LogService } from './modules/log/log.service';
import { AuditService } from './modules/audit/audit.service';
import { ValidationService } from './modules/validation/validation.service';
@Injectable() @Injectable()
export class AppService { export class AppService {
@ -13,6 +15,8 @@ export class AppService {
private tindakanDokterService: TindakanDokterService, private tindakanDokterService: TindakanDokterService,
private obatService: ObatService, private obatService: ObatService,
private logService: LogService, private logService: LogService,
private auditService: AuditService,
private validationService: ValidationService,
) {} ) {}
getHello(): string { getHello(): string {
@ -24,6 +28,19 @@ export class AppService {
const countTindakanDokter = const countTindakanDokter =
await this.tindakanDokterService.countTindakanDokter(); await this.tindakanDokterService.countTindakanDokter();
const countObat = await this.obatService.countObat(); const countObat = await this.obatService.countObat();
return { countRekamMedis, countTindakanDokter, countObat }; const auditTrailData = await this.auditService.getCountAuditTamperedData();
const validasiData =
await this.validationService.getAllValidationQueueDashboard();
const last7DaysRekamMedis =
await this.rekamMedisService.getLast7DaysCount();
return {
countRekamMedis,
countTindakanDokter,
countObat,
auditTrailData,
validasiData,
last7DaysRekamMedis,
};
} }
} }

View File

@ -19,6 +19,7 @@ async function bootstrap() {
'https://64spbch3-5174.asse.devtunnels.ms', 'https://64spbch3-5174.asse.devtunnels.ms',
'http://localhost:5173', 'http://localhost:5173',
'http://localhost:5174', 'http://localhost:5174',
'http://localhost:5175',
], ],
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],

View File

@ -9,15 +9,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '../auth/guard/auth.guard'; import { AuthGuard } from '../auth/guard/auth.guard';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
import { fromEvent, interval, map, Observable } from 'rxjs';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Controller('audit') @Controller('audit')
export class AuditController { export class AuditController {
constructor( constructor(private readonly auditService: AuditService) {}
private readonly auditService: AuditService,
private eventEmitter: EventEmitter2,
) {}
@Get('/trail') @Get('/trail')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@ -44,14 +39,4 @@ export class AuditController {
this.auditService.storeAuditTrail(); this.auditService.storeAuditTrail();
return { message: 'Proses audit trail dijalankan', status: 'STARTED' }; return { message: 'Proses audit trail dijalankan', status: 'STARTED' };
} }
@Sse('stream')
@UseGuards(AuthGuard)
auditStream(): Observable<MessageEvent> {
return fromEvent(this.eventEmitter, 'audit.*').pipe(
map((data: any) => {
return new MessageEvent('message', { data: data });
}),
);
}
} }

View File

@ -5,7 +5,11 @@ import { WebsocketGuard } from '../auth/guard/websocket.guard';
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: {
origin: 'https://64spbch3-5173.asse.devtunnels.ms', origin: [
'https://64spbch3-5173.asse.devtunnels.ms',
'http://localhost:5173',
'http://localhost:5175',
],
credentials: true, credentials: true,
}, },
}) })

View File

@ -21,5 +21,6 @@ import { WebsocketGuard } from '../auth/guard/websocket.guard';
], ],
providers: [AuditService, AuditGateway, WebsocketGuard], providers: [AuditService, AuditGateway, WebsocketGuard],
controllers: [AuditController], controllers: [AuditController],
exports: [AuditService],
}) })
export class AuditModule {} export class AuditModule {}

View File

@ -4,7 +4,7 @@ import { LogService } from '../log/log.service';
import { ObatService } from '../obat/obat.service'; import { ObatService } from '../obat/obat.service';
import { RekammedisService } from '../rekammedis/rekammedis.service'; import { RekammedisService } from '../rekammedis/rekammedis.service';
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service'; import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
import { sha256 } from '@api/common/crypto/hash';
import type { import type {
AuditEvent, AuditEvent,
resultStatus as ResultStatus, resultStatus as ResultStatus,
@ -54,7 +54,6 @@ export class AuditService {
tampered = undefined; tampered = undefined;
} }
console.log(type, tampered);
const auditLogs = await this.prisma.audit.findMany({ const auditLogs = await this.prisma.audit.findMany({
take: pageSize, take: pageSize,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
@ -66,8 +65,6 @@ export class AuditService {
}, },
}); });
console.log(auditLogs);
const count = await this.prisma.audit.count({ const count = await this.prisma.audit.count({
where: { where: {
id: type && type !== 'all' ? { startsWith: type } : undefined, id: type && type !== 'all' ? { startsWith: type } : undefined,
@ -182,6 +179,54 @@ export class AuditService {
} }
} }
async getCountAuditTamperedData() {
const auditTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
},
});
const auditNonTamperedCount = await this.prisma.audit.count({
where: {
result: 'non_tampered',
},
});
const rekamMedisTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
id: {
startsWith: 'REKAM',
},
},
});
const tindakanDokterTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
id: {
startsWith: 'TINDAKAN',
},
},
});
const obatTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
id: {
startsWith: 'OBAT',
},
},
});
return {
auditTamperedCount,
auditNonTamperedCount,
rekamMedisTamperedCount,
tindakanDokterTamperedCount,
obatTamperedCount,
};
}
private async buildAuditRecord( private async buildAuditRecord(
logEntry: any, logEntry: any,
index?: number, index?: number,

View File

@ -16,7 +16,7 @@ import { JwtModule } from '@nestjs/jwt';
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '60m' }, signOptions: { expiresIn: '120m' },
}), }),
}), }),
], ],

View File

@ -71,63 +71,63 @@ export class LogService {
return this.fabricService.getLogsWithPagination(pageSize, bookmark); return this.fabricService.getLogsWithPagination(pageSize, bookmark);
} }
async storeFromDBToBlockchain() {} // async storeFromDBToBlockchain() {}
// async storeFromDBToBlockchain( async storeFromDBToBlockchain(
// limitPerEntity = 5, limitPerEntity = 5,
// batchSize = 1, batchSize = 1,
// ): Promise<{ ): Promise<{
// summaries: Record<string, BackfillSummary>; summaries: Record<string, BackfillSummary>;
// checkpointFile: string; checkpointFile: string;
// }> { }> {
// const state = await this.loadState(); const state = await this.loadState();
// const summaries = { const summaries = {
// pemberian_obat: await this.syncPemberianObat( pemberian_obat: await this.syncPemberianObat(
// state, state,
// limitPerEntity, limitPerEntity,
// batchSize, batchSize,
// ), ),
// rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize), rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize),
// pemberian_tindakan: await this.syncPemberianTindakan( pemberian_tindakan: await this.syncPemberianTindakan(
// state, state,
// limitPerEntity, limitPerEntity,
// batchSize, batchSize,
// ), ),
// } as Record<EntityKey, BackfillSummary>; } as Record<EntityKey, BackfillSummary>;
// const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
// await this.persistState({ await this.persistState({
// ...state, ...state,
// metadata: { metadata: {
// ...(state.metadata ?? {}), ...(state.metadata ?? {}),
// pemberian_obat: { pemberian_obat: {
// lastRunAt: timestamp, lastRunAt: timestamp,
// processed: summaries.pemberian_obat.processed, processed: summaries.pemberian_obat.processed,
// success: summaries.pemberian_obat.success, success: summaries.pemberian_obat.success,
// failed: summaries.pemberian_obat.failed, failed: summaries.pemberian_obat.failed,
// }, },
// rekam_medis: { rekam_medis: {
// lastRunAt: timestamp, lastRunAt: timestamp,
// processed: summaries.rekam_medis.processed, processed: summaries.rekam_medis.processed,
// success: summaries.rekam_medis.success, success: summaries.rekam_medis.success,
// failed: summaries.rekam_medis.failed, failed: summaries.rekam_medis.failed,
// }, },
// pemberian_tindakan: { pemberian_tindakan: {
// lastRunAt: timestamp, lastRunAt: timestamp,
// processed: summaries.pemberian_tindakan.processed, processed: summaries.pemberian_tindakan.processed,
// success: summaries.pemberian_tindakan.success, success: summaries.pemberian_tindakan.success,
// failed: summaries.pemberian_tindakan.failed, failed: summaries.pemberian_tindakan.failed,
// }, },
// }, },
// }); });
// return { return {
// summaries, summaries,
// checkpointFile: this.statePath, checkpointFile: this.statePath,
// }; };
// } }
private async syncPemberianObat( private async syncPemberianObat(
state: BackfillState, state: BackfillState,

View File

@ -96,20 +96,30 @@ export class ProofService {
const payloadHash = sha256(JSON.stringify(payload)); const payloadHash = sha256(JSON.stringify(payload));
const response = { // const response = {
// id: `PROOF_${payload.id_visit}`,
// event: 'proof_verification_logged',
// user_id: 'External',
// payload: payloadHash,
// };
const responseData = {
id: `PROOF_${payload.id_visit}`, id: `PROOF_${payload.id_visit}`,
event: 'proof_verification_logged', event: 'proof_verification_logged',
user_id: 'External', user_id: 'External',
payload: payloadHash, payload: payloadHash,
}; };
// const response = await this.logService.storeLog({ const response = await this.logService.storeLog({
// id: `PROOF_${payload.id_visit}`, id: `PROOF_${payload.id_visit}`,
// event: 'proof_verification_logged', event: 'proof_verification_logged',
// user_id: 'External', user_id: 'External',
// payload: payloadHash, payload: payloadHash,
// }); });
return response; return {
response,
responseData,
};
} }
} }

View File

@ -112,4 +112,8 @@ export class CreateRekamMedisDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
tindak_lanjut?: string; tindak_lanjut?: string;
@IsOptional()
@IsDateString({}, { message: 'Waktu visit harus berupa tanggal yang valid' })
waktu_visit?: string;
} }

View File

@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Header, Header,
HttpCode, HttpCode,
@ -98,4 +99,14 @@ export class RekamMedisController {
) { ) {
return this.rekammedisService.updateRekamMedis(id_visit, dto, user); return this.rekammedisService.updateRekamMedis(id_visit, dto, user);
} }
@Delete('/:id_visit')
@Header('Content-Type', 'application/json')
@UseGuards(AuthGuard)
async deleteRekamMedis(
@Param('id_visit') id_visit: string,
@CurrentUser() user: ActiveUserPayload,
) {
return this.rekammedisService.deleteRekamMedisByIdVisit(id_visit, user);
}
} }

View File

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { RekamMedisController } from './rekammedis.controller'; import { RekamMedisController } from './rekammedis.controller';
import { RekammedisService } from './rekammedis.service'; import { RekammedisService } from './rekammedis.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';
import { LogModule } from '../log/log.module'; import { LogModule } from '../log/log.module';
@Module({ @Module({

View File

@ -6,7 +6,6 @@ import { LogService } from '../log/log.service';
import { sha256 } from '@api/common/crypto/hash'; import { sha256 } from '@api/common/crypto/hash';
import { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; import { ActiveUserPayload } from '../auth/decorator/current-user.decorator';
import { PayloadRekamMedisDto } from './dto/payload-rekammedis.dto'; import { PayloadRekamMedisDto } from './dto/payload-rekammedis.dto';
// import { CreateLogDto } from '../log/dto/create-log.dto';
@Injectable() @Injectable()
export class RekammedisService { export class RekammedisService {
@ -251,7 +250,10 @@ export class RekammedisService {
}); });
} }
async createRekamMedis(data: CreateRekamMedisDto, user: ActiveUserPayload) { async createRekamMedisToDBAndBlockchain(
data: CreateRekamMedisDto,
userId: number,
) {
const latestId = await this.prisma.rekam_medis.findFirst({ const latestId = await this.prisma.rekam_medis.findFirst({
orderBy: { waktu_visit: 'desc' }, orderBy: { waktu_visit: 'desc' },
}); });
@ -303,7 +305,7 @@ export class RekammedisService {
const data = { const data = {
id: `REKAM_${newId}`, id: `REKAM_${newId}`,
event: 'rekam_medis_created', event: 'rekam_medis_created',
user_id: user.sub, user_id: userId.toString(),
payload: payloadHash, payload: payloadHash,
}; };
@ -322,6 +324,30 @@ export class RekammedisService {
} }
} }
async createRekamMedis(data: CreateRekamMedisDto, user: ActiveUserPayload) {
const rekamMedis = {
...data,
waktu_visit: new Date(),
};
try {
const response = await this.prisma.validation_queue.create({
data: {
table_name: 'rekam_medis',
action: 'CREATE',
dataPayload: JSON.parse(JSON.stringify(rekamMedis)),
user_id_request: user.sub,
status: 'PENDING',
},
});
return response;
} catch (error) {
console.error('Error creating validation queue:', error);
throw error;
}
}
async getRekamMedisLogById(id_visit: string) { async getRekamMedisLogById(id_visit: string) {
const idLog = `REKAM_${id_visit}`; const idLog = `REKAM_${id_visit}`;
const rawLogs = await this.log.getLogById(idLog); const rawLogs = await this.log.getLogById(idLog);
@ -354,10 +380,10 @@ export class RekammedisService {
}; };
} }
async updateRekamMedis( async updateRekamMedisToDBAndBlockchain(
id_visit: string, id_visit: string,
data: CreateRekamMedisDto, data: CreateRekamMedisDto,
user: ActiveUserPayload, user_id_request: number,
) { ) {
const rekamMedis = await this.prisma.rekam_medis.update({ const rekamMedis = await this.prisma.rekam_medis.update({
where: { id_visit }, where: { id_visit },
@ -382,7 +408,7 @@ export class RekammedisService {
const logDto = { const logDto = {
id: `REKAM_${id_visit}`, id: `REKAM_${id_visit}`,
event: 'rekam_medis_updated', event: 'rekam_medis_updated',
user_id: user.sub, user_id: user_id_request.toString(),
payload: payloadHash, payload: payloadHash,
}; };
@ -394,6 +420,30 @@ export class RekammedisService {
}; };
} }
async updateRekamMedis(
id_visit: string,
data: CreateRekamMedisDto,
user: ActiveUserPayload,
) {
try {
const response = await this.prisma.validation_queue.create({
data: {
table_name: 'rekam_medis',
action: 'UPDATE',
record_id: id_visit,
dataPayload: JSON.parse(JSON.stringify({ ...data })),
user_id_request: user.sub,
status: 'PENDING',
},
});
return response;
} catch (error) {
console.error('Error updating validation queue:', error);
throw error;
}
}
async getAgeByIdVisit(id_visit: string) { async getAgeByIdVisit(id_visit: string) {
let age: number | null = null; let age: number | null = null;
try { try {
@ -413,6 +463,91 @@ export class RekammedisService {
return age; return age;
} }
async deleteRekamMedisByIdVisit(id_visit: string, user: ActiveUserPayload) {
const data = await this.getRekamMedisById(id_visit);
if (!data) {
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
}
try {
const response = await this.prisma.validation_queue.create({
data: {
table_name: 'rekam_medis',
action: 'DELETE',
record_id: id_visit,
dataPayload: data,
user_id_request: user.sub,
status: 'PENDING',
},
});
return response;
} catch (error) {
console.error('Error deleting validation queue:', error);
throw error;
}
}
async deleteRekamMedisFromDB(id_visit: string, user: ActiveUserPayload) {
try {
const deletedRekamMedis = await this.prisma.rekam_medis.delete({
where: { id_visit },
});
return deletedRekamMedis;
} catch (error) {
console.error('Error deleting Rekam Medis:', error);
throw error;
}
}
async getLast7DaysCount() {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);
sevenDaysAgo.setHours(0, 0, 0, 0);
const count = await this.prisma.rekam_medis.count({
where: {
waktu_visit: {
gte: sevenDaysAgo,
},
},
});
const dailyCounts = await this.prisma.rekam_medis.groupBy({
by: ['waktu_visit'],
where: {
waktu_visit: {
gte: sevenDaysAgo,
},
},
_count: {
id_visit: true,
},
});
const dailyCountsMap = dailyCounts.reduce(
(acc, item) => {
const date = new Date(item.waktu_visit).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + item._count.id_visit;
return acc;
},
{} as Record<string, number>,
);
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (6 - i));
return date.toISOString().split('T')[0];
});
const countsByDay = last7Days.map((date) => ({
date,
count: dailyCountsMap[date] || 0,
}));
return {
total: count,
byDay: countsByDay,
};
}
async countRekamMedis() { async countRekamMedis() {
return this.prisma.rekam_medis.count(); return this.prisma.rekam_medis.count();
} }

View File

@ -51,6 +51,7 @@ export class TindakanDokterController {
@Body() data: CreateTindakanDokterDto, @Body() data: CreateTindakanDokterDto,
@CurrentUser() user: ActiveUserPayload, @CurrentUser() user: ActiveUserPayload,
) { ) {
// console.log('APASIH');
return await this.tindakanDokterService.createTindakanDokter(data, user); return await this.tindakanDokterService.createTindakanDokter(data, user);
} }

View File

@ -3,6 +3,7 @@ import { TindakanDokterController } from './tindakandokter.controller';
import { TindakanDokterService } from './tindakandokter.service'; import { TindakanDokterService } from './tindakandokter.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { LogModule } from '../log/log.module'; import { LogModule } from '../log/log.module';
import { ValidationModule } from '../validation/validation.module';
@Module({ @Module({
imports: [PrismaModule, LogModule], imports: [PrismaModule, LogModule],

View File

@ -120,35 +120,61 @@ export class TindakanDokterService {
); );
} }
const createdTindakan = await this.prisma.pemberian_tindakan.create({ const response = await this.prisma.validation_queue.create({
data: { data: {
id_visit: dto.id_visit, table_name: 'pemberian_tindakan',
tindakan: dto.tindakan, action: 'CREATE',
kategori_tindakan: dto.kategori_tindakan ?? null, dataPayload: {
kelompok_tindakan: dto.kelompok_tindakan ?? null, id_visit: dto.id_visit,
tindakan: dto.tindakan,
kategori_tindakan: dto.kategori_tindakan ?? null,
kelompok_tindakan: dto.kelompok_tindakan ?? null,
},
user_id_request: user.sub,
status: 'PENDING',
}, },
}); });
const hashingPayload = this.createHashingPayload({ // const createValidate = await this.validationService.storeQueueValidation(
id_visit: createdTindakan.id_visit, // {
tindakan: createdTindakan.tindakan, // table_name: 'pemberian_tindakan',
kategori_tindakan: createdTindakan.kategori_tindakan ?? null, // record_id: null,
kelompok_tindakan: createdTindakan.kelompok_tindakan ?? null, // dataPayload: {
}); // id_visit: dto.id_visit,
// tindakan: dto.tindakan,
// kategori_tindakan: dto.kategori_tindakan ?? null,
// kelompok_tindakan: dto.kelompok_tindakan ?? null,
// },
// },
// user,
// );
const logPayloadHash = hashingPayload; // const createdTindakan = await this.prisma.pemberian_tindakan.create({
// data: {
// id_visit: dto.id_visit,
// tindakan: dto.tindakan,
// kategori_tindakan: dto.kategori_tindakan ?? null,
// kelompok_tindakan: dto.kelompok_tindakan ?? null,
// },
// });
const logResult = await this.logService.storeLog({ // const hashingPayload = this.createHashingPayload({
id: `TINDAKAN_${createdTindakan.id}`, // id_visit: createdTindakan.id_visit,
event: 'tindakan_dokter_created', // tindakan: createdTindakan.tindakan,
user_id: user.sub, // kategori_tindakan: createdTindakan.kategori_tindakan ?? null,
payload: logPayloadHash, // kelompok_tindakan: createdTindakan.kelompok_tindakan ?? null,
}); // });
return { // const logPayloadHash = hashingPayload;
...createdTindakan,
log: logResult, // const logResult = await this.logService.storeLog({
}; // id: `TINDAKAN_${createdTindakan.id}`,
// event: 'tindakan_dokter_created',
// user_id: user.sub,
// payload: logPayloadHash,
// });
return response;
} }
async getTindakanDokterById(id: number) { async getTindakanDokterById(id: number) {
@ -217,29 +243,51 @@ export class TindakanDokterService {
: {}), : {}),
}; };
const updatedTindakan = await this.prisma.pemberian_tindakan.update({ const validationQueue = await this.prisma.validation_queue.create({
where: { id: tindakanId }, data: {
data: updateData, table_name: 'pemberian_tindakan',
action: 'UPDATE',
dataPayload: {
...(dto.id_visit !== undefined ? { id_visit: dto.id_visit } : {}),
...(dto.tindakan !== undefined ? { tindakan: dto.tindakan } : {}),
...(dto.kategori_tindakan !== undefined
? { kategori_tindakan: dto.kategori_tindakan ?? null }
: {}),
...(dto.kelompok_tindakan !== undefined
? { kelompok_tindakan: dto.kelompok_tindakan ?? null }
: {}),
},
record_id: tindakanId.toString(),
user_id_request: user.sub,
status: 'PENDING',
},
}); });
const hashingPayload = this.createHashingPayload({ return validationQueue;
id_visit: updatedTindakan.id_visit,
tindakan: updatedTindakan.tindakan,
kategori_tindakan: updatedTindakan.kategori_tindakan ?? null,
kelompok_tindakan: updatedTindakan.kelompok_tindakan ?? null,
});
const logResult = await this.logService.storeLog({ // const updatedTindakan = await this.prisma.pemberian_tindakan.update({
id: `TINDAKAN_${tindakanId}`, // where: { id: tindakanId },
event: 'tindakan_dokter_updated', // data: updateData,
user_id: user.sub, // });
payload: hashingPayload,
});
return { // const hashingPayload = this.createHashingPayload({
...updatedTindakan, // id_visit: updatedTindakan.id_visit,
log: logResult, // tindakan: updatedTindakan.tindakan,
}; // kategori_tindakan: updatedTindakan.kategori_tindakan ?? null,
// kelompok_tindakan: updatedTindakan.kelompok_tindakan ?? null,
// });
// const logResult = await this.logService.storeLog({
// id: `TINDAKAN_${tindakanId}`,
// event: 'tindakan_dokter_updated',
// user_id: user.sub,
// payload: hashingPayload,
// });
// return {
// ...updatedTindakan,
// log: logResult,
// };
} }
async getTindakanLogById(id: string) { async getTindakanLogById(id: string) {

View File

@ -0,0 +1,49 @@
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
MaxLength,
IsNumber,
} from 'class-validator';
const TABLE_NAME_VALUES = [
'pemberian_obat',
'pemberian_tindakan',
'rekam_medis',
] as const;
const VALIDATION_ACTION_VALUES = ['CREATE', 'UPDATE', 'DELETE'] as const;
const VALIDATION_STATUS_VALUES = ['PENDING', 'APPROVED', 'REJECTED'] as const;
export type TableName = (typeof TABLE_NAME_VALUES)[number];
export type ValidationAction = (typeof VALIDATION_ACTION_VALUES)[number];
export type ValidationStatus = (typeof VALIDATION_STATUS_VALUES)[number];
export class StoreValidationQueueDto {
@IsNotEmpty({ message: 'Nama tabel wajib diisi' })
@IsEnum(TABLE_NAME_VALUES, { message: 'Nama tabel tidak valid' })
table_name: TableName;
@IsString({ message: 'ID record harus berupa string' })
@MaxLength(50, { message: 'ID record maksimal 50 karakter' })
record_id: string | null;
@IsNotEmpty({ message: 'Aksi wajib diisi' })
@IsEnum(VALIDATION_ACTION_VALUES, { message: 'Aksi tidak valid' })
action: ValidationAction;
@IsNotEmpty({ message: 'Payload data wajib diisi' })
@IsObject({ message: 'Payload data harus berupa objek' })
dataPayload: Record<string, unknown>;
@IsNotEmpty({ message: 'User pemohon wajib diisi' })
@IsNumber({}, { message: 'User pemohon harus berupa angka' })
user_id_request: number;
@IsOptional()
@IsEnum(VALIDATION_STATUS_VALUES, { message: 'Status tidak valid' })
status?: ValidationStatus;
}

View File

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

View File

@ -0,0 +1,62 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/guard/auth.guard';
import { ValidationService } from './validation.service';
import { CurrentUser } from '../auth/decorator/current-user.decorator';
import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator';
@Controller('/validation')
export class ValidationController {
constructor(private readonly validationService: ValidationService) {}
@Get('/')
@UseGuards(AuthGuard)
async getValidationStatus() {
return this.validationService.getAllValidationsQueue();
}
@Get('/:id')
@UseGuards(AuthGuard)
async getValidationById(@Param('id') id: number) {
return this.validationService.getValidationQueue(id);
}
@Post('/:id/approve')
@UseGuards(AuthGuard)
async approveValidation(
@Param('id') id: number,
@CurrentUser() user: ActiveUserPayload,
) {
return this.validationService.approveValidation(id, user);
}
@Post('/:id/reject')
@UseGuards(AuthGuard)
async rejectValidation(
@Param('id') id: number,
@CurrentUser() user: ActiveUserPayload,
) {
return this.validationService.rejectValidation(id, user);
}
// @Post('/')
// @UseGuards(AuthGuard)
// async storeValidationQueue(
// @Body() dataPayload: StoreValidationQueueDto,
// @CurrentUser() user: ActiveUserPayload,
// ) {
// return this.validationService.storeQueueValidation(dataPayload, user);
// }
// @Post('/process/:id')
// @UseGuards(AuthGuard)
// async processValidationQueue(
// // @Body('status') status: StoreValidationQueueDto['status'],
// // @CurrentUser() user: ActiveUserPayload,
// // @Body('id') id: string,
// @Body() data: StoreValidationQueueDto,
// @CurrentUser() user: ActiveUserPayload,
// ) {
// const { status, record_id: id } = data;
// return this.validationService.processValidationQueue(data, user);
// }
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ValidationController } from './validation.controller';
import { ValidationService } from './validation.service';
import { PrismaModule } from '../prisma/prisma.module';
import { LogModule } from '../log/log.module';
import { RekamMedisModule } from '../rekammedis/rekammedis.module';
@Module({
imports: [PrismaModule, LogModule, RekamMedisModule],
controllers: [ValidationController],
providers: [ValidationService],
exports: [ValidationService],
})
export class ValidationModule {}

View File

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

View File

@ -0,0 +1,168 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActiveUserPayload } from '../auth/decorator/current-user.decorator';
import { RekammedisService } from '../rekammedis/rekammedis.service';
import { CreateRekamMedisDto } from '../rekammedis/dto/create-rekammedis.dto';
@Injectable()
export class ValidationService {
constructor(
private prisma: PrismaService,
private rekamMedisService: RekammedisService,
) {}
private handlers: Record<
string,
{
approveCreate?: (queue: any) => Promise<any>;
approveUpdate?: (queue: any) => Promise<any>;
approveDelete?: (queue: any) => Promise<any>;
}
> = {
rekam_medis: {
approveCreate: async (queue: any) => {
const payload = queue.dataPayload as Partial<CreateRekamMedisDto>;
return this.rekamMedisService.createRekamMedisToDBAndBlockchain(
payload as CreateRekamMedisDto,
Number(queue.user_id_request),
);
},
approveUpdate: async (queue: any) => {
const payload = queue.dataPayload as Partial<CreateRekamMedisDto>;
return this.rekamMedisService.updateRekamMedisToDBAndBlockchain(
queue.record_id,
payload as CreateRekamMedisDto,
Number(queue.user_id_request),
);
},
approveDelete: async (queue: any) => {
return this.rekamMedisService.deleteRekamMedisFromDB(queue.record_id, {
sub: queue.user_id_request.toString(),
} as ActiveUserPayload);
},
},
pemberian_tindakan: {},
pemberian_obat: {},
};
async getAllValidationsQueue() {
const result = await this.prisma.validation_queue.findMany({
where: { status: 'PENDING' },
});
const totalCount = await this.prisma.validation_queue.count({
where: { status: 'PENDING' },
});
return { data: result, totalCount };
}
async getAllValidationQueueDashboard() {
const result = await this.prisma.validation_queue.findMany({
where: { status: 'PENDING' },
take: 5,
orderBy: { created_at: 'desc' },
});
const totalCount = await this.prisma.validation_queue.count({
where: { status: 'PENDING' },
});
return { data: result, totalCount };
}
async getValidationQueueById(id: number) {
return await this.prisma.validation_queue.findUnique({
where: { id },
});
}
async getValidationQueue(id: number) {
const result = await this.getValidationQueueById(id);
if (!result) return null;
if (result.action !== 'UPDATE') return result;
const previousLog = await this.fetchPreviousLog(
result.table_name,
result.record_id,
);
return { previousLog, ...result };
}
private async fetchPreviousLog(tableName: string, recordId: string) {
if (!recordId) return null;
switch (tableName) {
case 'pemberian_tindakan':
return this.prisma.pemberian_tindakan.findUnique({
where: { id: Number(recordId) },
});
case 'rekam_medis':
return this.prisma.rekam_medis.findUnique({
where: { id_visit: recordId },
});
case 'pemberian_obat':
return this.prisma.pemberian_obat.findUnique({
where: { id: Number(recordId) },
});
default:
return null;
}
}
private async approveWithHandler(queue: any) {
const handler = this.handlers[queue.table_name];
if (!handler) throw new Error('Unsupported table');
switch (queue.action) {
case 'CREATE':
if (!handler.approveCreate)
throw new Error('Create not implemented for table');
return handler.approveCreate(queue);
case 'UPDATE':
if (!handler.approveUpdate)
throw new Error('Update not implemented for table');
return handler.approveUpdate(queue);
case 'DELETE':
if (!handler.approveDelete)
throw new Error('Delete not implemented for table');
return handler.approveDelete(queue);
default:
throw new Error('Unknown action');
}
}
async approveValidation(id: number, user: ActiveUserPayload) {
const validationQueue = await this.getValidationQueueById(id);
if (!validationQueue) {
throw new Error('Validation queue not found');
}
if (!validationQueue.dataPayload) {
throw new Error('Data payload is missing');
}
try {
const approvalResult = await this.approveWithHandler(validationQueue);
const updated = await this.prisma.validation_queue.update({
where: { id: validationQueue.id },
data: {
status: 'APPROVED',
user_id_process: Number(user.sub),
processed_at: new Date(),
},
});
return { ...updated, approvalResult };
} catch (error) {
console.error('Error approving validation:', (error as Error).message);
throw error;
}
}
async rejectValidation(id: number, user: ActiveUserPayload) {
const validationQueue = await this.getValidationQueueById(id);
if (!validationQueue) {
throw new Error('Validation queue not found');
}
const updated = await this.prisma.validation_queue.update({
where: { id: validationQueue.id },
data: {
status: 'REJECTED',
user_id_process: Number(user.sub),
processed_at: new Date(),
},
});
return updated;
}
}

View File

@ -11,11 +11,13 @@
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"cally": "^0.8.0", "cally": "^0.8.0",
"chart.js": "^4.5.1",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"nouislider": "^15.8.1", "nouislider": "^15.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "4", "vue-router": "4",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
@ -112,6 +114,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@oxc-project/runtime": { "node_modules/@oxc-project/runtime": {
"version": "0.92.0", "version": "0.92.0",
"license": "MIT", "license": "MIT",
@ -774,6 +782,18 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/copy-anything": { "node_modules/copy-anything": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@ -1441,6 +1461,16 @@
} }
} }
}, },
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.6.3", "version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",

View File

@ -12,11 +12,13 @@
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"cally": "^0.8.0", "cally": "^0.8.0",
"chart.js": "^4.5.1",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"nouislider": "^15.8.1", "nouislider": "^15.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "4", "vue-router": "4",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },

View File

@ -26,6 +26,8 @@ const deleteDialogRef = ref<InstanceType<typeof DialogConfirm> | null>(null);
const pendingDeleteItem = ref<T | null>(null); const pendingDeleteItem = ref<T | null>(null);
const hasStatusColumn = () => props.columns.some((col) => col.key === "status"); const hasStatusColumn = () => props.columns.some((col) => col.key === "status");
const hasUserIdProcessColumn = () =>
props.columns.some((col) => col.key === "user_id_process");
const formatCellValue = (item: T, columnKey: keyof T) => { const formatCellValue = (item: T, columnKey: keyof T) => {
const value = item[columnKey]; const value = item[columnKey];
@ -132,6 +134,15 @@ const handleDeleteCancel = () => {
> >
{{ formatCellValue(item, column.key) }} {{ formatCellValue(item, column.key) }}
</td> </td>
<td v-if="hasUserIdProcessColumn()">
<RouterLink
v-if="item.user_id_process"
:to="`validasi/${item.id}`"
class="text-dark hover:underline hover:text-white"
>
Review
</RouterLink>
</td>
<td v-if="!hasStatusColumn()"> <td v-if="!hasStatusColumn()">
<div class="flex gap-2"> <div class="flex gap-2">
<!-- Details Button --> <!-- Details Button -->
@ -213,6 +224,8 @@ const handleDeleteCancel = () => {
:message=" :message="
pendingDeleteItem?.id pendingDeleteItem?.id
? `Apakah Anda yakin ingin menghapus item dengan ID ${pendingDeleteItem.id}?` ? `Apakah Anda yakin ingin menghapus item dengan ID ${pendingDeleteItem.id}?`
: pendingDeleteItem?.id_visit
? `Apakah Anda yakin ingin menghapus item dengan ID Visit ${pendingDeleteItem?.id_visit}?`
: 'Apakah Anda yakin ingin menghapus item ini?' : 'Apakah Anda yakin ingin menghapus item ini?'
" "
confirm-text="Hapus" confirm-text="Hapus"

View File

@ -233,6 +233,38 @@ const isActive = (routeName: string) => {
</button> </button>
</li> </li>
<!-- Validasi -->
<li>
<button
:class="[
'mb-1 is-drawer-close:tooltip is-drawer-close:tooltip-right active:bg-dark',
isActive('validasi') ? 'bg-dark text-white hover:bg-dark' : '',
]"
data-tip="Validasi"
@click="navigateTo('validasi')"
>
<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="M12 4l7 3v5c0 5 -3.5 8 -7 9c-3.5 -1 -7 -4 -7 -9v-5z"
></path>
<path d="M9 12l2 2l4 -4"></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"
>Validasi</span
>
</button>
</li>
<!-- Users --> <!-- Users -->
<li> <li>
<button <button

View File

@ -57,6 +57,19 @@ interface BlockchainLog {
status: string; status: string;
} }
interface ValidationLog {
id: string;
table_name: string;
record_id: string;
action: string;
dataPayload: string;
user_id_request: number;
user_id_process?: number;
created_at: string;
processed_at?: string;
status: "PENDING" | "APPROVED" | "REJECTED";
}
type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "proof" | "unknown"; type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "proof" | "unknown";
interface AuditLogEntry extends BlockchainLog { interface AuditLogEntry extends BlockchainLog {
@ -76,4 +89,5 @@ export type {
Obat, Obat,
AuditLogType, AuditLogType,
AuditLogEntry, AuditLogEntry,
ValidationLog,
}; };

View File

@ -4,6 +4,7 @@ import type {
RekamMedis, RekamMedis,
TindakanDokter, TindakanDokter,
Users, Users,
ValidationLog,
} from "./interfaces"; } from "./interfaces";
export const ITEMS_PER_PAGE_OPTIONS = [5, 10, 25, 50, 100] as const; export const ITEMS_PER_PAGE_OPTIONS = [5, 10, 25, 50, 100] as const;
@ -96,6 +97,11 @@ export const SORT_OPTIONS = {
username: "Username", username: "Username",
role: "Role", role: "Role",
}, },
VALIDATION: {
id: "ID Validasi",
created_at: "Waktu Dibuat",
processed_at: "Waktu Diproses",
},
} as const; } as const;
export const REKAM_MEDIS_TABLE_COLUMNS = [ export const REKAM_MEDIS_TABLE_COLUMNS = [
@ -241,6 +247,54 @@ export const LOG_TABLE_COLUMNS = [
}, },
]; ];
export const VALIDATION_TABLE_COLUMNS = [
{
key: "id" as keyof ValidationLog,
label: "ID",
class: "text-dark",
},
{
key: "table_name" as keyof ValidationLog,
label: "Kelompok Data",
class: "text-dark",
},
{
key: "record_id" as keyof ValidationLog,
label: "ID Record",
class: "text-dark",
},
{
key: "action" as keyof ValidationLog,
label: "Aksi",
class: "text-dark",
},
{
key: "user_id_request" as keyof ValidationLog,
label: "User ID Request",
class: "text-dark",
},
{
key: "user_id_process" as keyof ValidationLog,
label: "User ID Proses",
class: "text-dark",
},
{
key: "status" as keyof ValidationLog,
label: "Status",
class: "text-dark",
},
{
key: "created_at" as keyof ValidationLog,
label: "Waktu Dibuat",
class: "text-dark",
},
{
key: "processed_at" as keyof ValidationLog,
label: "Waktu Diproses",
class: "text-dark",
},
];
export const AUDIT_TABLE_COLUMNS = [ export const AUDIT_TABLE_COLUMNS = [
{ key: "id", label: "ID Log", class: "text-dark" }, { key: "id", label: "ID Log", class: "text-dark" },
{ key: "typeLabel", label: "Tipe Data", class: "text-dark" }, { key: "typeLabel", label: "Tipe Data", class: "text-dark" },

View File

@ -17,6 +17,9 @@ import CreateTindakanDokterView from "../views/dashboard/CreateTindakanDokterVie
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"; import AuditTrailView from "../views/dashboard/AuditTrailView.vue";
import ValidasiView from "../views/dashboard/validasi/ValidasiView.vue";
import ReviewValidasiView from "../views/dashboard/validasi/ReviewValidasiView.vue";
import CreateUserView from "../views/dashboard/users/CreateUserView.vue";
const routes = [ const routes = [
{ {
@ -103,12 +106,30 @@ const routes = [
component: TindakanDokterDetailsView, component: TindakanDokterDetailsView,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "/validasi",
name: "validasi",
component: ValidasiView,
meta: { requiresAuth: true },
},
{
path: "/validasi/:id",
name: "validasi-review",
component: ReviewValidasiView,
meta: { requiresAuth: true },
},
{ {
path: "/users", path: "/users",
name: "users", name: "users",
component: UsersView, component: UsersView,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "/users/add",
name: "users-add",
component: CreateUserView,
meta: { requiresAuth: true },
},
{ {
path: "/audit-trail", path: "/audit-trail",
name: "audit-trail", name: "audit-trail",

View File

@ -1,3 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Quicksand:wght@300..700&display=swap");
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui"; @plugin "daisyui";
@ -33,14 +34,17 @@
} */ } */
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; /* font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale; */
font-family: "Quicksand", sans-serif;
font-optical-sizing: auto;
font-style: normal;
} }
@theme { @theme {

View File

@ -297,8 +297,9 @@ onMounted(async () => {
} }
const csrfToken = localStorage.getItem("csrf_token") || ""; const csrfToken = localStorage.getItem("csrf_token") || "";
const webSocketURL =
socket = io("https://64spbch3-1323.asse.devtunnels.ms", { import.meta.env.VITE_WEBSOCKET_URL || "http://localhost:1323";
socket = io(webSocketURL, {
withCredentials: true, withCredentials: true,
extraHeaders: { extraHeaders: {
"X-CSRF-TOKEN": csrfToken, "X-CSRF-TOKEN": csrfToken,

View File

@ -1,15 +1,213 @@
<script setup lang="ts"> <script setup lang="ts">
import Sidebar from "../../components/dashboard/Sidebar.vue"; import Sidebar from "../../components/dashboard/Sidebar.vue";
import Footer from "../../components/dashboard/Footer.vue"; import Footer from "../../components/dashboard/Footer.vue";
import { onMounted, ref } from "vue"; import { onMounted, ref, computed } from "vue";
import { useApi } from "../../composables/useApi"; import { useApi } from "../../composables/useApi";
import PageHeader from "../../components/dashboard/PageHeader.vue"; import PageHeader from "../../components/dashboard/PageHeader.vue";
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
} from "chart.js";
import { Doughnut, Line, Bar } from "vue-chartjs";
ChartJS.register(
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title
);
const api = useApi(); const api = useApi();
const stats = { const stats = {
countRekamMedis: ref(0), countRekamMedis: ref(0),
countTindakanDokter: ref(0), countTindakanDokter: ref(0),
countObat: ref(0), countObat: ref(0),
auditNonTampered: ref(0),
auditTampered: ref(0),
last7DaysRekamMedis: ref<Array<{ date: string; count: number }>>([]),
tamperedDataPerKelompok: ref<Array<number>>([]),
};
const validasiData = {
totalCount: ref(0),
data: ref<Array<Record<string, any>>>([]),
};
const doughnutChartData = computed(() => ({
labels: ["Non Tampered Data", "Tampered Data"],
datasets: [
{
data: [stats.auditNonTampered.value, stats.auditTampered.value],
backgroundColor: ["#1a2a4f", "#ed7979"],
hoverBackgroundColor: ["#2e4375", "#ed9595"],
},
],
}));
const doughnutChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top" as const,
labels: {
padding: 8,
},
},
title: {
display: false,
padding: {
bottom: 8,
},
},
},
});
const rekamMedisCountLineChartData = computed(() => {
const labels: string[] = [];
const data: number[] = [];
stats.last7DaysRekamMedis.value.forEach((item) => {
labels.push(item.date.toString());
data.push(item.count);
});
return {
labels: labels,
datasets: [
{
label: "Jumlah Rekam Medis Baru",
backgroundColor: "#1a2a4f",
borderColor: "#1a2a4f",
borderWidth: 2,
data: data,
fill: false,
tension: 0.1,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: "#b34242",
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
],
};
});
const rekamMedisLineChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "top" as const,
},
title: {
display: false,
padding: {
bottom: 8,
},
},
tooltip: {
mode: "index" as const,
intersect: false,
callbacks: {
label: function (context: any) {
return (
context.dataset.label + ": " + context.parsed.y + " rekam medis"
);
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
precision: 0,
callback: function (value: any) {
if (Number.isInteger(value)) {
return value;
}
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
x: {
grid: {
display: false,
},
},
},
interaction: {
mode: "nearest" as const,
axis: "x" as const,
intersect: false,
},
});
const auditDataPerKelompokData = computed(() => ({
labels: ["Rekam Medis", "Pemberian Tindakan", "Obat"],
datasets: [
{
label: "Jumlah Data",
backgroundColor: "#1a2a4f",
data: stats.tamperedDataPerKelompok.value,
},
],
}));
const auditBarOption = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
labels: {
padding: 16,
},
},
title: {
display: true,
padding: {
bottom: 8,
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
precision: 0,
},
},
},
};
const normalizeTableName = (tableName: string) => {
switch (tableName) {
case "rekam_medis":
return "Rekam Medis";
case "pemberian_tindakan":
return "Pemberian Tindakan";
case "obat":
return "Obat";
default:
return tableName;
}
}; };
const fetchStats = async () => { const fetchStats = async () => {
@ -18,11 +216,38 @@ const fetchStats = async () => {
countRekamMedis: number; countRekamMedis: number;
countTindakanDokter: number; countTindakanDokter: number;
countObat: number; countObat: number;
auditTrailData: {
auditNonTamperedCount: number;
auditTamperedCount: number;
rekamMedisTamperedCount: number;
tindakanDokterTamperedCount: number;
obatTamperedCount: number;
};
validasiData: {
totalCount: number;
data: Array<Record<string, any>>;
};
last7DaysRekamMedis: any;
}>(`/dashboard`); }>(`/dashboard`);
console.log("Dashboard stats:", result); console.log("Dashboard stats:", result);
stats.countRekamMedis.value = result.countRekamMedis; stats.countRekamMedis.value = result.countRekamMedis;
stats.countTindakanDokter.value = result.countTindakanDokter; stats.countTindakanDokter.value = result.countTindakanDokter;
stats.countObat.value = result.countObat; stats.countObat.value = result.countObat;
stats.auditNonTampered.value = result.auditTrailData.auditNonTamperedCount;
stats.auditTampered.value = result.auditTrailData.auditTamperedCount;
validasiData.totalCount.value = result.validasiData.totalCount;
validasiData.data.value = result.validasiData.data;
stats.last7DaysRekamMedis.value = result.last7DaysRekamMedis.byDay;
stats.tamperedDataPerKelompok.value = [
result.auditTrailData.rekamMedisTamperedCount,
result.auditTrailData.tindakanDokterTamperedCount,
result.auditTrailData.obatTamperedCount,
];
// console.log(
// "Tampered Data Per Kelompok Data:",
// stats.tamperedDataPerKelompok.value
// );
} catch (error) { } catch (error) {
console.error("Error fetching dashboard stats:", error); console.error("Error fetching dashboard stats:", error);
} }
@ -39,7 +264,7 @@ onMounted(() => {
<Sidebar> <Sidebar>
<PageHeader title="Dashboard" subtitle="Detail Dashboard" /> <PageHeader title="Dashboard" subtitle="Detail Dashboard" />
<div> <div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div <div
class="bg-white p-4 rounded-lg shadow-md flex flex-col items-center" class="bg-white p-4 rounded-lg shadow-md flex flex-col items-center"
> >
@ -58,6 +283,119 @@ onMounted(() => {
<h2 class="text-lg font-semibold mb-2">Total Obat</h2> <h2 class="text-lg font-semibold mb-2">Total Obat</h2>
<p class="text-3xl font-bold">{{ stats.countObat }}</p> <p class="text-3xl font-bold">{{ stats.countObat }}</p>
</div> </div>
<div
class="bg-white p-4 rounded-lg shadow-md flex flex-col items-center"
>
<h2 class="text-lg font-semibold mb-2">Total Data Audit</h2>
<p class="text-3xl font-bold">
{{ stats.auditNonTampered.value + stats.auditTampered.value }}
</p>
</div>
</div>
<div class="flex gap-x-4 w-full">
<div class="flex-2 mt-4 bg-white p-4 rounded-lg shadow-md min-w-0">
<h5 class="text-lg font-semibold mb-2 text-center">
Jumlah Rekam Medis Baru 7 Hari Terakhir
</h5>
<div class="w-full">
<Line
:data="rekamMedisCountLineChartData"
:options="rekamMedisLineChartOptions"
/>
</div>
<div class="flex justify-center mt-4">
<RouterLink
to="/rekam-medis"
class="text-sm hover:opacity-75 transition-all"
>
Lihat Data Rekam Medis >>
</RouterLink>
</div>
</div>
<div class="flex-1 mt-4 bg-white p-4 rounded-lg shadow-md min-w-0">
<h5 class="text-lg font-semibold mb-2 text-center">
Data Audit Trail
</h5>
<div class="w-full">
<Doughnut
:data="doughnutChartData"
:options="doughnutChartOptions"
/>
</div>
<p class="text-center text-sm mt-4">
Non Tampered Data:
<span class="font-bold text-dark">{{
stats.auditNonTampered
}}</span
>, Tampered Data:
<span class="font-bold text-red-400">{{
stats.auditTampered
}}</span>
</p>
<div class="flex justify-center">
<RouterLink
to="/audit-trail"
class="text-sm hover:opacity-75 transition-all"
>
Lihat Detail Audit Trail >>
</RouterLink>
</div>
</div>
</div>
<div class="flex gap-x-4">
<div class="mt-4 flex-2 bg-white p-4 rounded-lg shadow-md h-fit">
<h5 class="text-lg font-semibold text-center">
Data yang perlu divalidasi (<span
class="font-bold text-red-400"
>{{ validasiData.totalCount }}</span
>)
</h5>
<table class="w-full mt-4">
<thead>
<tr class="text-left">
<th class="pb-2">Kelompok Data</th>
<th class="pb-2">Tipe Aksi</th>
<th class="pb-2">ID User</th>
</tr>
</thead>
<tbody class="">
<tr
class=""
v-for="(value, index) in validasiData.data.value"
:key="index"
>
<td class="pt-1">
{{ normalizeTableName(value.table_name) || "N/A" }}
</td>
<td>
{{ value.action || "N/A" }}
</td>
<td class="">
{{ value.user_id_request || "N/A" }}
</td>
</tr>
</tbody>
</table>
<div class="justify-center flex mt-4">
<RouterLink
to="/validasi"
class="text-sm hover:opacity-75 transition-all"
>
Lihat Detail Validasi Data >>
</RouterLink>
</div>
</div>
<div class="mt-4 flex-1 bg-white p-4 rounded-lg shadow-md">
<h5 class="text-lg font-semibold text-center">
Tampered data per kelompok data
</h5>
<div class="h-64 w-full">
<Bar
:data="auditDataPerKelompokData"
:options="auditBarOption"
/>
</div>
</div>
</div> </div>
</div> </div>
</Sidebar> </Sidebar>

View File

@ -15,6 +15,8 @@ const dataLog = ref<BlockchainLog[]>([]);
const currentHash = ref<string>(""); const currentHash = ref<string>("");
const route = useRoute(); const route = useRoute();
const api = useApi(); const api = useApi();
const routeId = ref<string>(route.params.id?.toString() ?? "");
const isTampered = ref<boolean>(false);
const fetchData = async () => { const fetchData = async () => {
try { try {
const result = await api.get<Obat>(`/obat/${route.params.id}`); const result = await api.get<Obat>(`/obat/${route.params.id}`);
@ -25,70 +27,100 @@ const fetchData = async () => {
} }
}; };
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: "numeric",
month: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
const indonesianTime = date.toLocaleString("id-ID", options);
return indonesianTime.replace(/\./g, ":");
};
const normalizeLogEntries = (
entries: any[],
isTampered: boolean
): BlockchainLog[] =>
entries.map((item, index) => {
const value = item?.value ?? item;
const payload = value?.payload ?? item?.payload ?? "";
const statusLabel = (() => {
if (item.status === "ORIGINAL") {
return "ORIGINAL DATA" + (isTampered ? " (TAMPERED)" : "");
}
if (index === 0) {
if (isTampered) {
return `TAMPERED after ${item.status}`;
}
if (entries[entries.length - 1].payload === currentHash.value) {
return "DATA SAME WITH ORIGINAL";
}
return `VALID ${item.status}`;
}
if (index === entries.length - 1) {
return item.status;
}
return `OLD ${item.status}`;
})();
return {
id: value?.id ?? item?.id ?? "",
event: value?.event ?? item?.event ?? "-",
hash: payload,
userId: value?.user_id ?? item?.userId ?? 0,
txId: item?.txId,
timestamp: formatTimestamp(value?.timestamp ?? item?.timestamp),
status: statusLabel,
};
});
const fetchLogData = async () => { const fetchLogData = async () => {
if (!routeId.value) {
dataLog.value = [];
isTampered.value = false;
return;
}
try { try {
const result = await api.get<{ const result = await api.get<{
currentDataHash: string; currentDataHash?: string;
data: Record<string, any>; logs?: any[];
isTampered: boolean; isTampered?: boolean;
}>(`/obat/${route.params.id}/log`); }>(`/obat/${routeId.value}/log`);
currentHash.value = result.currentDataHash || ""; currentHash.value = result.currentDataHash ?? "";
const apiResponse = result as any; isTampered.value = Boolean(result.isTampered);
const dataArray: any[] = apiResponse.logs || [];
const flattenedData = dataArray.map((item, index) => {
const date = new Date(item.timestamp);
const options: Intl.DateTimeFormatOptions = {
timeZone: "Asia/Jakarta",
day: "numeric",
month: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
const indonesianTime = date.toLocaleString("id-ID", options);
const formattedTime = indonesianTime.replace(/\./g, ":");
const statusLabel = (() => { const logs = Array.isArray(result.logs) ? result.logs : [];
if (item.status === "ORIGINAL") { dataLog.value = normalizeLogEntries(logs, isTampered.value);
return "ORIGINAL DATA"; console.log("Pemberian Obat Log API Result:", dataLog.value);
} console.log("isTampered:", isTampered.value);
if (index === 0) {
if (result.isTampered) {
return `TAMPERED after ${item.status}`;
}
if (dataArray[dataArray.length - 1].payload === currentHash.value) {
return "DATA SAME WITH ORIGINAL";
}
return `VALID ${item.status}`;
}
if (index === dataArray.length - 1) {
return item.status;
}
return `OLD ${item.status}`;
})();
return {
id: item.id,
event: item.event,
hash: item.payload,
userId: item.user_id,
timestamp: formattedTime,
txId: item.txId,
status: statusLabel,
};
});
dataLog.value = flattenedData;
} catch (error) { } catch (error) {
console.error("Error fetching tindakan logs:", error);
dataLog.value = []; dataLog.value = [];
isTampered.value = false;
} }
}; };
@ -128,6 +160,22 @@ onMounted(async () => {
</div> </div>
<hr /> <hr />
<h5 class="font-bold">Log Perubahan</h5> <h5 class="font-bold">Log Perubahan</h5>
<div role="alert" class="alert alert-error" v-if="isTampered">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Peringatan! Manipulasi Data Terdeteksi.</span>
</div>
<DataTable <DataTable
:data="dataLog" :data="dataLog"
:columns="LOG_TABLE_COLUMNS" :columns="LOG_TABLE_COLUMNS"

View File

@ -24,7 +24,8 @@ const dataPost = ref<PostObat>({
jumlah_obat: 0, jumlah_obat: 0,
aturan_pakai: "", aturan_pakai: "",
}); });
const route = useRoute();
const routeId = ref<string>(route.params.id?.toString() ?? "");
const data = ref<Obat>({ const data = ref<Obat>({
id: 0, id: 0,
id_visit: "", id_visit: "",
@ -33,8 +34,77 @@ const data = ref<Obat>({
aturan_pakai: "", aturan_pakai: "",
}); });
const dataLog = ref<BlockchainLog[]>([]); const dataLog = ref<BlockchainLog[]>([]);
const dataLogRaw = ref<any[]>([]);
const currentHash = ref<string>(""); const currentHash = ref<string>("");
const isTampered = ref<boolean>(false);
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: "numeric",
month: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
const indonesianTime = date.toLocaleString("id-ID", options);
return indonesianTime.replace(/\./g, ":");
};
const normalizeLogEntries = (
entries: any[],
isTampered: boolean
): BlockchainLog[] =>
entries.map((item, index) => {
const value = item?.value ?? item;
const payload = value?.payload ?? item?.payload ?? "";
const statusLabel = (() => {
if (item.status === "ORIGINAL") {
return "ORIGINAL DATA";
}
if (index === 0) {
if (isTampered) {
return `TAMPERED after ${item.status}`;
}
if (entries[entries.length - 1].payload === currentHash.value) {
return "DATA SAME WITH ORIGINAL";
}
return `VALID ${item.status}`;
}
if (index === entries.length - 1) {
return item.status;
}
return `OLD ${item.status}`;
})();
return {
id: value?.id ?? item?.id ?? "",
event: value?.event ?? item?.event ?? "-",
hash: payload,
userId: value?.user_id ?? item?.userId ?? 0,
txId: item?.txId,
timestamp: formatTimestamp(value?.timestamp ?? item?.timestamp),
status: statusLabel,
};
});
const handleSubmit = async () => { const handleSubmit = async () => {
isLoading.value = true; isLoading.value = true;
@ -63,7 +133,6 @@ const handleSubmit = async () => {
} }
} }
}; };
const route = useRoute();
const api = useApi(); const api = useApi();
const fetchData = async () => { const fetchData = async () => {
try { try {
@ -83,57 +152,29 @@ const fetchData = async () => {
}; };
const fetchLogData = async () => { const fetchLogData = async () => {
if (!routeId.value) {
dataLog.value = [];
isTampered.value = false;
return;
}
try { try {
const result = await api.get<{ const result = await api.get<{
currentDataHash: string; currentDataHash?: string;
data: Record<string, any>; logs?: any[];
}>(`/obat/${route.params.id}/log`); isTampered?: boolean;
currentHash.value = result.currentDataHash || ""; }>(`/obat/${routeId.value}/log`);
console.log("Log API Result:", result);
if ("data" in result && Array.isArray(result.data)) {
dataLogRaw.value = result.data;
} else {
const apiResponse = result as any;
const dataArray: any[] = [];
Object.keys(apiResponse).forEach((key) => {
if (key !== "totalCount") {
const item = apiResponse[Number(key)];
if (item) {
dataArray.push(item);
}
}
});
const flattenedData = dataArray.map((item) => {
const date = new Date(item.value.timestamp);
const options: Intl.DateTimeFormatOptions = {
timeZone: "Asia/Jakarta",
day: "numeric",
month: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
const indonesianTime = date.toLocaleString("id-ID", options);
const formattedTime = indonesianTime.replace(/\./g, ":");
console.log("Timestamp:", item.value.timestamp, "=>", formattedTime);
return {
id: item.value.id,
event: item.value.event,
hash: item.value.payload,
userId: item.value.user_id,
timestamp: formattedTime,
txId: item.txId,
status:
item.value.payload === currentHash.value ? "Valid" : "Changed",
};
});
dataLog.value = flattenedData; currentHash.value = result.currentDataHash ?? "";
} isTampered.value = Boolean(result.isTampered);
const logs = Array.isArray(result.logs) ? result.logs : [];
dataLog.value = normalizeLogEntries(logs, isTampered.value);
console.log("Pemberian Obat Log API Result:", dataLog.value);
} catch (error) { } catch (error) {
console.error("Error fetching tindakan logs:", error);
dataLog.value = []; dataLog.value = [];
isTampered.value = false;
} }
}; };
@ -237,6 +278,22 @@ onMounted(async () => {
</div> </div>
<hr /> <hr />
<h5 class="font-bold">Log Perubahan</h5> <h5 class="font-bold">Log Perubahan</h5>
<div role="alert" class="alert alert-error" v-if="isTampered">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Peringatan! Manipulasi Data Terdeteksi.</span>
</div>
<DataTable <DataTable
:data="dataLog" :data="dataLog"
:columns="LOG_TABLE_COLUMNS" :columns="LOG_TABLE_COLUMNS"

View File

@ -58,7 +58,7 @@ const normalizeLogEntries = (
const statusLabel = (() => { const statusLabel = (() => {
if (item.status === "ORIGINAL") { if (item.status === "ORIGINAL") {
return "ORIGINAL DATA"; return "ORIGINAL DATA" + (isTampered ? " (TAMPERED)" : "");
} }
if (index === 0) { if (index === 0) {

View File

@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>Create User</template>
<style scoped></style>

View File

@ -0,0 +1,427 @@
<script setup lang="ts">
import Sidebar from "../../../components/dashboard/Sidebar.vue";
import Footer from "../../../components/dashboard/Footer.vue";
import PageHeader from "../../../components/dashboard/PageHeader.vue";
import { onMounted, ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useApi } from "../../../composables/useApi";
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
import DialogConfirm from "../../../components/DialogConfirm.vue";
interface ValidationQueue {
id: number;
table_name: string;
record_id: string;
action: string;
dataPayload: Record<string, any> | rekamMedisPayload;
user_id_request: number;
user_id_process?: number;
status: string;
created_at: string;
processed_at?: string;
previousLog?: Record<string, any>;
}
interface rekamMedisPayload {
id_visit: string;
waktu_visit: string;
no_rm: string;
nama_pasien: string;
umur_pasien: number;
jenis_kelamin: string;
gol_darah: string;
pekerjaan: string;
suku: string;
kode_diagnosa: string;
diagnosa: string;
anamnese: string;
sistolik: number;
diastolik: number;
nadi: number;
suhu: number;
nafas: number;
tinggi_badan: number;
berat_badan: number;
jenis_kasus: string;
tindak_lanjut: string;
}
const previousLog = ref<Record<string, any>>();
const validationPayload = ref<rekamMedisPayload | Record<string, any>>();
const validation = ref<ValidationQueue>();
const normalizeForCompare = (value: any) => {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") return JSON.stringify(value);
return String(value);
};
const normalizedNewPayload = computed<Record<string, any> | undefined>(() => {
if (!validation.value) return undefined;
const rawPayload = (validation.value.dataPayload || {}) as Record<
string,
any
>;
if (validation.value.action !== "UPDATE") {
return rawPayload;
}
const base = previousLog.value ? { ...previousLog.value } : {};
const merged = { ...base } as Record<string, any>;
Object.keys(rawPayload).forEach((key) => {
const incoming = rawPayload[key];
const existing = base[key];
if (
existing !== undefined &&
normalizeForCompare(existing) === normalizeForCompare(incoming)
) {
merged[key] = existing;
} else {
merged[key] = incoming;
}
});
if (
validation.value.table_name === "rekam_medis" &&
validation.value.record_id
) {
merged.id_visit = validation.value.record_id;
}
return merged;
});
const getChangedFields = computed(() => {
if (!validation.value) return [];
const newData =
normalizedNewPayload.value ||
(validation.value.dataPayload as Record<string, any>) ||
{};
const changes: Array<{
field: string;
oldValue: any;
newValue: any;
changed: boolean;
}> = [];
const allKeys = new Set([
...(previousLog.value ? Object.keys(previousLog.value) : []),
...Object.keys(newData),
]);
allKeys.forEach((key) => {
const oldVal = previousLog.value?.[key];
const newVal = newData[key];
const changed = normalizeForCompare(oldVal) !== normalizeForCompare(newVal);
changes.push({
field: key,
oldValue: oldVal,
newValue: newVal,
changed,
});
});
return changes;
});
const formatJsonWithHighlight = (
data: Record<string, any> | undefined,
type: "old" | "new"
) => {
if (!data) return "";
const changedFields = getChangedFields.value
.filter((c) => c.changed)
.map((c) => c.field);
const highlightClass =
type === "old" ? "bg-red-200 text-red-800" : "bg-green-200 text-green-800";
const sortedData = Object.keys(data)
.sort()
.reduce((acc, key) => {
acc[key] = data[key];
return acc;
}, {} as Record<string, any>);
const lines = JSON.stringify(sortedData, null, 2).split("\n");
return lines
.map((line) => {
const fieldMatch = line.match(/"([^"]+)":/);
if (
fieldMatch &&
fieldMatch[1] &&
changedFields.includes(fieldMatch[1])
) {
return `<span class="${highlightClass}">${line}</span>`;
}
return line;
})
.join("\n");
};
const route = useRoute();
const router = useRouter();
const api = useApi();
const approveDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
const rejectDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
const showApproveDialog = () => {
approveDialog.value?.show();
};
const showRejectDialog = () => {
rejectDialog.value?.show();
};
const tableLabelMap: Record<string, string> = {
pemberian_obat: "Pemberian Obat",
pemberian_tindakan: "Pemberian Tindakan",
rekam_medis: "Rekam Medis",
};
const actionLabelMap: Record<string, string> = {
CREATE: "Tambah Data",
UPDATE: "Ubah Data",
DELETE: "Hapus Data",
};
const statusBadgeClass = computed(() => {
switch (validation.value?.status) {
case "PENDING":
return "badge-warning";
case "APPROVED":
return "badge-success";
case "REJECTED":
return "badge-error";
default:
return "badge-ghost";
}
});
const fetchData = async () => {
try {
const result = await api.get<ValidationQueue>(
`/validation/${route.params.id}`
);
console.log("Fetched validation data:", result);
validation.value = result;
if (validation.value.previousLog) {
previousLog.value = validation.value.previousLog;
if (validation.value.table_name === "rekam_medis") {
validationPayload.value = {
...(validation.value.action === "UPDATE" ? result.dataPayload : {}),
id_visit: validation.value.record_id,
} as rekamMedisPayload;
}
}
} catch (error) {
console.error("Error fetching validation data:", error);
validation.value = undefined;
}
};
const handleApprove = async () => {
try {
const response = await api.post(
`/validation/${route.params.id}/approve`,
{}
);
console.log("Approve response:", response);
alert("Permintaan berhasil disetujui");
router.push({ name: "validasi" });
} catch (error) {
console.error("Error approving validation:", error);
alert("Gagal menyetujui permintaan");
}
};
const handleReject = async () => {
try {
await api.post(`/validation/${route.params.id}/reject`, {});
alert("Permintaan berhasil ditolak");
router.push({ name: "validasi" });
} catch (error) {
console.error("Error rejecting validation:", error);
alert("Gagal menolak permintaan");
}
};
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: "numeric",
month: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
};
return date.toLocaleString("id-ID", options).replace(/\./g, ":");
};
onMounted(async () => {
await fetchData();
document.title = `Review Validasi - ID ${route.params.id}`;
});
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader
title="Validasi"
:subtitle="`Review Permintaan Validasi ${route.params.id}`"
/>
<div class="bg-white rounded-xl shadow-md text-dark">
<div class="flex flex-col px-4 py-4 justify-between gap-4">
<div class="breadcrumbs text-sm">
<ul>
<li class="font-bold">
<RouterLink to="/validasi"> Validasi </RouterLink>
</li>
<li>Review Permintaan {{ route.params.id }}</li>
</ul>
</div>
<div class="flex justify-between items-center">
<h5 class="font-bold">Detail Permintaan Validasi</h5>
<span class="badge" :class="statusBadgeClass">
{{ validation?.status }}
</span>
</div>
<div class="text-sm">
<p><strong>ID Permintaan:</strong> {{ validation?.id }}</p>
<p>
<strong>Tabel:</strong>
{{
tableLabelMap[validation?.table_name || ""] ||
validation?.table_name
}}
</p>
<p><strong>ID Record:</strong> {{ validation?.record_id }}</p>
<p>
<strong>Aksi:</strong>
{{
actionLabelMap[validation?.action || ""] || validation?.action
}}
</p>
<p>
<strong>Pemohon:</strong> User ID
{{ validation?.user_id_request }}
</p>
<p>
<strong>Diajukan:</strong>
{{ formatTimestamp(validation?.created_at) }}
</p>
<p v-if="validation?.processed_at">
<strong>Diproses:</strong>
{{ formatTimestamp(validation?.processed_at) }}
</p>
<p v-if="validation?.user_id_process">
<strong>Diproses oleh:</strong> User ID
{{ validation?.user_id_process }}
</p>
</div>
<hr />
<h5 class="font-bold">Payload</h5>
<div
v-if="
validation?.action === 'CREATE' ||
validation?.action === 'DELETE'
"
>
<div
class="bg-white border border-dark rounded-lg p-4 text-sm overflow-x-auto"
>
<pre class="whitespace-pre-wrap">{{
JSON.stringify(normalizedNewPayload, null, 2)
}}</pre>
</div>
</div>
<!-- Side-by-side JSON comparison for UPDATE -->
<div v-if="validation?.action === 'UPDATE'" class="flex gap-x-2">
<div
class="bg-white border border-dark rounded-lg p-4 text-sm overflow-x-auto flex-1"
>
<h1 class="font-bold mb-2">Data Sebelum</h1>
<pre
class="whitespace-pre-wrap"
v-html="formatJsonWithHighlight(previousLog, 'old')"
></pre>
</div>
<div
class="bg-white border border-dark rounded-lg p-4 text-sm overflow-x-auto flex-1"
>
<h1 class="font-bold mb-2">Data Sesudah</h1>
<pre
class="whitespace-pre-wrap"
v-html="formatJsonWithHighlight(normalizedNewPayload, 'new')"
></pre>
</div>
</div>
<div
class="flex gap-2 justify-end"
v-if="validation?.status === 'PENDING'"
>
<ButtonDark
type="button"
@click="showApproveDialog"
:disabled="api.isLoading.value"
text="Setujui"
/>
<button
@click="showRejectDialog"
class="bg-white btn btn-sm text-dark"
>
Tolak
</button>
</div>
</div>
</div>
<DialogConfirm
ref="approveDialog"
title="Setujui Permintaan"
message="Apakah Anda yakin ingin menyetujui permintaan validasi ini?"
confirm-text="Ya, Setujui"
cancel-text="Batal"
@confirm="handleApprove"
/>
<DialogConfirm
ref="rejectDialog"
title="Tolak Permintaan"
message="Apakah Anda yakin ingin menolak permintaan validasi ini?"
confirm-text="Ya, Tolak"
cancel-text="Batal"
@confirm="handleReject"
/>
</Sidebar>
</div>
<Footer></Footer>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,321 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import Sidebar from "../../../components/dashboard/Sidebar.vue";
import { useRoute, useRouter } from "vue-router";
import { usePagination } from "../../../composables/usePagination";
import {
DEBOUNCE_DELAY,
DEFAULT_PAGE_SIZE,
ITEMS_PER_PAGE_OPTIONS,
SORT_OPTIONS,
VALIDATION_TABLE_COLUMNS,
} from "../../../constants/pagination";
import { useApi } from "../../../composables/useApi";
import { useDebounce } from "../../../composables/useDebounce";
import PageHeader from "../../../components/dashboard/PageHeader.vue";
import SearchInput from "../../../components/dashboard/SearchInput.vue";
import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
import DataTable from "../../../components/dashboard/DataTable.vue";
import PaginationControls from "../../../components/dashboard/PaginationControls.vue";
import type { ValidationLog } from "../../../constants/interfaces";
interface ApiResponse {
data: ValidationLog[];
totalCount: number;
}
const data = ref<ValidationLog[]>([]);
const searchValidation = ref("");
const sortBy = ref("id");
const router = useRouter();
const route = useRoute();
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 api = useApi();
const { debounce } = useDebounce();
const updateQueryParams = () => {
const query: Record<string, string> = {
page: pagination.page.value.toString(),
pageSize: pagination.pageSize.value.toString(),
};
if (searchValidation.value) {
query.search = searchValidation.value;
}
if (sortBy.value !== "id") {
query.sortBy = sortBy.value;
}
query.order = sortOrder.value;
router.replace({ query });
};
const normalizedData = (rawData: any[]): ValidationLog[] => {
const formattedTableName = (tableName: string): string => {
switch (tableName) {
case "rekam_medis":
return "Rekam Medis";
case "pemberian_tindakan":
return "Tindakan Dokter";
case "obat":
return "Obat";
default:
return tableName;
}
};
const formattedIdRecord = (recordId: any) => {
if (recordId === null || recordId === undefined || recordId === "") {
return "-";
}
return recordId.toString();
};
const formattedUserIdProcess = (userId: any) => {
if (userId === null || userId === undefined || userId === "") {
return "-";
}
return userId.toString();
};
const formattedProcessedAt = (dateString: string): string => {
const date = new Date(dateString);
if (dateString === null || dateString === undefined || dateString === "") {
return "-";
}
if (isNaN(date.getTime())) {
return dateString;
}
return date.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
const formattedDate = (dateString: string): string => {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return dateString;
}
return date.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
return rawData.map((item) => ({
id: item.id,
table_name: formattedTableName(item.table_name),
status: item.status,
created_at: formattedDate(item.created_at),
processed_at: formattedProcessedAt(item.processed_at),
record_id: formattedIdRecord(item.record_id),
action: item.action,
dataPayload: item.dataPayload,
user_id_request: item.user_id_request,
user_id_process: formattedUserIdProcess(item.user_id_process),
}));
};
const fetchData = async () => {
try {
const queryParams = new URLSearchParams({
take: pagination.pageSize.value.toString(),
page: pagination.page.value.toString(),
orderBy: sortBy.value,
order: sortOrder.value,
...(searchValidation.value && { validation: searchValidation.value }),
});
const result = await api.get<ApiResponse>(
`/validation?${queryParams.toString()}`
);
const formattedData = normalizedData(result.data);
data.value = formattedData;
pagination.totalCount.value = result.totalCount;
console.log(formattedData);
updateQueryParams();
} catch (error) {
console.error("Error fetching validation data:", error);
data.value = [];
}
};
const debouncedFetchData = debounce(fetchData, DEBOUNCE_DELAY);
const handleSearch = () => {
pagination.reset();
debouncedFetchData();
};
const handleSortChange = (newSortBy: string) => {
sortBy.value = newSortBy;
pagination.reset();
fetchData();
};
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
};
const handlePageSizeChange = (newSize: number) => {
pagination.setPageSize(newSize);
fetchData();
};
const handleDetails = (item: ValidationLog) => {
router.push({ name: "validation-details", params: { id: item.id } });
};
const handleUpdate = (item: ValidationLog) => {
router.push({ name: "validation-update", params: { id: item.id } });
};
const handleDelete = async (item: ValidationLog) => {
if (confirm(`Apakah Anda yakin ingin menghapus item "${item.id}"?`)) {
try {
await api.delete(`/validation/${item.id}`);
await fetchData();
} catch (error) {
console.error("Error deleting validation:", error);
alert("Gagal menghapus data validasi");
}
}
};
watch([() => pagination.page.value], () => {
fetchData();
});
watch(sortOrder, () => {
pagination.reset();
fetchData();
});
watch(searchValidation, (newValue, oldValue) => {
if (oldValue && !newValue) {
pagination.reset();
fetchData();
}
});
onMounted(async () => {
if (route.query.search) {
searchValidation.value = route.query.search 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 = "Validation - Hospital Log";
});
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader title="Validasi" subtitle="Manajemen Validasi" />
<div class="bg-white rounded-xl shadow-md">
<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="searchValidation"
placeholder="Cari berdasarkan Validation"
@search="handleSearch"
/>
<div class="flex items-center gap-2 md:ml-4">
<SortDropdown
v-model="sortBy"
:options="SORT_OPTIONS.VALIDATION"
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>
</div>
<!-- Data Table -->
<DataTable
:data="data"
:columns="VALIDATION_TABLE_COLUMNS"
:is-loading="api.isLoading.value"
empty-message="Tidak ada data validasi"
@details="handleDetails"
@update="handleUpdate"
@delete="handleDelete"
:is-aksi="true"
/>
<PaginationControls
v-if="!api.isLoading.value && data.length > 0"
:page="pagination.page"
:page-size="pagination.pageSize"
:total-count="pagination.totalCount"
:start-index="pagination.startIndex"
:end-index="pagination.endIndex"
:can-go-next="pagination.canGoNext"
:can-go-previous="pagination.canGoPrevious"
:page-size-options="[...ITEMS_PER_PAGE_OPTIONS]"
:get-page-numbers="pagination.getPageNumbers"
@page-change="pagination.goToPage"
@page-size-change="handlePageSizeChange"
@next="pagination.nextPage"
@previous="pagination.previousPage"
/>
</div>
</Sidebar>
</div>
</div>
</template>
<style scoped></style>