feat: dashboard, CRU Tindakan Dokter

This commit is contained in:
yosaphatprs 2025-11-06 17:07:08 +07:00
parent a3bd6e028a
commit 91495091d5
26 changed files with 2034 additions and 44 deletions

View File

@ -72,6 +72,10 @@
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@api/(.*)$": "<rootDir>/$1",
"^@dist/(.*)$": "<rootDir>/../dist/$1"
},
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"

View File

@ -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();
}
}

View File

@ -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 };
}
}

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -8,5 +8,6 @@ import { LogModule } from '../log/log.module';
imports: [PrismaModule, LogModule],
controllers: [ObatController],
providers: [ObatService],
exports: [ObatService],
})
export class ObatModule {}

View File

@ -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<T> = {
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<any>,
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<typeof createPrismaMock>;
let logService: ReturnType<typeof createLogServiceMock>;
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>(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,
});
});
});
});

View File

@ -183,4 +183,8 @@ export class ObatService {
...logResult,
};
}
async countObat() {
return this.prisma.pemberian_obat.count();
}
}

View File

@ -9,5 +9,6 @@ import { LogModule } from '../log/log.module';
imports: [PrismaModule, LogModule],
controllers: [RekamMedisController],
providers: [RekammedisService],
exports: [RekammedisService],
})
export class RekamMedisModule {}

View File

@ -393,4 +393,8 @@ export class RekammedisService {
log: createdLog,
};
}
async countRekamMedis() {
return this.prisma.rekam_medis.count();
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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();
}
}

View File

@ -27,6 +27,24 @@ const pendingDeleteItem = ref<T | null>(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) }}
</td>
<td v-if="!hasStatusColumn()">
<div class="flex gap-2">

View File

@ -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;
};
</script>

View File

@ -54,6 +54,7 @@ interface BlockchainLog {
timestamp: string;
hash: string;
userId: number;
status: string;
}
export type { RekamMedis, Users, TindakanDokter, BlockchainLog, Obat };

View File

@ -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,
},

View File

@ -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<typeof tindakanFormSchema>;
export type TindakanFormValues = z.infer<typeof tindakanFormSchema>;
export interface TindakanPayload {
id_visit: string;
tindakan: string;
kategori_tindakan: KategoriTindakan;
kelompok_tindakan: KelompokTindakan;
}
export type TindakanFormErrors = Record<string, string>;
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<TindakanFormErrors>(
(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),
};
};

View File

