diff --git a/backend/api/package.json b/backend/api/package.json index 3d3e7f8..058a019 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -72,6 +72,10 @@ "ts" ], "rootDir": "src", + "moduleNameMapper": { + "^@api/(.*)$": "/$1", + "^@dist/(.*)$": "/../dist/$1" + }, "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/backend/api/src/app.controller.ts b/backend/api/src/app.controller.ts index cce879e..eca8f3f 100644 --- a/backend/api/src/app.controller.ts +++ b/backend/api/src/app.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { AppService } from './app.service'; +import { AuthGuard } from './modules/auth/guard/auth.guard'; @Controller() export class AppController { @@ -9,4 +10,10 @@ export class AppController { getHello(): string { return this.appService.getHello(); } + + @Get('/dashboard') + @UseGuards(AuthGuard) + getDashboard() { + return this.appService.getDashboard(); + } } diff --git a/backend/api/src/app.service.ts b/backend/api/src/app.service.ts index 927d7cc..6fe1c4f 100644 --- a/backend/api/src/app.service.ts +++ b/backend/api/src/app.service.ts @@ -1,8 +1,29 @@ import { Injectable } from '@nestjs/common'; +import { PrismaService } from './modules/prisma/prisma.service'; +import { TindakanDokterService } from './modules/tindakandokter/tindakandokter.service'; +import { RekammedisService } from './modules/rekammedis/rekammedis.service'; +import { ObatService } from './modules/obat/obat.service'; +import { LogService } from './modules/log/log.service'; @Injectable() export class AppService { + constructor( + private prisma: PrismaService, + private rekamMedisService: RekammedisService, + private tindakanDokterService: TindakanDokterService, + private obatService: ObatService, + private logService: LogService, + ) {} + getHello(): string { return 'Hello World!'; } + + async getDashboard() { + const countRekamMedis = await this.rekamMedisService.countRekamMedis(); + const countTindakanDokter = + await this.tindakanDokterService.countTindakanDokter(); + const countObat = await this.obatService.countObat(); + return { countRekamMedis, countTindakanDokter, countObat }; + } } diff --git a/backend/api/src/common/fabric-gateway/index.ts b/backend/api/src/common/fabric-gateway/index.ts index 652da28..353647a 100644 --- a/backend/api/src/common/fabric-gateway/index.ts +++ b/backend/api/src/common/fabric-gateway/index.ts @@ -209,6 +209,24 @@ class FabricGateway { throw error; } } + + async getAllLogs() { + try { + if (!this.contract) { + throw new Error('Not connected to network. Call connect() first.'); + } + + console.log('Evaluating getAllLogs transaction...'); + const resultBytes = await this.contract.evaluateTransaction('getAllLogs'); + const resultJson = new TextDecoder().decode(resultBytes); + + const result = JSON.parse(resultJson); + return result; + } catch (error) { + console.error('Failed to get all logs:', error); + throw error; + } + } } export default FabricGateway; diff --git a/backend/api/src/modules/fabric/fabric.service.ts b/backend/api/src/modules/fabric/fabric.service.ts index 6ef10d8..3a4eb8e 100644 --- a/backend/api/src/modules/fabric/fabric.service.ts +++ b/backend/api/src/modules/fabric/fabric.service.ts @@ -36,4 +36,9 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown { this.logger.log(`Retrieving log with ID: ${id}`); return this.gateway.getLogById(id); } + + async getAllLogs() { + this.logger.log('Retrieving all logs from Fabric network'); + return this.gateway.getAllLogs(); + } } diff --git a/backend/api/src/modules/log/log.service.ts b/backend/api/src/modules/log/log.service.ts index fd68424..e0dbc62 100644 --- a/backend/api/src/modules/log/log.service.ts +++ b/backend/api/src/modules/log/log.service.ts @@ -15,6 +15,11 @@ export class LogService { return result; } async getAllLogs() { - // return this.fabricService.getAllLogs(); + return this.fabricService.getAllLogs(); + } + + async countLogs() { + const countLogs = await this.fabricService.getAllLogs(); + return countLogs.length; } } diff --git a/backend/api/src/modules/obat/obat.module.ts b/backend/api/src/modules/obat/obat.module.ts index 85378a6..4a9a7b8 100644 --- a/backend/api/src/modules/obat/obat.module.ts +++ b/backend/api/src/modules/obat/obat.module.ts @@ -8,5 +8,6 @@ import { LogModule } from '../log/log.module'; imports: [PrismaModule, LogModule], controllers: [ObatController], providers: [ObatService], + exports: [ObatService], }) export class ObatModule {} diff --git a/backend/api/src/modules/obat/obat.service.spec.ts b/backend/api/src/modules/obat/obat.service.spec.ts index a50dbcd..90edaa3 100644 --- a/backend/api/src/modules/obat/obat.service.spec.ts +++ b/backend/api/src/modules/obat/obat.service.spec.ts @@ -1,12 +1,60 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../prisma/prisma.service'; +import { LogService } from '../log/log.service'; +import { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; +import { CreateObatDto } from './dto/create-obat-dto'; +import { UpdateObatDto } from './dto/update-obat-dto'; import { ObatService } from './obat.service'; +type PrismaDelegate = { + findMany: jest.Mock; + findUnique: jest.Mock; + count: jest.Mock; + create: jest.Mock; + update: jest.Mock; +}; + +const createPrismaMock = () => ({ + pemberian_obat: { + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + } as PrismaDelegate, + rekam_medis: { + findUnique: jest.fn(), + }, +}); + +const createLogServiceMock = () => ({ + storeLog: jest.fn(), + getLogById: jest.fn(), +}); + +const mockUser: ActiveUserPayload = { + sub: 1, + username: 'tester', + role: 'admin' as any, + csrf: 'token', +}; + describe('ObatService', () => { let service: ObatService; + let prisma: ReturnType; + let logService: ReturnType; beforeEach(async () => { + prisma = createPrismaMock(); + logService = createLogServiceMock(); + const module: TestingModule = await Test.createTestingModule({ - providers: [ObatService], + providers: [ + ObatService, + { provide: PrismaService, useValue: prisma }, + { provide: LogService, useValue: logService }, + ], }).compile(); service = module.get(ObatService); @@ -15,4 +63,185 @@ describe('ObatService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getAllObat', () => { + it('returns paginated data and total count', async () => { + prisma.pemberian_obat.findMany.mockResolvedValueOnce([ + { id: 1, obat: 'Paracetamol' }, + ]); + prisma.pemberian_obat.count.mockResolvedValueOnce(10); + + const result = await service.getAllObat({ + take: 10, + page: 1, + orderBy: { id: 'asc' }, + order: 'asc', + obat: 'Para', + }); + + expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith({ + skip: 0, + take: 10, + where: { + obat: { contains: 'Para' }, + }, + orderBy: { id: 'asc' }, + }); + expect(prisma.pemberian_obat.count).toHaveBeenCalledWith({ + where: { + obat: { contains: 'Para' }, + }, + }); + expect(result).toEqual({ + 0: { id: 1, obat: 'Paracetamol' }, + totalCount: 10, + }); + }); + }); + + describe('createObat', () => { + const payload: CreateObatDto = { + id_visit: 'VISIT-1', + obat: 'Amoxicillin', + jumlah_obat: 2, + aturan_pakai: '3x1', + }; + + it('throws when visit not found', async () => { + prisma.rekam_medis.findUnique.mockResolvedValueOnce(null); + + await expect(service.createObat(payload, mockUser)).rejects.toThrow( + BadRequestException, + ); + expect(prisma.pemberian_obat.create).not.toHaveBeenCalled(); + }); + + it('creates obat and stores log', async () => { + prisma.rekam_medis.findUnique.mockResolvedValueOnce({ + id_visit: 'VISIT-1', + }); + prisma.pemberian_obat.create.mockResolvedValueOnce({ + id: 42, + ...payload, + }); + logService.storeLog.mockResolvedValueOnce({ txId: 'abc' }); + + const result = await service.createObat(payload, mockUser); + + expect(prisma.pemberian_obat.create).toHaveBeenCalledWith({ + data: { + id_visit: 'VISIT-1', + obat: 'Amoxicillin', + jumlah_obat: 2, + aturan_pakai: '3x1', + }, + }); + + expect(logService.storeLog).toHaveBeenCalledWith({ + id: 'OBAT_42', + event: 'obat_created', + user_id: mockUser.sub, + payload: expect.any(String), + }); + + expect(result).toEqual({ + id: 42, + id_visit: 'VISIT-1', + obat: 'Amoxicillin', + jumlah_obat: 2, + aturan_pakai: '3x1', + txId: 'abc', + }); + }); + }); + + describe('updateObatById', () => { + const updatePayload: UpdateObatDto = { + obat: 'Ibuprofen', + jumlah_obat: 1, + aturan_pakai: '2x1', + }; + + it('updates obat and stores log', async () => { + prisma.pemberian_obat.update.mockResolvedValueOnce({ + id: 99, + id_visit: 'VISIT-1', + ...updatePayload, + }); + logService.storeLog.mockResolvedValueOnce({ txId: 'updated' }); + + const result = await service.updateObatById(99, updatePayload, mockUser); + + expect(prisma.pemberian_obat.update).toHaveBeenCalledWith({ + where: { id: 99 }, + data: { + obat: 'Ibuprofen', + jumlah_obat: 1, + aturan_pakai: '2x1', + }, + }); + + expect(logService.storeLog).toHaveBeenCalledWith({ + id: 'OBAT_99', + event: 'obat_updated', + user_id: mockUser.sub, + payload: expect.any(String), + }); + + expect(result).toEqual({ + id: 99, + id_visit: 'VISIT-1', + obat: 'Ibuprofen', + jumlah_obat: 1, + aturan_pakai: '2x1', + txId: 'updated', + }); + }); + }); + + describe('getLogObatById', () => { + it('returns processed logs and tamper status', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValueOnce({ + id: 5, + obat: 'Paracetamol', + jumlah_obat: 1, + aturan_pakai: '3x1', + }); + const expectedHash = service.createHashingPayload({ + obat: 'Paracetamol', + jumlah_obat: 1, + aturan_pakai: '3x1', + }); + + logService.getLogById.mockResolvedValueOnce([ + { + value: { + event: 'obat_created', + payload: expectedHash, + timestamp: '2024-01-01T00:00:00Z', + user_id: 1, + }, + txId: 'abc', + }, + ]); + + const result = await service.getLogObatById('5'); + + expect(logService.getLogById).toHaveBeenCalledWith('OBAT_5'); + expect(result).toEqual({ + logs: [ + { + event: 'obat_created', + payload: expectedHash, + timestamp: '2024-01-01T00:00:00Z', + user_id: 1, + txId: 'abc', + status: 'ORIGINAL', + }, + ], + isTampered: false, + currentDataHash: expectedHash, + }); + }); + }); }); diff --git a/backend/api/src/modules/obat/obat.service.ts b/backend/api/src/modules/obat/obat.service.ts index 2a08f7b..06de4eb 100644 --- a/backend/api/src/modules/obat/obat.service.ts +++ b/backend/api/src/modules/obat/obat.service.ts @@ -183,4 +183,8 @@ export class ObatService { ...logResult, }; } + + async countObat() { + return this.prisma.pemberian_obat.count(); + } } diff --git a/backend/api/src/modules/rekammedis/rekammedis.module.ts b/backend/api/src/modules/rekammedis/rekammedis.module.ts index 2f4c9ca..fad9405 100644 --- a/backend/api/src/modules/rekammedis/rekammedis.module.ts +++ b/backend/api/src/modules/rekammedis/rekammedis.module.ts @@ -9,5 +9,6 @@ import { LogModule } from '../log/log.module'; imports: [PrismaModule, LogModule], controllers: [RekamMedisController], providers: [RekammedisService], + exports: [RekammedisService], }) export class RekamMedisModule {} diff --git a/backend/api/src/modules/rekammedis/rekammedis.service.ts b/backend/api/src/modules/rekammedis/rekammedis.service.ts index 8567170..493bd40 100644 --- a/backend/api/src/modules/rekammedis/rekammedis.service.ts +++ b/backend/api/src/modules/rekammedis/rekammedis.service.ts @@ -393,4 +393,8 @@ export class RekammedisService { log: createdLog, }; } + + async countRekamMedis() { + return this.prisma.rekam_medis.count(); + } } diff --git a/backend/api/src/modules/tindakandokter/dto/create-tindakan-dto.ts b/backend/api/src/modules/tindakandokter/dto/create-tindakan-dto.ts new file mode 100644 index 0000000..317ae0d --- /dev/null +++ b/backend/api/src/modules/tindakandokter/dto/create-tindakan-dto.ts @@ -0,0 +1,81 @@ +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Length, + MaxLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +const KATEGORI_TINDAKAN_OPTIONS = [ + 'Radiologi', + 'Laboratorium', + 'EKG', + 'Tindakan', + 'Tindakan Poliklinik', + 'USG', + 'Alat Canggih', + 'Tindakan Fisioterapi', + 'Tindakan Dokter', + 'Pemeriksaan', + 'Jasa Tindakan Medis Rawat Jalan', + 'Audiometry', + 'Kamar Bedah', + 'Jasa Dokter Rawat Inap', + 'Endoskopi EGD', +] as const; + +const KELOMPOK_TINDAKAN_OPTIONS = [ + 'PEMERIKSAAN', + 'LAIN-LAIN', + 'TINDAKAN', + 'LABORATORIUM', +] as const; + +type KategoriTindakan = (typeof KATEGORI_TINDAKAN_OPTIONS)[number]; +type KelompokTindakan = (typeof KELOMPOK_TINDAKAN_OPTIONS)[number]; + +const trimRequired = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +const trimOptional = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +export class CreateTindakanDokterDto { + @IsNotEmpty({ message: 'ID Visit wajib diisi' }) + @IsString() + @Length(1, 25, { message: 'ID Visit maksimal 25 karakter' }) + @Transform(trimRequired) + id_visit: string; + + @IsNotEmpty({ message: 'Nama tindakan wajib diisi' }) + @IsString() + @Length(1, 100, { message: 'Nama tindakan maksimal 100 karakter' }) + @Transform(trimRequired) + tindakan: string; + + @IsOptional() + @IsString() + @MaxLength(50, { message: 'Kategori tindakan maksimal 50 karakter' }) + @IsIn(KATEGORI_TINDAKAN_OPTIONS, { + message: 'Pastikan kategori tindakan valid', + }) + @Transform(trimOptional) + kategori_tindakan?: KategoriTindakan; + + @IsOptional() + @IsString() + @MaxLength(50, { message: 'Kelompok tindakan maksimal 50 karakter' }) + @IsIn(KELOMPOK_TINDAKAN_OPTIONS, { + message: 'Pastikan kelompok tindakan valid', + }) + @Transform(trimOptional) + kelompok_tindakan?: KelompokTindakan; +} diff --git a/backend/api/src/modules/tindakandokter/dto/update-tindakan-dto.ts b/backend/api/src/modules/tindakandokter/dto/update-tindakan-dto.ts new file mode 100644 index 0000000..b37b148 --- /dev/null +++ b/backend/api/src/modules/tindakandokter/dto/update-tindakan-dto.ts @@ -0,0 +1,78 @@ +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Length, + MaxLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +const KATEGORI_TINDAKAN_OPTIONS = [ + 'Radiologi', + 'Laboratorium', + 'EKG', + 'Tindakan', + 'Tindakan Poliklinik', + 'USG', + 'Alat Canggih', + 'Tindakan Fisioterapi', + 'Tindakan Dokter', + 'Pemeriksaan', + 'Jasa Tindakan Medis Rawat Jalan', + 'Audiometry', + 'Kamar Bedah', + 'Jasa Dokter Rawat Inap', + 'Endoskopi EGD', +] as const; + +const KELOMPOK_TINDAKAN_OPTIONS = [ + 'PEMERIKSAAN', + 'LAIN-LAIN', + 'TINDAKAN', + 'LABORATORIUM', +] as const; + +type KategoriTindakan = (typeof KATEGORI_TINDAKAN_OPTIONS)[number]; +type KelompokTindakan = (typeof KELOMPOK_TINDAKAN_OPTIONS)[number]; + +const trimOptional = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +export class UpdateTindakanDokterDto { + @IsOptional() + @IsString() + @Length(1, 25, { message: 'ID Visit maksimal 25 karakter' }) + @Transform(trimOptional) + id_visit?: string; + + @IsNotEmpty() + @IsString() + @Length(1, 100, { message: 'Nama tindakan maksimal 100 karakter' }) + @Transform(trimOptional) + tindakan?: string; + + @IsNotEmpty() + @IsString() + @MaxLength(50, { message: 'Kategori tindakan maksimal 50 karakter' }) + @IsIn(KATEGORI_TINDAKAN_OPTIONS, { + message: 'Pastikan kategori tindakan valid', + }) + @Transform(trimOptional) + kategori_tindakan?: KategoriTindakan; + + @IsNotEmpty() + @IsString() + @MaxLength(50, { message: 'Kelompok tindakan maksimal 50 karakter' }) + @IsIn(KELOMPOK_TINDAKAN_OPTIONS, { + message: 'Pastikan kelompok tindakan valid', + }) + @Transform(trimOptional) + kelompok_tindakan?: KelompokTindakan; +} diff --git a/backend/api/src/modules/tindakandokter/tindakandokter.controller.ts b/backend/api/src/modules/tindakandokter/tindakandokter.controller.ts index 8e555d7..eff4049 100644 --- a/backend/api/src/modules/tindakandokter/tindakandokter.controller.ts +++ b/backend/api/src/modules/tindakandokter/tindakandokter.controller.ts @@ -1,14 +1,19 @@ import { + Body, Controller, Get, - Header, - HttpCode, Param, + Post, + Put, Query, UseGuards, } from '@nestjs/common'; import { TindakanDokterService } from './tindakandokter.service'; import { AuthGuard } from '../auth/guard/auth.guard'; +import { CurrentUser } from '../auth/decorator/current-user.decorator'; +import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; +import { CreateTindakanDokterDto } from './dto/create-tindakan-dto'; +import { UpdateTindakanDokterDto } from './dto/update-tindakan-dto'; @Controller('/tindakan') export class TindakanDokterController { @@ -39,4 +44,39 @@ export class TindakanDokterController { orderBy: orderBy ? { [orderBy]: order || 'asc' } : undefined, }); } + + @Post('/') + @UseGuards(AuthGuard) + async createTindakanDokter( + @Body() data: CreateTindakanDokterDto, + @CurrentUser() user: ActiveUserPayload, + ) { + return await this.tindakanDokterService.createTindakanDokter(data, user); + } + + @Get('/:id') + @UseGuards(AuthGuard) + async getTindakanDokterById(@Param('id') id: number) { + return await this.tindakanDokterService.getTindakanDokterById(id); + } + + @Put('/:id') + @UseGuards(AuthGuard) + async updateTindakanDokter( + @Param('id') id: number, + @Body() data: UpdateTindakanDokterDto, + @CurrentUser() user: ActiveUserPayload, + ) { + return await this.tindakanDokterService.updateTindakanDokter( + id, + data, + user, + ); + } + + @Get('/:id/log') + @UseGuards(AuthGuard) + async getTindakanLog(@Param('id') id: string) { + return await this.tindakanDokterService.getTindakanLogById(id); + } } diff --git a/backend/api/src/modules/tindakandokter/tindakandokter.module.ts b/backend/api/src/modules/tindakandokter/tindakandokter.module.ts index b8613c3..20afd19 100644 --- a/backend/api/src/modules/tindakandokter/tindakandokter.module.ts +++ b/backend/api/src/modules/tindakandokter/tindakandokter.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { TindakanDokterController } from './tindakandokter.controller'; import { TindakanDokterService } from './tindakandokter.service'; import { PrismaModule } from '../prisma/prisma.module'; -import { JwtModule } from '@nestjs/jwt'; +import { LogModule } from '../log/log.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, LogModule], controllers: [TindakanDokterController], providers: [TindakanDokterService], + exports: [TindakanDokterService], }) export class TindakanDokterModule {} diff --git a/backend/api/src/modules/tindakandokter/tindakandokter.service.ts b/backend/api/src/modules/tindakandokter/tindakandokter.service.ts index 4a978a6..b03bc8f 100644 --- a/backend/api/src/modules/tindakandokter/tindakandokter.service.ts +++ b/backend/api/src/modules/tindakandokter/tindakandokter.service.ts @@ -1,10 +1,43 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { Prisma } from '@dist/generated/prisma'; +import { sha256 } from '@api/common/crypto/hash'; +import { LogService } from '../log/log.service'; +import { CreateTindakanDokterDto } from './dto/create-tindakan-dto'; +import { UpdateTindakanDokterDto } from './dto/update-tindakan-dto'; +import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; @Injectable() export class TindakanDokterService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private logService: LogService, + ) {} + + createHashingPayload(currentData: any): string { + return sha256(JSON.stringify(currentData)); + } + + determineStatus(rawFabricLog: any, index: number, arrLength: number): any { + const flatLog = { + ...rawFabricLog.value, + txId: rawFabricLog.txId, + timestamp: rawFabricLog.value.timestamp, + }; + + console.log('Processed flat log:', flatLog); + + if ( + index === arrLength - 1 && + rawFabricLog.value.event === 'tindakan_dokter_created' + ) { + flatLog.status = 'ORIGINAL'; + } else { + flatLog.status = 'UPDATED'; + } + + return flatLog; + } async getAllTindakanDokter(params: { skip?: number; @@ -17,16 +50,7 @@ export class TindakanDokterService { orderBy?: Prisma.pemberian_tindakanOrderByWithRelationInput; order?: 'asc' | 'desc'; }) { - const { - skip, - page, - tindakan, - orderBy, - order, - id_visit, - kelompok_tindakan, - kategori_tindakan, - } = params; + const { skip, page, tindakan, orderBy, order, id_visit } = params; const take = params.take ? parseInt(params.take.toString()) : 10; const kelompok_tindakanArray = params.kelompok_tindakan ? params.kelompok_tindakan.split(',') @@ -81,4 +105,189 @@ export class TindakanDokterService { totalCount: count, }; } + + async createTindakanDokter( + dto: CreateTindakanDokterDto, + user: ActiveUserPayload, + ) { + const visitExists = await this.prisma.rekam_medis.findUnique({ + where: { id_visit: dto.id_visit }, + }); + + if (!visitExists) { + throw new BadRequestException( + `Visit dengan ID ${dto.id_visit} tidak ditemukan`, + ); + } + + 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 hashingPayload = this.createHashingPayload({ + id_visit: createdTindakan.id_visit, + tindakan: createdTindakan.tindakan, + kategori_tindakan: createdTindakan.kategori_tindakan ?? null, + kelompok_tindakan: createdTindakan.kelompok_tindakan ?? null, + }); + + const logPayloadHash = hashingPayload; + + const logResult = await this.logService.storeLog({ + id: `TINDAKAN_${createdTindakan.id}`, + event: 'tindakan_dokter_created', + user_id: user.sub, + payload: logPayloadHash, + }); + + return { + ...createdTindakan, + log: logResult, + }; + } + + async getTindakanDokterById(id: number) { + const tindakanId = Number(id); + + if (Number.isNaN(tindakanId)) { + throw new BadRequestException('ID tindakan tidak valid'); + } + + return this.prisma.pemberian_tindakan.findUnique({ + where: { id: tindakanId }, + }); + } + + async updateTindakanDokter( + id: number, + dto: UpdateTindakanDokterDto, + user: ActiveUserPayload, + ) { + const tindakanId = Number(id); + + if (Number.isNaN(tindakanId)) { + throw new BadRequestException('ID tindakan tidak valid'); + } + + const existing = await this.prisma.pemberian_tindakan.findUnique({ + where: { id: tindakanId }, + }); + + if (!existing) { + throw new BadRequestException( + `Tindakan dokter dengan ID ${id} tidak ditemukan`, + ); + } + + const hasUpdates = + dto.id_visit !== undefined || + dto.tindakan !== undefined || + dto.kategori_tindakan !== undefined || + dto.kelompok_tindakan !== undefined; + + if (!hasUpdates) { + throw new BadRequestException('Tidak ada data tindakan yang diubah'); + } + + if (dto.id_visit) { + const visitExists = await this.prisma.rekam_medis.findUnique({ + where: { id_visit: dto.id_visit }, + }); + + if (!visitExists) { + throw new BadRequestException( + `Visit dengan ID ${dto.id_visit} tidak ditemukan`, + ); + } + } + + const updateData: Prisma.pemberian_tindakanUpdateInput = { + ...(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 } + : {}), + }; + + const updatedTindakan = await this.prisma.pemberian_tindakan.update({ + where: { id: tindakanId }, + data: updateData, + }); + + const hashingPayload = this.createHashingPayload({ + 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({ + id: `TINDAKAN_${tindakanId}`, + event: 'tindakan_dokter_updated', + user_id: user.sub, + payload: hashingPayload, + }); + + return { + ...updatedTindakan, + log: logResult, + }; + } + + async getTindakanLogById(id: string) { + const tindakanId = parseInt(id, 10); + + if (Number.isNaN(tindakanId)) { + throw new BadRequestException('ID tindakan tidak valid'); + } + + const currentData = await this.prisma.pemberian_tindakan.findUnique({ + where: { id: tindakanId }, + }); + + if (!currentData) { + throw new BadRequestException( + `Tindakan dokter dengan ID ${id} tidak ditemukan`, + ); + } + + const idLog = `TINDAKAN_${id}`; + const rawLogs = await this.logService.getLogById(idLog); + + const currentDataHash = this.createHashingPayload({ + id_visit: currentData.id_visit, + tindakan: currentData.tindakan, + kategori_tindakan: currentData.kategori_tindakan ?? null, + kelompok_tindakan: currentData.kelompok_tindakan ?? null, + }); + + const latestPayload = rawLogs?.[0]?.value?.payload; + const isTampered = latestPayload + ? currentDataHash !== latestPayload + : false; + + const processedLogs = Array.isArray(rawLogs) + ? rawLogs.map((log, index) => + this.determineStatus(log, index, rawLogs.length), + ) + : []; + + return { + logs: processedLogs, + isTampered, + currentDataHash, + }; + } + + async countTindakanDokter() { + return this.prisma.pemberian_tindakan.count(); + } } diff --git a/frontend/hospital-log/src/components/dashboard/DataTable.vue b/frontend/hospital-log/src/components/dashboard/DataTable.vue index 94e3d50..20eff7e 100644 --- a/frontend/hospital-log/src/components/dashboard/DataTable.vue +++ b/frontend/hospital-log/src/components/dashboard/DataTable.vue @@ -27,6 +27,24 @@ const pendingDeleteItem = ref(null); const hasStatusColumn = () => props.columns.some((col) => col.key === "status"); +const formatCellValue = (item: T, columnKey: keyof T) => { + const value = item[columnKey]; + + if (columnKey === "event" && typeof value === "string") { + const segments = value.split("_"); + + if (segments.length >= 3 && segments[segments.length - 1] === "created") { + return "CREATE"; + } + + if (segments.length >= 3 && segments[segments.length - 1] === "updated") { + return "UPDATE"; + } + } + + return value; +}; + const openDeleteDialog = (item: T) => { pendingDeleteItem.value = item; deleteDialogRef.value?.show(); @@ -109,7 +127,7 @@ const handleDeleteCancel = () => { hasStatusColumn() ? 'text-xs' : '', ]" > - {{ item[column.key] }} + {{ formatCellValue(item, column.key) }}
diff --git a/frontend/hospital-log/src/components/dashboard/Sidebar.vue b/frontend/hospital-log/src/components/dashboard/Sidebar.vue index c2770bf..d1a9c0f 100644 --- a/frontend/hospital-log/src/components/dashboard/Sidebar.vue +++ b/frontend/hospital-log/src/components/dashboard/Sidebar.vue @@ -21,12 +21,42 @@ const navigateTo = (routeName: string) => { }; const isActive = (routeName: string) => { + if (route.name === "rekam-medis-add" && routeName === "rekam-medis") { + return true; + } if (route.name === "rekam-medis-details" && routeName === "rekam-medis") { return true; } + if (route.name === "rekam-medis-edit" && routeName === "rekam-medis") { + return true; + } + if ( + route.name === "pemberian-tindakan-add" && + routeName === "pemberian-tindakan" + ) { + return true; + } + if ( + route.name === "pemberian-tindakan-details" && + routeName === "pemberian-tindakan" + ) { + return true; + } + if ( + route.name === "pemberian-tindakan-edit" && + routeName === "pemberian-tindakan" + ) { + return true; + } + if (route.name === "pemberian-obat-add" && routeName === "obat") { + return true; + } if (route.name === "pemberian-obat-details" && routeName === "obat") { return true; } + if (route.name === "pemberian-obat-edit" && routeName === "obat") { + return true; + } return route.name === routeName; }; diff --git a/frontend/hospital-log/src/constants/interfaces.ts b/frontend/hospital-log/src/constants/interfaces.ts index cc65157..a1d8388 100644 --- a/frontend/hospital-log/src/constants/interfaces.ts +++ b/frontend/hospital-log/src/constants/interfaces.ts @@ -54,6 +54,7 @@ interface BlockchainLog { timestamp: string; hash: string; userId: number; + status: string; } export type { RekamMedis, Users, TindakanDokter, BlockchainLog, Obat }; diff --git a/frontend/hospital-log/src/routes/index.ts b/frontend/hospital-log/src/routes/index.ts index aaf5254..25b1704 100644 --- a/frontend/hospital-log/src/routes/index.ts +++ b/frontend/hospital-log/src/routes/index.ts @@ -13,6 +13,9 @@ import PemberianObatEditView from "../views/dashboard/PemberianObatEditView.vue" import CreateRekamMedisView from "../views/dashboard/CreateRekamMedisView.vue"; import RekamMedisEditView from "../views/dashboard/RekamMedisEditView.vue"; import CreateObatView from "../views/dashboard/CreateObatView.vue"; +import CreateTindakanDokterView from "../views/dashboard/CreateTindakanDokterView.vue"; +import TindakanDokterEditView from "../views/dashboard/TindakanDokterEditView.vue"; +import TindakanDokterDetailsView from "../views/dashboard/TindakanDokterDetailsView.vue"; const routes = [ { @@ -81,6 +84,24 @@ const routes = [ component: TindakanView, meta: { requiresAuth: true }, }, + { + path: "/pemberian-tindakan/add", + name: "pemberian-tindakan-add", + component: CreateTindakanDokterView, + meta: { requiresAuth: true }, + }, + { + path: "/pemberian-tindakan/:id/edit", + name: "pemberian-tindakan-edit", + component: TindakanDokterEditView, + meta: { requiresAuth: true }, + }, + { + path: "/pemberian-tindakan/:id/details", + name: "pemberian-tindakan-details", + component: TindakanDokterDetailsView, + meta: { requiresAuth: true }, + }, { path: "/users", name: "users", @@ -88,7 +109,7 @@ const routes = [ meta: { requiresAuth: true }, }, { - path: "/:catchAll(.*)*", // This regex matches any path + path: "/:catchAll(.*)*", name: "NotFound", component: NotFoundView, }, diff --git a/frontend/hospital-log/src/validation/tindakan.ts b/frontend/hospital-log/src/validation/tindakan.ts new file mode 100644 index 0000000..575cc0e --- /dev/null +++ b/frontend/hospital-log/src/validation/tindakan.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; + +const KATEGORI_TINDAKAN_OPTIONS = [ + "Radiologi", + "Laboratorium", + "EKG", + "Tindakan", + "Tindakan Poliklinik", + "USG", + "Alat Canggih", + "Tindakan Fisioterapi", + "Tindakan Dokter", + "Pemeriksaan", + "Jasa Tindakan Medis Rawat Jalan", + "Audiometry", + "Kamar Bedah", + "Jasa Dokter Rawat Inap", + "Endoskopi EGD", +] as const; + +const KELOMPOK_TINDAKAN_OPTIONS = [ + "PEMERIKSAAN", + "LAIN-LAIN", + "TINDAKAN", + "LABORATORIUM", +] as const; + +type NonEmptyStringOptions = { + max?: { + value: number; + message: string; + }; +}; + +const trimmedString = (message: string, options?: NonEmptyStringOptions) => { + let schema = z.string().trim().min(1, message); + + if (options?.max) { + schema = schema.max(options.max.value, options.max.message); + } + + return schema; +}; + +type KategoriTindakan = (typeof KATEGORI_TINDAKAN_OPTIONS)[number]; +type KelompokTindakan = (typeof KELOMPOK_TINDAKAN_OPTIONS)[number]; + +const isKategoriTindakanOption = (value: string): value is KategoriTindakan => + (KATEGORI_TINDAKAN_OPTIONS as readonly string[]).includes(value); + +const isKelompokTindakanOption = (value: string): value is KelompokTindakan => + (KELOMPOK_TINDAKAN_OPTIONS as readonly string[]).includes(value); + +const kategoriTindakanSchema = z + .string() + .trim() + .min(1, "Kategori tindakan wajib diisi") + .refine(isKategoriTindakanOption, "Pastikan kategori tindakan valid") + .transform((value) => value as KategoriTindakan); + +const kelompokTindakanSchema = z + .string() + .trim() + .min(1, "Kelompok tindakan wajib diisi") + .refine(isKelompokTindakanOption, "Pastikan kelompok tindakan valid") + .transform((value) => value as KelompokTindakan); + +export const tindakanFormSchema = z + .object({ + id_visit: trimmedString("ID Visit wajib diisi", { + max: { + value: 50, + message: "ID Visit maksimal 50 karakter", + }, + }), + tindakan: trimmedString("Nama tindakan wajib diisi", { + max: { + value: 150, + message: "Nama tindakan maksimal 150 karakter", + }, + }), + kategori_tindakan: kategoriTindakanSchema, + kelompok_tindakan: kelompokTindakanSchema, + }) + .passthrough(); + +export type TindakanFormInput = z.input; +export type TindakanFormValues = z.infer; + +export interface TindakanPayload { + id_visit: string; + tindakan: string; + kategori_tindakan: KategoriTindakan; + kelompok_tindakan: KelompokTindakan; +} + +export type TindakanFormErrors = Record; + +const sanitize = (value: string) => value.trim(); + +export const buildTindakanPayload = ( + values: TindakanFormValues +): TindakanPayload => ({ + id_visit: sanitize(values.id_visit), + tindakan: sanitize(values.tindakan), + kategori_tindakan: values.kategori_tindakan, + kelompok_tindakan: values.kelompok_tindakan, +}); + +export const validateTindakanForm = ( + values: TindakanFormInput +): + | { success: true; data: TindakanPayload } + | { success: false; errors: TindakanFormErrors } => { + const result = tindakanFormSchema.safeParse(values); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + + const errors = Object.entries(fieldErrors).reduce( + (accumulator, [field, messages]) => { + if (messages && messages.length > 0) { + accumulator[field] = messages[0] ?? ""; + } + + return accumulator; + }, + {} + ); + + return { success: false, errors }; + } + + return { + success: true, + data: buildTindakanPayload(result.data), + }; +}; diff --git a/frontend/hospital-log/src/views/dashboard/CreateTindakanDokterView.vue b/frontend/hospital-log/src/views/dashboard/CreateTindakanDokterView.vue index e69de29..9a435bf 100644 --- a/frontend/hospital-log/src/views/dashboard/CreateTindakanDokterView.vue +++ b/frontend/hospital-log/src/views/dashboard/CreateTindakanDokterView.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend/hospital-log/src/views/dashboard/DashboardView.vue b/frontend/hospital-log/src/views/dashboard/DashboardView.vue index b716b15..3f110a3 100644 --- a/frontend/hospital-log/src/views/dashboard/DashboardView.vue +++ b/frontend/hospital-log/src/views/dashboard/DashboardView.vue @@ -1,16 +1,64 @@