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:
parent
3903191cdc
commit
433e6889cd
|
|
@ -1,28 +1,28 @@
|
||||||
{
|
{
|
||||||
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "validation_queue" ADD COLUMN "status" "ValidationStatus" NOT NULL DEFAULT 'PENDING';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "validation_queue" ALTER COLUMN "record_id" SET DEFAULT '';
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -101,4 +103,37 @@ model audit {
|
||||||
user_id BigInt
|
user_id BigInt
|
||||||
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")
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
backend/api/src/modules/validation/validation.controller.ts
Normal file
62
backend/api/src/modules/validation/validation.controller.ts
Normal 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);
|
||||||
|
// }
|
||||||
|
}
|
||||||
14
backend/api/src/modules/validation/validation.module.ts
Normal file
14
backend/api/src/modules/validation/validation.module.ts
Normal 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 {}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
168
backend/api/src/modules/validation/validation.service.ts
Normal file
168
backend/api/src/modules/validation/validation.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
30
frontend/hospital-log/package-lock.json
generated
30
frontend/hospital-log/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>Create User</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue
Block a user