@ -0,0 +1,310 @@
<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 FieldInput from "../../components/dashboard/FieldInput.vue";
import ButtonDark from "../../components/dashboard/ButtonDark.vue";
import { onMounted, ref, watch } from "vue";
import { useApi, type ApiError } from "../../composables/useApi";
import { useDebounce } from "../../composables/useDebounce";
import { DEBOUNCE_DELAY, FILTER } from "../../constants/pagination";
import {
validateTindakanForm,
type TindakanFormInput,
type TindakanFormErrors,
} from "../../validation/tindakan";
type CreateTindakanFormErrors = Partial<TindakanFormErrors>;
const api = useApi();
const data = ref<TindakanFormInput>({
id_visit: "",
tindakan: "",
kategori_tindakan: "",
kelompok_tindakan: "",
});
const visitOptions = ref<string[]>([]);
const isVisitOptionsLoading = ref<boolean>(false);
const fetchVisitOptions = async (searchTerm: string) => {
const params = new URLSearchParams({
take: "10",
page: "1",
});
if (searchTerm.trim()) {
params.set("id_visit", searchTerm.trim());
}
try {
isVisitOptionsLoading.value = true;
const result = await api.get<any>(`/rekammedis?${params.toString()}`);
let records: Array<{ id_visit?: string }> = [];
if (result && Array.isArray((result as any).data)) {
records = (result as any).data as Array<{ id_visit?: string }>;
} else if (result && typeof result === "object") {
const maybeRecords: Array<{ id_visit?: string }> = [];
Object.keys(result).forEach((key) => {
const potentialIndex = Number(key);
if (!Number.isNaN(potentialIndex)) {
const entry = (result as Record<string, any>)[key];
if (entry && typeof entry === "object") {
maybeRecords.push(entry);
}
}
});
records = maybeRecords;
}
const uniqueIds = Array.from(
new Set(
records
.map((record) => record.id_visit)
.filter((idVisit): idVisit is string => Boolean(idVisit))
)
);
visitOptions.value = uniqueIds.slice(0, 10);
} catch (error) {
console.error("Failed to fetch visit options:", error);
visitOptions.value = [];
} finally {
isVisitOptionsLoading.value = false;
}
};
const { debounce } = useDebounce();
const debouncedFetchVisitOptions = debounce(
(term: string) => fetchVisitOptions(term),
DEBOUNCE_DELAY
);
const errors = ref<CreateTindakanFormErrors>({});
const isLoading = ref<boolean>(false);
const isSuccess = ref<boolean>(false);
const submitError = ref<string | null>(null);
const clearError = (field: keyof TindakanFormInput) => {
if (errors.value[field]) {
const { [field]: _omit, ...rest } = errors.value;
errors.value = rest;
}
submitError.value = null;
};
const resetForm = () => {
data.value = {
id_visit: "",
tindakan: "",
kategori_tindakan: "",
kelompok_tindakan: "",
};
};
const handleSubmit = async () => {
isSuccess.value = false;
submitError.value = null;
const validationResult = validateTindakanForm(data.value);
if (!validationResult.success) {
errors.value = validationResult.errors;
return;
}
errors.value = {};
isLoading.value = true;
try {
const response = await api.post("/tindakan", validationResult.data);
console.log(response);
isSuccess.value = true;
resetForm();
} catch (error) {
console.error("Failed to create tindakan:", error);
submitError.value =
(error as ApiError)?.message || "Gagal menyimpan data tindakan.";
} finally {
isLoading.value = false;
}
};
watch(
() => data.value.id_visit,
(value) => {
clearError("id_visit");
debouncedFetchVisitOptions(value);
}
);
watch(
() => data.value.tindakan,
() => clearError("tindakan")
);
watch(
() => data.value.kategori_tindakan,
() => clearError("kategori_tindakan")
);
watch(
() => data.value.kelompok_tindakan,
() => clearError("kelompok_tindakan")
);
onMounted(() => {
document.title = "Tambah Pemberian Tindakan - Hospital Log";
fetchVisitOptions("");
});
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader
title="Pemberian Tindakan"
subtitle="Tambah Pemberian Tindakan"
/>
<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="/pemberian-tindakan"
>Pemberian Tindakan</RouterLink
>
</li>
<li>Tambah Pemberian Tindakan</li>
</ul>
</div>
<h5 class="font-bold">Tambah Pemberian Tindakan</h5>
<div v-if="isSuccess" role="alert" class="alert alert-success">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Berhasil menambahkan data tindakan!</span>
</div>
<div v-if="submitError" role="alert" class="alert alert-error">
<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>{{ submitError }}</span>
</div>
<div
class="flex h-screen flex-col justify-center gap-y-2 items-center"
v-if="isLoading"
>
<span class="loading loading-ring loading-xl"></span>
<h5>Mohon tunggu, sedang menyimpan data tindakan...</h5>
</div>
<form v-else @submit.prevent="handleSubmit" class="text-sm">
<FieldInput
class="mt-2"
label="ID Visit"
type="text"
placeholder="Masukkan ID visit"
v-model="data.id_visit"
:error="errors.id_visit || null"
list="visit-options"
/>
<datalist id="visit-options">
<option
v-for="option in visitOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</datalist>
<FieldInput
class="mt-4"
label="Tindakan"
type="text"
placeholder="Masukkan nama tindakan"
v-model="data.tindakan"
:error="errors.tindakan || null"
/>
<FieldInput
class="mt-2"
placeholder="Masukkan Kategori Tindakan"
label="Kategori Tindakan"
list="kategori_tindakan"
v-model="data.kategori_tindakan"
:error="errors.kategori_tindakan || null"
:readonly="false"
/>
<datalist id="kategori_tindakan">
<option
v-for="value in FILTER.KATEGORI_TINDAKAN"
:value="value"
>
{{ value }}
</option>
</datalist>
<FieldInput
class="mt-2"
placeholder="Masukkan Kelompok Tindakan"
label="Kelompok Tindakan"
list="kelompok_tindakan"
v-model="data.kelompok_tindakan"
:error="errors.kelompok_tindakan || null"
:readonly="false"
/>
<datalist id="kelompok_tindakan">
<option
v-for="value in FILTER.KELOMPOK_TINDAKAN"
:value="value"
>
{{ value }}
</option>
</datalist>
<div
class="flex justify-end gap-2 border-t border-gray-200 mt-6 pt-4"
>
<ButtonDark
type="submit"
:text="isLoading ? 'Menyimpan...' : 'Simpan Tindakan'"
:isLoading="isLoading"
/>
</div>
</form>
</div>
</div>
</Sidebar>
</div>
<Footer></Footer>
</div>
</template>
<style scoped></style>

