feat: dashboard, CRU Tindakan Dokter
This commit is contained in:
parent
a3bd6e028a
commit
91495091d5
|
|
@ -72,6 +72,10 @@
|
|||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^@api/(.*)$": "<rootDir>/$1",
|
||||
"^@dist/(.*)$": "<rootDir>/../dist/$1"
|
||||
},
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ import { LogModule } from '../log/log.module';
|
|||
imports: [PrismaModule, LogModule],
|
||||
controllers: [ObatController],
|
||||
providers: [ObatService],
|
||||
exports: [ObatService],
|
||||
})
|
||||
export class ObatModule {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -183,4 +183,8 @@ export class ObatService {
|
|||
...logResult,
|
||||
};
|
||||
}
|
||||
|
||||
async countObat() {
|
||||
return this.prisma.pemberian_obat.count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ import { LogModule } from '../log/log.module';
|
|||
imports: [PrismaModule, LogModule],
|
||||
controllers: [RekamMedisController],
|
||||
providers: [RekammedisService],
|
||||
exports: [RekammedisService],
|
||||
})
|
||||
export class RekamMedisModule {}
|
||||
|
|
|
|||
|
|
@ -393,4 +393,8 @@ export class RekammedisService {
|
|||
log: createdLog,
|
||||
};
|
||||
}
|
||||
|
||||
async countRekamMedis() {
|
||||
return this.prisma.rekam_medis.count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ interface BlockchainLog {
|
|||
timestamp: string;
|
||||
hash: string;
|
||||
userId: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type { RekamMedis, Users, TindakanDokter, BlockchainLog, Obat };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
138
frontend/hospital-log/src/validation/tindakan.ts
Normal file
138
frontend/hospital-log/src/validation/tindakan.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user