feat: dashboard, CRU Tindakan Dokter
This commit is contained in:
parent
a3bd6e028a
commit
91495091d5
|
|
@ -72,6 +72,10 @@
|
||||||
"ts"
|
"ts"
|
||||||
],
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@api/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@dist/(.*)$": "<rootDir>/../dist/$1"
|
||||||
|
},
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(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 { AppService } from './app.service';
|
||||||
|
import { AuthGuard } from './modules/auth/guard/auth.guard';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
|
|
@ -9,4 +10,10 @@ export class AppController {
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/dashboard')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
getDashboard() {
|
||||||
|
return this.appService.getDashboard();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,29 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
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()
|
@Injectable()
|
||||||
export class AppService {
|
export class AppService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private rekamMedisService: RekammedisService,
|
||||||
|
private tindakanDokterService: TindakanDokterService,
|
||||||
|
private obatService: ObatService,
|
||||||
|
private logService: LogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return 'Hello World!';
|
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;
|
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;
|
export default FabricGateway;
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,9 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown {
|
||||||
this.logger.log(`Retrieving log with ID: ${id}`);
|
this.logger.log(`Retrieving log with ID: ${id}`);
|
||||||
return this.gateway.getLogById(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;
|
return result;
|
||||||
}
|
}
|
||||||
async getAllLogs() {
|
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],
|
imports: [PrismaModule, LogModule],
|
||||||
controllers: [ObatController],
|
controllers: [ObatController],
|
||||||
providers: [ObatService],
|
providers: [ObatService],
|
||||||
|
exports: [ObatService],
|
||||||
})
|
})
|
||||||
export class ObatModule {}
|
export class ObatModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,60 @@
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
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';
|
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', () => {
|
describe('ObatService', () => {
|
||||||
let service: ObatService;
|
let service: ObatService;
|
||||||
|
let prisma: ReturnType<typeof createPrismaMock>;
|
||||||
|
let logService: ReturnType<typeof createLogServiceMock>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
prisma = createPrismaMock();
|
||||||
|
logService = createLogServiceMock();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [ObatService],
|
providers: [
|
||||||
|
ObatService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
{ provide: LogService, useValue: logService },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<ObatService>(ObatService);
|
service = module.get<ObatService>(ObatService);
|
||||||
|
|
@ -15,4 +63,185 @@ describe('ObatService', () => {
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
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,
|
...logResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countObat() {
|
||||||
|
return this.prisma.pemberian_obat.count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@ import { LogModule } from '../log/log.module';
|
||||||
imports: [PrismaModule, LogModule],
|
imports: [PrismaModule, LogModule],
|
||||||
controllers: [RekamMedisController],
|
controllers: [RekamMedisController],
|
||||||
providers: [RekammedisService],
|
providers: [RekammedisService],
|
||||||
|
exports: [RekammedisService],
|
||||||
})
|
})
|
||||||
export class RekamMedisModule {}
|
export class RekamMedisModule {}
|
||||||
|
|
|
||||||
|
|
@ -393,4 +393,8 @@ export class RekammedisService {
|
||||||
log: createdLog,
|
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 {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Header,
|
|
||||||
HttpCode,
|
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TindakanDokterService } from './tindakandokter.service';
|
import { TindakanDokterService } from './tindakandokter.service';
|
||||||
import { AuthGuard } from '../auth/guard/auth.guard';
|
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')
|
@Controller('/tindakan')
|
||||||
export class TindakanDokterController {
|
export class TindakanDokterController {
|
||||||
|
|
@ -39,4 +44,39 @@ export class TindakanDokterController {
|
||||||
orderBy: orderBy ? { [orderBy]: order || 'asc' } : undefined,
|
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 { TindakanDokterController } from './tindakandokter.controller';
|
||||||
import { TindakanDokterService } from './tindakandokter.service';
|
import { TindakanDokterService } from './tindakandokter.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { LogModule } from '../log/log.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, LogModule],
|
||||||
controllers: [TindakanDokterController],
|
controllers: [TindakanDokterController],
|
||||||
providers: [TindakanDokterService],
|
providers: [TindakanDokterService],
|
||||||
|
exports: [TindakanDokterService],
|
||||||
})
|
})
|
||||||
export class TindakanDokterModule {}
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { Prisma } from '@dist/generated/prisma';
|
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()
|
@Injectable()
|
||||||
export class TindakanDokterService {
|
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: {
|
async getAllTindakanDokter(params: {
|
||||||
skip?: number;
|
skip?: number;
|
||||||
|
|
@ -17,16 +50,7 @@ export class TindakanDokterService {
|
||||||
orderBy?: Prisma.pemberian_tindakanOrderByWithRelationInput;
|
orderBy?: Prisma.pemberian_tindakanOrderByWithRelationInput;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { skip, page, tindakan, orderBy, order, id_visit } = params;
|
||||||
skip,
|
|
||||||
page,
|
|
||||||
tindakan,
|
|
||||||
orderBy,
|
|
||||||
order,
|
|
||||||
id_visit,
|
|
||||||
kelompok_tindakan,
|
|
||||||
kategori_tindakan,
|
|
||||||
} = params;
|
|
||||||
const take = params.take ? parseInt(params.take.toString()) : 10;
|
const take = params.take ? parseInt(params.take.toString()) : 10;
|
||||||
const kelompok_tindakanArray = params.kelompok_tindakan
|
const kelompok_tindakanArray = params.kelompok_tindakan
|
||||||
? params.kelompok_tindakan.split(',')
|
? params.kelompok_tindakan.split(',')
|
||||||
|
|
@ -81,4 +105,189 @@ export class TindakanDokterService {
|
||||||
totalCount: count,
|
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 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) => {
|
const openDeleteDialog = (item: T) => {
|
||||||
pendingDeleteItem.value = item;
|
pendingDeleteItem.value = item;
|
||||||
deleteDialogRef.value?.show();
|
deleteDialogRef.value?.show();
|
||||||
|
|
@ -109,7 +127,7 @@ const handleDeleteCancel = () => {
|
||||||
hasStatusColumn() ? 'text-xs' : '',
|
hasStatusColumn() ? 'text-xs' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ item[column.key] }}
|
{{ formatCellValue(item, column.key) }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="!hasStatusColumn()">
|
<td v-if="!hasStatusColumn()">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,42 @@ const navigateTo = (routeName: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = (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") {
|
if (route.name === "rekam-medis-details" && routeName === "rekam-medis") {
|
||||||
return true;
|
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") {
|
if (route.name === "pemberian-obat-details" && routeName === "obat") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (route.name === "pemberian-obat-edit" && routeName === "obat") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return route.name === routeName;
|
return route.name === routeName;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ interface BlockchainLog {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { RekamMedis, Users, TindakanDokter, BlockchainLog, Obat };
|
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 CreateRekamMedisView from "../views/dashboard/CreateRekamMedisView.vue";
|
||||||
import RekamMedisEditView from "../views/dashboard/RekamMedisEditView.vue";
|
import RekamMedisEditView from "../views/dashboard/RekamMedisEditView.vue";
|
||||||
import CreateObatView from "../views/dashboard/CreateObatView.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 = [
|
const routes = [
|
||||||
{
|
{
|
||||||
|
|
@ -81,6 +84,24 @@ const routes = [
|
||||||
component: TindakanView,
|
component: TindakanView,
|
||||||
meta: { requiresAuth: true },
|
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",
|
path: "/users",
|
||||||
name: "users",
|
name: "users",
|
||||||
|
|
@ -88,7 +109,7 @@ const routes = [
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/:catchAll(.*)*", // This regex matches any path
|
path: "/:catchAll(.*)*",
|
||||||
name: "NotFound",
|
name: "NotFound",
|
||||||
component: NotFoundView,
|
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">
|
<script setup lang="ts">
|
||||||
import Sidebar from "../../components/dashboard/Sidebar.vue";
|
import Sidebar from "../../components/dashboard/Sidebar.vue";
|
||||||
import Footer from "../../components/dashboard/Footer.vue";
|
import Footer from "../../components/dashboard/Footer.vue";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-light w-full text-dark">
|
<div class="bg-light w-full text-dark">
|
||||||
<div class="flex h-full p-2">
|
<div class="flex h-full p-2">
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
<PageHeader title="Dashboard" subtitle="Detail Dashboard" />
|
||||||
<div>
|
<div>
|
||||||
<div>Pasien</div>
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
<div>Jumlah Kunjungan</div>
|
<div
|
||||||
<div>Jumlah Pengguna</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>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</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>
|
<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>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -164,11 +164,11 @@ const handlePageSizeChange = (newSize: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDetails = (item: TindakanDokter) => {
|
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) => {
|
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) => {
|
const handleDelete = async (item: TindakanDokter) => {
|
||||||
|
|
@ -307,20 +307,30 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-xl shadow-md">
|
<div class="bg-white rounded-xl shadow-md">
|
||||||
<div class="flex items-center px-4 py-4 justify-between gap-4">
|
<div class="flex">
|
||||||
<SortDropdown
|
<div class="flex items-center pl-4 pb-4 flex-1">
|
||||||
v-model="sortBy"
|
<RouterLink
|
||||||
:options="SORT_OPTIONS.TINDAKAN"
|
to="/pemberian-tindakan/add"
|
||||||
label="Urut berdasarkan:"
|
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
|
||||||
@change="handleSortChange"
|
>
|
||||||
/>
|
Tambah Pemberian Tindakan
|
||||||
|
</RouterLink>
|
||||||
<SearchInput
|
</div>
|
||||||
v-model="searchIdVisit"
|
<div
|
||||||
id="id_visit"
|
class="flex flex-col items-end flex-1 px-4 pt-4 pb-2 justify-between gap-4"
|
||||||
placeholder="Cari berdasarkan ID Visit"
|
>
|
||||||
@search="handleSearch"
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Data Table -->
|
<!-- Data Table -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user