View File

@ -1,16 +1,64 @@
<script setup lang="ts">
import Sidebar from "../../components/dashboard/Sidebar.vue";
import Footer from "../../components/dashboard/Footer.vue";
import { onMounted, ref } from "vue";
import { useApi } from "../../composables/useApi";
import PageHeader from "../../components/dashboard/PageHeader.vue";
const api = useApi();
const stats = {
countRekamMedis: ref(0),
countTindakanDokter: ref(0),
countObat: ref(0),
};
const fetchStats = async () => {
try {
const result = await api.get<{
countRekamMedis: number;
countTindakanDokter: number;
countObat: number;
}>(`/dashboard`);
console.log("Dashboard stats:", result);
stats.countRekamMedis.value = result.countRekamMedis;
stats.countTindakanDokter.value = result.countTindakanDokter;
stats.countObat.value = result.countObat;
} catch (error) {
console.error("Error fetching dashboard stats:", error);
}
};
onMounted(() => {
fetchStats();
});
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader title="Dashboard" subtitle="Detail Dashboard" />
<div>
<div>Pasien</div>
<div>Jumlah Kunjungan</div>
<div>Jumlah Pengguna</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div
class="bg-white p-4 rounded-lg shadow-md flex flex-col items-center"
>
<h2 class="text-lg font-semibold mb-2">Total Rekam Medis</h2>
<p class="text-3xl font-bold">{{ stats.countRekamMedis }}</p>
</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 Tindakan Dokter</h2>
<p class="text-3xl font-bold">{{ stats.countTindakanDokter }}</p>
</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 Obat</h2>
<p class="text-3xl font-bold">{{ stats.countObat }}</p>
</div>
</div>
</div>
</Sidebar>
</div>

View File

@ -1,5 +1,201 @@
<script setup lang="ts"></script>
<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 DataTable from "../../components/dashboard/DataTable.vue";
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import type { BlockchainLog, TindakanDokter } from "../../constants/interfaces";
import { useApi } from "../../composables/useApi";
import { LOG_TABLE_COLUMNS } from "../../constants/pagination";
<template></template>
// Prevent TypeScript false positives for template-only usage.
const componentsInTemplate = { Sidebar, Footer, PageHeader, DataTable };
void componentsInTemplate;
void LOG_TABLE_COLUMNS;
const tindakan = ref<TindakanDokter>();
const dataLog = ref<BlockchainLog[]>([]);
const isTampered = ref<boolean>(false);
const currentHash = ref<string>("");
const route = useRoute();
const api = useApi();
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 fetchData = async () => {
try {
const result = await api.get<TindakanDokter>(
`/tindakan/${route.params.id}`
);
tindakan.value = result;
} catch (error) {
console.error("Error fetching tindakan data:", error);
tindakan.value = undefined;
}
};
const fetchLogData = async () => {
try {
const result = await api.get<{
currentDataHash?: string;
logs?: any[];
isTampered?: boolean;
}>(`/tindakan/${route.params.id}/log`);
currentHash.value = result.currentDataHash ?? "";
isTampered.value = Boolean(result.isTampered);
console.log("Tindakan Log API Result:", result);
const logs = Array.isArray(result.logs) ? result.logs : [];
dataLog.value = normalizeLogEntries(logs, isTampered.value);
} catch (error) {
console.error("Error fetching tindakan logs:", error);
dataLog.value = [];
isTampered.value = false;
}
};
onMounted(async () => {
await fetchData();
await fetchLogData();
document.title = `Detail Pemberian Tindakan - ID ${route.params.id}`;
});
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader
title="Pemberian Tindakan"
:subtitle="`Detail Pemberian Tindakan ${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="/pemberian-tindakan">
Pemberian Tindakan
</RouterLink>
</li>
<li>Detail Pemberian Tindakan {{ route.params.id }}</li>
</ul>
</div>
<h5 class="font-bold">Detail Pemberian Tindakan</h5>
<div class="text-sm">
<p>ID: {{ tindakan?.id }}</p>
<p>ID Visit: {{ tindakan?.id_visit }}</p>
<p>Tindakan: {{ tindakan?.tindakan }}</p>
<p>
Kategori Tindakan:
{{ tindakan?.kategori_tindakan || "-" }}
</p>
<p>
Kelompok Tindakan:
{{ tindakan?.kelompok_tindakan || "-" }}
</p>
</div>
<hr />
<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
:data="dataLog"
:columns="LOG_TABLE_COLUMNS"
:is-loading="api.isLoading.value"
empty-message="Belum terdapat log perubahan"
:is-aksi="false"
/>
</div>
</div>
</Sidebar>
</div>
<Footer></Footer>
</div>
</template>
<style scoped></style>

View File

@ -1,5 +1,515 @@
<script setup lang="ts"></script>
<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 FieldInput from "../../components/dashboard/FieldInput.vue";
import ButtonDark from "../../components/dashboard/ButtonDark.vue";
import DataTable from "../../components/dashboard/DataTable.vue";
import { onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useApi, type ApiError } from "../../composables/useApi";
import { useDebounce } from "../../composables/useDebounce";
import {
DEBOUNCE_DELAY,
FILTER,
LOG_TABLE_COLUMNS,
} from "../../constants/pagination";
import type { BlockchainLog, TindakanDokter } from "../../constants/interfaces";
import {
validateTindakanForm,
type TindakanFormErrors,
type TindakanFormInput,
} from "../../validation/tindakan";
<template></template>
// Prevent TypeScript false positives for template-only usage.
const componentsInTemplate = {
Sidebar,
Footer,
PageHeader,
FieldInput,
ButtonDark,
DataTable,
};
void componentsInTemplate;
void LOG_TABLE_COLUMNS;
void FILTER;
const api = useApi();
const route = useRoute();
const routeId = ref<string>(route.params.id?.toString() ?? "");
const { debounce } = useDebounce();
const tindakanId = ref<string>("");
const data = ref<TindakanFormInput>({
id_visit: "",
tindakan: "",
kategori_tindakan: "",
kelompok_tindakan: "",
});
const errors = ref<Partial<TindakanFormErrors>>({});
const isSubmitting = ref<boolean>(false);
const isSuccess = ref<boolean>(false);
const submitError = ref<string | null>(null);
const isInitialLoading = ref<boolean>(true);
const visitOptions = ref<string[]>([]);
const isVisitOptionsLoading = ref<boolean>(false);
const dataLog = ref<BlockchainLog[]>([]);
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 fetchVisitOptions = async (searchTerm: string) => {
const params = new URLSearchParams({
take: "10",
page: "1",
});
if (searchTerm.trim()) {
params.set("id_visit", searchTerm.trim());
}
try {
isVisitOptionsLoading.value = true;
const result = await api.get<any>(`/rekammedis?${params.toString()}`);
let records: Array<{ id_visit?: string }> = [];
if (result && Array.isArray((result as any).data)) {
records = (result as any).data as Array<{ id_visit?: string }>;
} else if (result && typeof result === "object") {
const maybeRecords: Array<{ id_visit?: string }> = [];
Object.keys(result).forEach((key) => {
const potentialIndex = Number(key);
if (!Number.isNaN(potentialIndex)) {
const entry = (result as Record<string, any>)[key];
if (entry && typeof entry === "object") {
maybeRecords.push(entry);
}
}
});
records = maybeRecords;
}
const uniqueIds = Array.from(
new Set(
records
.map((record) => record.id_visit)
.filter((idVisit): idVisit is string => Boolean(idVisit))
)
);
visitOptions.value = uniqueIds.slice(0, 10);
} catch (error) {
console.error("Failed to fetch visit options:", error);
visitOptions.value = [];
} finally {
isVisitOptionsLoading.value = false;
}
};
const debouncedFetchVisitOptions = debounce(
(term: string) => fetchVisitOptions(term),
DEBOUNCE_DELAY
);
const clearError = (field: keyof TindakanFormInput) => {
if (errors.value[field]) {
const { [field]: _omit, ...rest } = errors.value;
errors.value = rest;
}
submitError.value = null;
};
const populateForm = (result: TindakanDokter) => {
tindakanId.value = result.id?.toString() ?? routeId.value;
data.value = {
id_visit: result.id_visit ?? "",
tindakan: result.tindakan ?? "",
kategori_tindakan: result.kategori_tindakan ?? "",
kelompok_tindakan: result.kelompok_tindakan ?? "",
};
};
const fetchTindakanDokter = async (suppressLoader = false) => {
if (!routeId.value) {
isInitialLoading.value = false;
return;
}
if (!suppressLoader) {
isInitialLoading.value = true;
}
try {
const result = await api.get<TindakanDokter>(`/tindakan/${routeId.value}`);
populateForm(result);
} catch (error) {
console.error("Error fetching tindakan data:", error);
tindakanId.value = routeId.value;
} finally {
if (!suppressLoader) {
isInitialLoading.value = false;
}
}
};
const fetchLogData = async () => {
if (!routeId.value) {
dataLog.value = [];
isTampered.value = false;
return;
}
try {
const result = await api.get<{
currentDataHash?: string;
logs?: any[];
isTampered?: boolean;
}>(`/tindakan/${routeId.value}/log`);
currentHash.value = result.currentDataHash ?? "";
isTampered.value = Boolean(result.isTampered);
const logs = Array.isArray(result.logs) ? result.logs : [];
dataLog.value = normalizeLogEntries(logs, isTampered.value);
} catch (error) {
console.error("Error fetching tindakan logs:", error);
dataLog.value = [];
isTampered.value = false;
}
};
const handleSubmit = async () => {
isSuccess.value = false;
submitError.value = null;
const validationResult = validateTindakanForm(data.value);
if (!validationResult.success) {
errors.value = validationResult.errors;
return;
}
errors.value = {};
isSubmitting.value = true;
try {
await api.put(`/tindakan/${routeId.value}`, validationResult.data);
isSuccess.value = true;
await fetchTindakanDokter(true);
await fetchLogData();
} catch (error) {
console.error("Failed to update tindakan:", error);
submitError.value =
(error as ApiError)?.message || "Gagal memperbarui data tindakan.";
} finally {
isSubmitting.value = false;
}
};
void handleSubmit;
watch(
() => data.value.id_visit,
(value) => {
clearError("id_visit");
debouncedFetchVisitOptions(value);
}
);
watch(
() => data.value.tindakan,
() => clearError("tindakan")
);
watch(
() => data.value.kategori_tindakan,
() => clearError("kategori_tindakan")
);
watch(
() => data.value.kelompok_tindakan,
() => clearError("kelompok_tindakan")
);
onMounted(async () => {
await Promise.all([
fetchTindakanDokter(),
fetchLogData(),
fetchVisitOptions(""),
]);
document.title = `Edit Pemberian Tindakan - ID ${routeId.value}`;
});
watch(
() => route.params.id,
async (value) => {
const newId = value?.toString() ?? "";
if (newId === routeId.value) {
return;
}
routeId.value = newId;
if (!newId) {
dataLog.value = [];
tindakanId.value = "";
return;
}
await Promise.all([fetchTindakanDokter(), fetchLogData()]);
}
);
</script>
<template>
<div class="bg-light w-full text-dark">
<div class="flex h-full p-2">
<Sidebar>
<PageHeader
title="Pemberian Tindakan"
:subtitle="`Update Pemberian Tindakan ${routeId}`"
/>
<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="/pemberian-tindakan">
Pemberian Tindakan
</RouterLink>
</li>
<li>Update Pemberian Tindakan {{ routeId }}</li>
</ul>
</div>
<h5 class="font-bold">Update Pemberian Tindakan</h5>
<div v-if="isSuccess" role="alert" class="alert alert-success">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Berhasil memperbarui data!</span>
</div>
<div v-if="submitError" role="alert" class="alert alert-error">
<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>{{ submitError }}</span>
</div>
<div
class="flex h-screen flex-col justify-center gap-y-2 items-center"
v-if="isInitialLoading"
>
<span class="loading loading-ring loading-xl"></span>
<h5>Memuat data tindakan...</h5>
</div>
<form v-else @submit.prevent="handleSubmit" class="text-sm">
<FieldInput
class="mt-2"
label="ID"
type="text"
v-model="tindakanId"
:is-disabled="true"
/>
<FieldInput
class="mt-4"
label="ID Visit"
type="text"
placeholder="Masukkan ID visit"
v-model="data.id_visit"
:error="errors.id_visit || null"
list="visit-options"
/>
<datalist id="visit-options">
<option
v-for="option in visitOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</datalist>
<FieldInput
class="mt-4"
label="Tindakan"
type="text"
placeholder="Masukkan nama tindakan"
v-model="data.tindakan"
:error="errors.tindakan || null"
/>
<FieldInput
class="mt-2"
placeholder="Masukkan Kategori Tindakan"
label="Kategori Tindakan"
list="kategori_tindakan"
v-model="data.kategori_tindakan"
:error="errors.kategori_tindakan || null"
:readonly="false"
/>
<datalist id="kategori_tindakan">
<option
v-for="value in FILTER.KATEGORI_TINDAKAN"
:value="value"
>
{{ value }}
</option>
</datalist>
<FieldInput
class="mt-2"
placeholder="Masukkan Kelompok Tindakan"
label="Kelompok Tindakan"
list="kelompok_tindakan"
v-model="data.kelompok_tindakan"
:error="errors.kelompok_tindakan || null"
:readonly="false"
/>
<datalist id="kelompok_tindakan">
<option
v-for="value in FILTER.KELOMPOK_TINDAKAN"
:value="value"
>
{{ value }}
</option>
</datalist>
<div
class="flex justify-end gap-2 border-t border-gray-200 mt-6 pt-4"
>
<ButtonDark
type="submit"
:text="isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'"
:isLoading="isSubmitting"
/>
</div>
</form>
<hr />
<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
:data="dataLog"
:columns="LOG_TABLE_COLUMNS"
:is-loading="api.isLoading.value"
empty-message="Belum terdapat log perubahan"
:is-aksi="false"
/>
</div>
</div>
</Sidebar>
</div>
<Footer></Footer>
</div>
</template>
<style scoped></style>

View File

@ -164,11 +164,11 @@ const handlePageSizeChange = (newSize: number) => {
};
const handleDetails = (item: TindakanDokter) => {
router.push({ name: "tindakan-details", params: { id: item.id } });
router.push({ name: "pemberian-tindakan-details", params: { id: item.id } });
};
const handleUpdate = (item: TindakanDokter) => {
router.push({ name: "tindakan-edit", params: { id: item.id } });
router.push({ name: "pemberian-tindakan-edit", params: { id: item.id } });
};
const handleDelete = async (item: TindakanDokter) => {
@ -307,20 +307,30 @@ onMounted(async () => {
</div>
</div>
<div class="bg-white rounded-xl shadow-md">
<div class="flex items-center px-4 py-4 justify-between gap-4">
<SortDropdown
v-model="sortBy"
:options="SORT_OPTIONS.TINDAKAN"
label="Urut berdasarkan:"
@change="handleSortChange"
/>
<SearchInput
v-model="searchIdVisit"
id="id_visit"
placeholder="Cari berdasarkan ID Visit"
@search="handleSearch"
/>
<div class="flex">
<div class="flex items-center pl-4 pb-4 flex-1">
<RouterLink
to="/pemberian-tindakan/add"
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
>
Tambah Pemberian Tindakan
</RouterLink>
</div>
<div
class="flex flex-col items-end flex-1 px-4 pt-4 pb-2 justify-between gap-4"
>
<SearchInput
v-model="searchIdVisit"
placeholder="Cari berdasarkan ID Visit"
@search="handleSearch"
/>
<SortDropdown
v-model="sortBy"
:options="SORT_OPTIONS.TINDAKAN"
label="Urut berdasarkan:"
@change="handleSortChange"
/>
</div>
</div>
<!-- Data Table -->