Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d0541689 |
@ -12,6 +12,8 @@ const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const triageRoutes = require('./routes/triage');
|
||||
const bankidRoutes = require('./routes/bankid');
|
||||
const fileRoutes = require('./routes/file');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const sqlRoutes = require('./routes/sql');
|
||||
@ -102,6 +104,8 @@ require('./auth/auth');
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/bankid', bankidRoutes);
|
||||
app.use('/api/triage', triageRoutes);
|
||||
app.use('/api/file', fileRoutes);
|
||||
app.use('/api/pexels', pexelsRoutes);
|
||||
app.enable('trust proxy');
|
||||
|
||||
87
backend/src/routes/bankid.js
Normal file
87
backend/src/routes/bankid.js
Normal file
@ -0,0 +1,87 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const helpers = require('../helpers');
|
||||
const UsersDBApi = require('../db/api/users');
|
||||
const PatientsDBApi = require('../db/api/patients');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
router.post('/initiate', wrapAsync(async (req, res) => {
|
||||
const { personalNumber } = req.body;
|
||||
if (!personalNumber) {
|
||||
return res.status(400).send({ message: 'Personal number is required' });
|
||||
}
|
||||
const orderRef = Math.random().toString(36).substring(7);
|
||||
sessions.set(orderRef, { personalNumber, status: 'pending', createdAt: Date.now() });
|
||||
res.status(200).send({ orderRef, message: 'Auth initiated. Please sign in your BankID app.' });
|
||||
}));
|
||||
|
||||
router.post('/status', wrapAsync(async (req, res) => {
|
||||
const { orderRef } = req.body;
|
||||
const session = sessions.get(orderRef);
|
||||
if (!session) {
|
||||
return res.status(404).send({ message: 'Session not found' });
|
||||
}
|
||||
|
||||
// Simulate progress
|
||||
const elapsed = Date.now() - session.createdAt;
|
||||
if (elapsed > 1000) { // Reduced from 3000 to 1000 for faster feel
|
||||
session.status = 'complete';
|
||||
}
|
||||
|
||||
res.status(200).send({ status: session.status });
|
||||
}));
|
||||
|
||||
router.post('/verify', wrapAsync(async (req, res) => {
|
||||
const { orderRef } = req.body;
|
||||
const session = sessions.get(orderRef);
|
||||
if (!session || session.status !== 'complete') {
|
||||
return res.status(400).send({ message: 'Auth not complete' });
|
||||
}
|
||||
|
||||
const { personalNumber } = session;
|
||||
|
||||
// 1. Try to find patient by personal_number
|
||||
const patient = await PatientsDBApi.findBy({ personal_number: personalNumber });
|
||||
let user;
|
||||
|
||||
if (patient && patient.user) {
|
||||
user = await UsersDBApi.findBy({ id: patient.user.id });
|
||||
} else {
|
||||
// 2. Try to find user by mock email as fallback
|
||||
user = await UsersDBApi.findBy({ email: `${personalNumber}@bankid.sim` });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// Create mock user if not found
|
||||
user = await UsersDBApi.createFromAuth({
|
||||
firstName: 'Patient',
|
||||
lastName: personalNumber,
|
||||
email: `${personalNumber}@bankid.sim`,
|
||||
password: 'mock_password',
|
||||
phoneNumber: personalNumber,
|
||||
});
|
||||
|
||||
// Also create a patient record if it didn't exist
|
||||
if (!patient) {
|
||||
await PatientsDBApi.create({
|
||||
display_name: `Patient ${personalNumber}`,
|
||||
personal_number: personalNumber,
|
||||
user: user.id
|
||||
}, { currentUser: user });
|
||||
}
|
||||
}
|
||||
|
||||
const token = helpers.jwtSign({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email
|
||||
}
|
||||
});
|
||||
|
||||
sessions.delete(orderRef);
|
||||
res.status(200).send({ token, user });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
17
backend/src/routes/triage.js
Normal file
17
backend/src/routes/triage.js
Normal file
@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const TriageService = require('../services/triage');
|
||||
const { wrapAsync } = require('../helpers');
|
||||
const passport = require('passport');
|
||||
|
||||
router.post('/process', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
|
||||
const { symptomText, fullName, phoneNumber } = req.body;
|
||||
if (!symptomText) {
|
||||
return res.status(400).send({ message: 'Symptom text is required' });
|
||||
}
|
||||
|
||||
const result = await TriageService.performTriage(symptomText, req.currentUser, { fullName, phoneNumber });
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const VisitsService = require('../services/visits');
|
||||
@ -19,6 +18,10 @@ const {
|
||||
|
||||
router.use(checkCrudPermissions('visits'));
|
||||
|
||||
router.post('/:id/call', wrapAsync(async (req, res) => {
|
||||
const result = await VisitsService.callPatient(req.params.id, req.currentUser);
|
||||
res.status(200).send(result);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@ -445,4 +448,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
181
backend/src/services/triage.js
Normal file
181
backend/src/services/triage.js
Normal file
@ -0,0 +1,181 @@
|
||||
const db = require('../db/models');
|
||||
const { LocalAIApi } = require('../ai/LocalAIApi');
|
||||
|
||||
class TriageService {
|
||||
static async performTriage(symptomText, currentUser, data = {}) {
|
||||
const { fullName, phoneNumber, extraData = {} } = data;
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
// 1. Update User info if provided
|
||||
if (fullName || phoneNumber) {
|
||||
const names = (fullName || "").split(" ");
|
||||
const firstName = names[0] || currentUser.firstName;
|
||||
const lastName = names.slice(1).join(" ") || currentUser.lastName;
|
||||
|
||||
await db.users.update({
|
||||
firstName,
|
||||
lastName,
|
||||
phoneNumber: phoneNumber || currentUser.phoneNumber
|
||||
}, {
|
||||
where: { id: currentUser.id },
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Get or create patient record for user
|
||||
let patient = await db.patients.findOne({ where: { userId: currentUser.id }, transaction });
|
||||
|
||||
const patientData = {
|
||||
display_name: fullName || (currentUser.firstName + ' ' + currentUser.lastName),
|
||||
userId: currentUser.id
|
||||
};
|
||||
|
||||
if (!patient) {
|
||||
patientData.personal_number = currentUser.email.split('@')[0];
|
||||
patient = await db.patients.create(patientData, { transaction });
|
||||
} else {
|
||||
await patient.update({ display_name: patientData.display_name }, { transaction });
|
||||
}
|
||||
|
||||
// 3. AI Triage with detailed info
|
||||
const fullContext = {
|
||||
symptoms: symptomText,
|
||||
...extraData
|
||||
};
|
||||
|
||||
let triageResult = await this.callAI(fullContext);
|
||||
let aiFallback = false;
|
||||
|
||||
if (!triageResult) {
|
||||
triageResult = this.ruleBasedFallback(fullContext);
|
||||
aiFallback = true;
|
||||
}
|
||||
|
||||
const levelStr = triageResult.level.toString();
|
||||
|
||||
// 4. Create Visit
|
||||
const visit = await db.visits.create({
|
||||
patientId: patient.id,
|
||||
symptom_text: symptomText,
|
||||
triage_level: levelStr,
|
||||
priority_score: triageResult.score,
|
||||
status: 'waiting',
|
||||
ai_fallback: aiFallback,
|
||||
created_ts: new Date()
|
||||
}, { transaction });
|
||||
|
||||
// 5. Create AI Analysis record
|
||||
await db.ai_analyses.create({
|
||||
visitId: visit.id,
|
||||
raw_output: JSON.stringify(triageResult),
|
||||
analysis_type: 'triage',
|
||||
result_level: levelStr
|
||||
}, { transaction });
|
||||
|
||||
// 6. Calculate dynamic wait time
|
||||
const waitTime = await this.calculateWaitTime(triageResult.level, transaction);
|
||||
|
||||
// 7. Add to Queue
|
||||
await db.queue.create({
|
||||
visitId: visit.id,
|
||||
triage_level: levelStr,
|
||||
waiting_since: new Date(),
|
||||
label: `Patient ${patient.display_name}`,
|
||||
estimated_wait_minutes: waitTime
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
return { visit, triageResult };
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async callAI(context) {
|
||||
try {
|
||||
const prompt = `
|
||||
You are a highly experienced medical triage specialist in a Swedish Emergency Room.
|
||||
Analyze the following patient data to determine the triage level (1-5).
|
||||
|
||||
LEVEL DEFINITIONS:
|
||||
1: Red - Life-threatening. Immediate intervention required.
|
||||
2: Orange - Very urgent. High risk of deterioration.
|
||||
3: Yellow - Urgent. Stable but needs investigation.
|
||||
4: Green - Standard. Non-urgent, but requires care.
|
||||
5: Blue - Non-urgent. Could be handled by primary care.
|
||||
|
||||
PATIENT DATA:
|
||||
- Main Symptoms: ${context.symptoms}
|
||||
- Pain Level (0-10): ${context.painLevel}
|
||||
- Onset: ${context.onset}
|
||||
- Selected Categories: ${context.selectedSymptoms?.join(', ')}
|
||||
- Medical History: ${context.medicalHistory}
|
||||
- Medications: ${context.medications}
|
||||
- Allergies: ${context.allergies}
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{
|
||||
"level": number (1-5),
|
||||
"reasoning": "Short Swedish explanation (max 30 words)",
|
||||
"score": number (0-100, where 100 is most urgent),
|
||||
"advice": "Short Swedish advice for the patient while waiting"
|
||||
}
|
||||
`;
|
||||
|
||||
const resp = await LocalAIApi.createResponse({
|
||||
input: [
|
||||
{ role: "system", content: "You are a professional medical triage AI." },
|
||||
{ role: "user", content: prompt }
|
||||
]
|
||||
}, { poll_interval: 2, poll_timeout: 45 });
|
||||
|
||||
if (resp.success) {
|
||||
return LocalAIApi.decodeJsonFromResponse(resp);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("AI Triage failed:", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ruleBasedFallback(context) {
|
||||
const text = (context.symptoms || "").toLowerCase();
|
||||
const pain = context.painLevel || 0;
|
||||
|
||||
// Critical signs
|
||||
if (text.includes('medvetslös') || text.includes('andas inte') || text.includes('stopp')) {
|
||||
return { level: 1, reasoning: "Livshotande tillstånd identifierat (Fallback)", score: 100 };
|
||||
}
|
||||
|
||||
if (text.includes('bröst') || text.includes('andas') || text.includes('hjärta') || pain >= 9) {
|
||||
return { level: 2, reasoning: "Potentiellt allvarligt (Fallback)", score: 85 };
|
||||
}
|
||||
|
||||
if (text.includes('blöd') || text.includes('fraktur') || pain >= 7) {
|
||||
return { level: 3, reasoning: "Urgent behov av vård (Fallback)", score: 65 };
|
||||
}
|
||||
|
||||
if (pain >= 4 || text.includes('feber')) {
|
||||
return { level: 4, reasoning: "Stabil men behöver bedömning (Fallback)", score: 45 };
|
||||
}
|
||||
|
||||
return { level: 5, reasoning: "Mindre brådskande (Fallback)", score: 25 };
|
||||
}
|
||||
|
||||
static async calculateWaitTime(level, transaction) {
|
||||
const base = { 1: 0, 2: 20, 3: 90, 4: 180, 5: 300 };
|
||||
|
||||
const count = await db.queue.count({
|
||||
where: {
|
||||
triage_level: { [db.Sequelize.Op.lte]: level.toString() },
|
||||
called: false
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
return (base[level] || 120) + (count * 15);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TriageService;
|
||||
@ -7,10 +7,6 @@ const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = class VisitsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -30,6 +26,37 @@ module.exports = class VisitsService {
|
||||
}
|
||||
};
|
||||
|
||||
static async callPatient(id, currentUser) {
|
||||
console.log('Calling patient with visit ID:', id);
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!id || id === 'undefined') {
|
||||
throw new ValidationError('visitNotFound');
|
||||
}
|
||||
|
||||
const visit = await db.visits.findByPk(id, { transaction });
|
||||
if (!visit) {
|
||||
console.error('Visit not found for ID:', id);
|
||||
throw new ValidationError('visitNotFound');
|
||||
}
|
||||
|
||||
// Update visit status
|
||||
await visit.update({ status: 'in_consultation' }, { transaction });
|
||||
|
||||
// Update queue entry
|
||||
await db.queue.update(
|
||||
{ called: true },
|
||||
{ where: { visitId: id }, transaction }
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return visit;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
@ -131,8 +158,4 @@ module.exports = class VisitsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
@ -7,7 +7,12 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/staff/queue',
|
||||
label: 'Patientkö (Personal)',
|
||||
icon: icon.mdiAccountMultiple,
|
||||
permissions: 'READ_QUEUE'
|
||||
},
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
@ -128,4 +133,4 @@ const menuAside: MenuAsideItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
export default menuAside;
|
||||
212
frontend/src/pages/bankid-login.tsx
Normal file
212
frontend/src/pages/bankid-login.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import axios from 'axios';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import FormField from '../components/FormField';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { mdiFingerprint, mdiShieldCheck, mdiAlertCircle, mdiClockFast } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
|
||||
export default function BankIDLogin() {
|
||||
const [personalNumber, setPersonalNumber] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState('idle'); // idle, initiated, completed, error, timeout
|
||||
const [orderRef, setOrderRef] = useState(null);
|
||||
const [securityCode, setSecurityCode] = useState('');
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
const router = useRouter();
|
||||
|
||||
const handleInitiate = async () => {
|
||||
// Basic validation for Swedish personal number
|
||||
const pn = personalNumber.replace(/[^0-9]/g, '');
|
||||
if (pn.length !== 10 && pn.length !== 12) {
|
||||
alert('Vänligen ange ett giltigt personnummer (10 eller 12 siffror)');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/bankid/initiate', { personalNumber });
|
||||
setOrderRef(response.data.orderRef);
|
||||
setSecurityCode(Math.floor(1000 + Math.random() * 9000).toString());
|
||||
setStatus('initiated');
|
||||
setCountdown(60);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setStatus('idle');
|
||||
setOrderRef(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let interval;
|
||||
if (status === 'initiated' && orderRef) {
|
||||
interval = setInterval(async () => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
setStatus('timeout');
|
||||
clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post('/bankid/status', { orderRef });
|
||||
if (response.data.status === 'complete') {
|
||||
setStatus('completed');
|
||||
clearInterval(interval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('error');
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [status, orderRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'completed' && orderRef) {
|
||||
const verify = async () => {
|
||||
try {
|
||||
const response = await axios.post('/bankid/verify', { orderRef });
|
||||
const { token, user } = response.data;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
router.push('/patient/new-visit');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
verify();
|
||||
}
|
||||
}, [status, orderRef, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Logga in - Akuten Online')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='white'>
|
||||
<div className="flex flex-col items-center justify-center w-full max-w-md mx-auto p-4">
|
||||
<div className="mb-10 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-[#004B87] rounded-3xl shadow-xl mb-6 transform hover:scale-105 transition-transform">
|
||||
<BaseIcon path={mdiFingerprint} size={40} color="white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-[#004B87] mb-2">Akuten Online</h1>
|
||||
<p className="text-gray-500 font-medium italic">Trygg och säker identifiering</p>
|
||||
</div>
|
||||
|
||||
<CardBox className="w-full shadow-2xl rounded-3xl border-0 overflow-hidden">
|
||||
{status === 'idle' && (
|
||||
<div className="space-y-6 p-2">
|
||||
<div className="bg-blue-50 p-4 rounded-2xl flex items-start gap-3">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} className="text-[#004B87] mt-1" />
|
||||
<p className="text-sm text-[#004B87] leading-relaxed">
|
||||
Identifiera dig med BankID för att hämta din journal och påbörja din triagering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField label="Ditt Personnummer" help="Format: ÅÅÅÅMMDD-XXXX">
|
||||
<input
|
||||
className="w-full px-5 py-4 text-xl tracking-widest border-2 border-gray-100 rounded-2xl focus:ring-4 focus:ring-blue-100 focus:border-[#004B87] outline-none transition-all font-mono"
|
||||
value={personalNumber}
|
||||
onChange={(e) => setPersonalNumber(e.target.value)}
|
||||
placeholder="19900101-1234"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseButton
|
||||
label="Identifiera mig"
|
||||
color="info"
|
||||
className="w-full py-4 text-lg font-bold rounded-2xl shadow-lg bg-[#004B87] hover:bg-[#003a6a]"
|
||||
onClick={handleInitiate}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'initiated' || status === 'completed') && (
|
||||
<div className="flex flex-col items-center py-10 space-y-8">
|
||||
<div className="relative">
|
||||
<LoadingSpinner className="w-24 h-24 text-[#004B87]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center font-bold text-[#004B87]">
|
||||
{countdown}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<h2 className="text-2xl font-bold text-gray-800">Öppna BankID-appen</h2>
|
||||
<p className="text-gray-500 max-w-[200px] mx-auto leading-relaxed">
|
||||
Kontrollera att säkerhetskoden i appen stämmer överens:
|
||||
</p>
|
||||
<div className="inline-block px-6 py-2 bg-gray-100 rounded-xl text-3xl font-black tracking-[0.5em] text-[#004B87]">
|
||||
{securityCode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
label="Avbryt inloggning"
|
||||
color="light"
|
||||
onClick={handleCancel}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'error' || status === 'timeout') && (
|
||||
<div className="flex flex-col items-center py-10 space-y-6 text-center">
|
||||
<div className="w-20 h-20 bg-red-50 rounded-full flex items-center justify-center text-red-500 mb-2">
|
||||
<BaseIcon path={mdiAlertCircle} size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
{status === 'timeout' ? 'Tiden har gått ut' : 'Något gick fel'}
|
||||
</h2>
|
||||
<p className="text-gray-500 px-4">
|
||||
Inloggningen kunde inte slutföras. Vänligen kontrollera din anslutning och försök igen.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
label="Försök igen"
|
||||
color="info"
|
||||
className="w-full rounded-2xl py-4"
|
||||
onClick={() => setStatus('idle')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<div className="mt-12 flex items-center gap-2 text-gray-400">
|
||||
<BaseIcon path={mdiClockFast} size={16} />
|
||||
<p className="text-xs uppercase tracking-widest font-bold">
|
||||
Snabb & Säker Digital Triage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BankIDLogin.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -1,166 +1,177 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
import { mdiHospitalMarker, mdiAccountCheck, mdiClockOutline } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'App Draft'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
export default function LandingPage() {
|
||||
const primaryColor = '#004B87'; // Swedish Health Blue
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="min-h-screen bg-white font-sans text-gray-900">
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Akuten Online - Digital Akutmottagning')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your App Draft app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
{/* Navigation */}
|
||||
<nav className="flex items-center justify-between px-6 py-4 border-b border-gray-100 bg-white sticky top-0 z-50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-[#E30613] rounded flex items-center justify-center">
|
||||
<span className="text-white font-bold">+</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#004B87]">Akuten Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden md:flex space-x-8 text-sm font-medium text-gray-600">
|
||||
<Link href="#om" className="hover:text-blue-700 transition">Om tjänsten</Link>
|
||||
<Link href="#hur" className="hover:text-blue-700 transition">Hur det fungerar</Link>
|
||||
<Link href="/login" className="hover:text-blue-700 transition">Personalinloggning</Link>
|
||||
</div>
|
||||
<BaseButton href="/bankid-login" label="Sök vård nu" color="info" />
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<header className="px-6 py-16 md:py-24 bg-gradient-to-b from-blue-50 to-white overflow-hidden relative">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center">
|
||||
<div className="md:w-1/2 space-y-6 z-10">
|
||||
<div className="inline-block px-4 py-1.5 bg-blue-100 text-[#004B87] rounded-full text-xs font-bold tracking-wide uppercase">
|
||||
Digital triagering dygnet runt
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-extrabold leading-tight text-slate-900">
|
||||
Sök vård digitalt <br />
|
||||
<span className="text-[#004B87]">minska väntetiden.</span>
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-lg leading-relaxed">
|
||||
Vår AI-baserade plattform hjälper dig att snabbt få rätt vård. Genom att triagera dig hemma minskar vi trycket på akuten och ger dig en estimerad köplats direkt.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4 pt-4">
|
||||
<BaseButton
|
||||
href="/bankid-login"
|
||||
label="Logga in med BankID"
|
||||
color="info"
|
||||
className="px-8 py-3 text-lg font-bold rounded-xl shadow-lg shadow-blue-200"
|
||||
/>
|
||||
<BaseButton
|
||||
href="#hur"
|
||||
label="Läs mer"
|
||||
color="white"
|
||||
className="px-8 py-3 text-lg font-bold rounded-xl border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 pt-4">
|
||||
<div className="flex -space-x-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="w-8 h-8 rounded-full border-2 border-white bg-gray-200 overflow-hidden">
|
||||
<img src={`https://i.pravatar.cc/100?u=${i}`} alt="user" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p>Över 10,000 patienter hjälpta denna månad</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:w-1/2 mt-12 md:mt-0 relative flex justify-center">
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute -top-10 -right-10 w-64 h-64 bg-blue-400 opacity-10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-10 -left-10 w-64 h-64 bg-red-400 opacity-5 rounded-full blur-3xl"></div>
|
||||
|
||||
<div className="bg-white p-6 rounded-3xl shadow-2xl border border-gray-100 relative z-10 w-full max-w-sm transform rotate-3 hover:rotate-0 transition duration-500">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-green-100 text-green-600 rounded-full flex items-center justify-center font-bold">✓</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Status</p>
|
||||
<p className="font-bold text-sm">Patient Triage</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 text-[#004B87] px-2 py-1 rounded text-[10px] font-bold">LIVE</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 w-3/4"></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Estimerad väntetid</span>
|
||||
<span className="font-bold">12 min</span>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded-2xl border border-dashed border-gray-200">
|
||||
<p className="text-xs text-gray-500 mb-1 italic">"Symtom: Tryck över bröstet..."</p>
|
||||
<p className="text-xs font-bold text-red-600">Prioritet: Akut (Nivå 2)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Features */}
|
||||
<section id="hur" className="py-24 px-6 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16 space-y-4">
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-slate-900">Varför använda Akuten Online?</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">Vårt system är utvecklat för att optimera patientflödet och säkerställa att de mest akuta fallen får hjälp först.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{[
|
||||
{
|
||||
title: 'AI-prioritering',
|
||||
desc: 'Vår smarta algoritm analyserar dina symtom och riskfaktorer för att ge dig rätt prioritet direkt.',
|
||||
icon: mdiAccountCheck,
|
||||
color: 'bg-blue-100 text-[#004B87]'
|
||||
},
|
||||
{
|
||||
title: 'Realtidskö',
|
||||
desc: 'Se din exakta plats i kön och få en estimerad väntetid baserat på personalens belastning.',
|
||||
icon: mdiClockOutline,
|
||||
color: 'bg-green-100 text-green-600'
|
||||
},
|
||||
{
|
||||
title: 'Minskad smittrisk',
|
||||
desc: 'Vänta i hemmiljö istället för i ett fullsatt väntrum och bli kallad precis när det är din tur.',
|
||||
icon: mdiHospitalMarker,
|
||||
color: 'bg-red-100 text-red-600'
|
||||
}
|
||||
].map((feature, i) => (
|
||||
<div key={i} className="space-y-4 group p-6 rounded-2xl hover:bg-gray-50 transition duration-300">
|
||||
<div className={`w-14 h-14 ${feature.color} rounded-2xl flex items-center justify-center transition group-hover:scale-110`}>
|
||||
<BaseIcon path={feature.icon} size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-slate-900 text-white py-12 px-6">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex items-center space-x-2 mb-6 md:mb-0">
|
||||
<div className="w-8 h-8 bg-[#E30613] rounded flex items-center justify-center">
|
||||
<span className="text-white font-bold">+</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">Akuten Online</span>
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">
|
||||
© 2026 Akuten Online (Flatlogic Demo). Simulation för utbildningsbruk.
|
||||
</div>
|
||||
<div className="flex space-x-6 mt-6 md:mt-0 text-slate-400 text-sm">
|
||||
<Link href="/privacy-policy" className="hover:text-white">Integritetspolicy</Link>
|
||||
<Link href="/terms" className="hover:text-white">Villkor</Link>
|
||||
<Link href="/login" className="hover:text-white">Logga in (Personal)</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
};
|
||||
278
frontend/src/pages/patient/new-visit.tsx
Normal file
278
frontend/src/pages/patient/new-visit.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import axios from 'axios';
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../../config';
|
||||
import { mdiMedicalBag, mdiArrowRight, mdiAccount, mdiPhone, mdiHeartPulse, mdiHistory, mdiCheck, mdiChevronLeft, mdiAlertCircle } from '@mdi/js';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import FormField from '../../components/FormField';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
|
||||
const NewVisitPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Step 1: Identity
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
|
||||
// Step 2: Symptoms
|
||||
const [symptomText, setSymptomText] = useState('');
|
||||
const [painLevel, setPainLevel] = useState(0);
|
||||
|
||||
// Step 3: Details
|
||||
const [onset, setOnset] = useState(''); // When did it start?
|
||||
const [selectedSymptoms, setSelectedSymptoms] = useState<string[]>([]);
|
||||
|
||||
// Step 4: History
|
||||
const [medicalHistory, setMedicalHistory] = useState('');
|
||||
const [medications, setMedications] = useState('');
|
||||
const [allergies, setAllergies] = useState('');
|
||||
|
||||
const commonSymptoms = [
|
||||
'Feber', 'Hosta', 'Andnöd', 'Bröstsmärta', 'Huvudvärk', 'Illamående', 'Buksmärta', 'Yrsel'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
setFullName(`${currentUser.firstName || ''} ${currentUser.lastName || ''}`.trim());
|
||||
setPhoneNumber(currentUser.phoneNumber || '');
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
const toggleSymptom = (s: string) => {
|
||||
setSelectedSymptoms(prev =>
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/triage/process', {
|
||||
symptomText,
|
||||
fullName,
|
||||
phoneNumber,
|
||||
extraData: {
|
||||
painLevel,
|
||||
onset,
|
||||
selectedSymptoms,
|
||||
medicalHistory,
|
||||
medications,
|
||||
allergies
|
||||
}
|
||||
});
|
||||
const { visit } = response.data;
|
||||
router.push(`/patient/waiting-room?id=${visit.id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Ett fel uppstod vid bearbetning. Försök igen.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => setStep(s => s + 1);
|
||||
const prevStep = () => setStep(s => s - 1);
|
||||
|
||||
const renderProgress = () => (
|
||||
<div className="flex items-center justify-between mb-8 max-w-md mx-auto">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center flex-1 last:flex-none">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold transition-colors ${step >= i ? 'bg-[#004B87] text-white' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{step > i ? <BaseIcon path={mdiCheck} size={20} /> : i}
|
||||
</div>
|
||||
{i < 4 && <div className={`h-1 flex-grow mx-2 rounded ${step > i ? 'bg-[#004B87]' : 'bg-gray-200'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Nytt besök')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiMedicalBag} title="Bedömning av hälsotillstånd" main>
|
||||
<div className="text-sm font-medium text-gray-500">Steg {step} av 4</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{renderProgress()}
|
||||
|
||||
<CardBox className="shadow-xl rounded-3xl border-0">
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-black text-[#004B87]">Dina Kontaktuppgifter</h2>
|
||||
<p className="text-gray-500 leading-relaxed">
|
||||
Säkerställ att vi kan nå dig vid behov. Uppgifterna hämtas från din profil men kan ändras här.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField label="Namn" icon={mdiAccount}>
|
||||
<input
|
||||
className="w-full px-4 py-3 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Telefon" icon={mdiPhone}>
|
||||
<input
|
||||
className="w-full px-4 py-3 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4">
|
||||
<BaseButton label="Gå vidare" color="info" onClick={nextStep} disabled={!fullName || !phoneNumber} className="px-10 py-4 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-black text-[#004B87]">Beskriv dina symtom</h2>
|
||||
<p className="text-gray-500">Förklara kortfattat vad som har hänt och hur du känner dig.</p>
|
||||
</div>
|
||||
<FormField label="Beskrivning" help="Ex: 'Plötslig bröstsmärta som strålar ut i vänster arm.'">
|
||||
<textarea
|
||||
className="w-full px-5 py-4 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none h-40"
|
||||
value={symptomText}
|
||||
onChange={(e) => setSymptomText(e.target.value)}
|
||||
placeholder="Skriv här..."
|
||||
/>
|
||||
</FormField>
|
||||
<div className="space-y-4">
|
||||
<label className="block font-bold text-gray-700">Smärtnivå (0-10)</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range" min="0" max="10"
|
||||
className="flex-grow h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#004B87]"
|
||||
value={painLevel}
|
||||
onChange={(e) => setPainLevel(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className={`w-12 h-12 flex items-center justify-center rounded-xl font-bold text-xl ${painLevel > 7 ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-[#004B87]'}`}>
|
||||
{painLevel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between pt-4">
|
||||
<BaseButton label="Bakåt" color="light" onClick={prevStep} icon={mdiChevronLeft} />
|
||||
<BaseButton label="Gå vidare" color="info" onClick={nextStep} disabled={!symptomText} className="px-10 py-4 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-black text-[#004B87]">Detaljer & Varaktighet</h2>
|
||||
<p className="text-gray-500">Välj de symtom som stämmer in på dig.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{commonSymptoms.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => toggleSymptom(s)}
|
||||
className={`p-3 rounded-xl border-2 transition-all text-sm font-semibold ${selectedSymptoms.includes(s) ? 'border-[#004B87] bg-blue-50 text-[#004B87]' : 'border-gray-50 text-gray-500 hover:border-gray-200'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FormField label="När började besvären?">
|
||||
<input
|
||||
className="w-full px-4 py-3 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none"
|
||||
value={onset}
|
||||
onChange={(e) => setOnset(e.target.value)}
|
||||
placeholder="Ex: 'För ca 2 timmar sedan'"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-between pt-4">
|
||||
<BaseButton label="Bakåt" color="light" onClick={prevStep} icon={mdiChevronLeft} />
|
||||
<BaseButton label="Gå vidare" color="info" onClick={nextStep} className="px-10 py-4 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-black text-[#004B87]">Medicinsk Bakgrund</h2>
|
||||
<p className="text-gray-500 text-sm">Denna information hjälper oss att göra en säkrare bedömning.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField label="Sjukdomar / Tidigare besvär" icon={mdiHistory}>
|
||||
<input
|
||||
className="w-full px-4 py-3 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none text-sm"
|
||||
value={medicalHistory}
|
||||
onChange={(e) => setMedicalHistory(e.target.value)}
|
||||
placeholder="Ex: Astma, Högt blodtryck..."
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Nuvarande medicinering" icon={mdiHeartPulse}>
|
||||
<input
|
||||
className="w-full px-4 py-3 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none text-sm"
|
||||
value={medications}
|
||||
onChange={(e) => setMedications(e.target.value)}
|
||||
placeholder="Ex: Waran, Alvedon..."
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Allergier">
|
||||
<input
|
||||
className="w-full px-4 py-3 border-2 border-gray-50 rounded-2xl focus:ring-2 focus:ring-blue-100 focus:border-[#004B87] outline-none text-sm"
|
||||
value={allergies}
|
||||
onChange={(e) => setAllergies(e.target.value)}
|
||||
placeholder="Ex: Penicillin, Pollen..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 p-5 rounded-2xl border-2 border-red-100 mt-6">
|
||||
<p className="text-red-700 text-sm font-bold flex items-center gap-2">
|
||||
<BaseIcon path={mdiAlertCircle} size={20} />
|
||||
VIKTIGT
|
||||
</p>
|
||||
<p className="text-red-600 text-xs mt-1 leading-relaxed">
|
||||
Genom att klicka på "Skicka in" godkänner du att din information analyseras av systemet för att kunna prioritera ditt besök. Vid livsfara, ring 112.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<BaseButton label="Bakåt" color="light" onClick={prevStep} icon={mdiChevronLeft} />
|
||||
<BaseButton
|
||||
label={loading ? 'Bearbetar...' : 'Skicka in'}
|
||||
color="info"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-12 py-4 rounded-2xl shadow-xl bg-[#004B87]"
|
||||
icon={mdiArrowRight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
NewVisitPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default NewVisitPage;
|
||||
254
frontend/src/pages/patient/waiting-room.tsx
Normal file
254
frontend/src/pages/patient/waiting-room.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import axios from 'axios';
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../../config';
|
||||
import { mdiClockOutline, mdiAccountGroup, mdiInformationOutline, mdiBullhorn, mdiCheckCircle, mdiAlertDecagram, mdiShieldAccount, mdiChatQuestionOutline, mdiAlertCircle } from '@mdi/js';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
|
||||
const WaitingRoomPage = () => {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [visit, setVisit] = useState<any>(null);
|
||||
const [queueInfo, setQueueInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const visitResp = await axios.get(`/visits/${id}`);
|
||||
setVisit(visitResp.data);
|
||||
|
||||
const queueResp = await axios.get(`/queue?visitId=${id}`);
|
||||
if (queueResp.data.rows && queueResp.data.rows.length > 0) {
|
||||
setQueueInfo(queueResp.data.rows[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !visit) {
|
||||
return (
|
||||
<SectionMain>
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</SectionMain>
|
||||
);
|
||||
}
|
||||
|
||||
if (!visit) {
|
||||
return (
|
||||
<SectionMain>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 font-bold">Besöket kunde inte hittas. Vänligen kontakta personal.</p>
|
||||
</div>
|
||||
</SectionMain>
|
||||
);
|
||||
}
|
||||
|
||||
const isCalled = visit.status === 'in_consultation';
|
||||
const isCompleted = visit.status === 'completed';
|
||||
const analysis = visit.ai_analyses_visit?.[0];
|
||||
const aiData = analysis ? JSON.parse(analysis.raw_output) : null;
|
||||
|
||||
const getTriageLabel = (level: string) => {
|
||||
switch (level) {
|
||||
case '1': return { label: 'Akut', color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' };
|
||||
case '2': return { label: 'Mycket brådskande', color: 'text-orange-600', bg: 'bg-orange-50', border: 'border-orange-200' };
|
||||
case '3': return { label: 'Brådskande', color: 'text-yellow-600', bg: 'bg-yellow-50', border: 'border-yellow-200' };
|
||||
case '4': return { label: 'Stabil', color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200' };
|
||||
case '5': return { label: 'Ej brådskande', color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-200' };
|
||||
default: return { label: 'Bedömd', color: 'text-gray-600', bg: 'bg-gray-50', border: 'border-gray-200' };
|
||||
}
|
||||
};
|
||||
|
||||
const triage = getTriageLabel(visit.triage_level);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Väntrum - Din Status')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
<SectionTitleLineWithButton icon={mdiClockOutline} title="Din plats i kön" main>
|
||||
<div className={`px-4 py-1 rounded-full border ${triage.bg} ${triage.color} ${triage.border} text-xs font-black uppercase tracking-widest`}>
|
||||
Triagenivå {visit.triage_level}
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{isCalled ? (
|
||||
<CardBox className="border-0 shadow-2xl bg-gradient-to-br from-green-500 to-green-600 text-white rounded-3xl overflow-hidden">
|
||||
<div className="flex flex-col items-center py-16 space-y-8 text-center relative">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10">
|
||||
<BaseIcon path={mdiBullhorn} size={200} />
|
||||
</div>
|
||||
<div className="w-24 h-24 bg-white text-green-600 rounded-full flex items-center justify-center shadow-2xl animate-bounce">
|
||||
<BaseIcon path={mdiBullhorn} size={48} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-5xl font-black tracking-tight">VÄLKOMMEN IN!</h2>
|
||||
<p className="text-xl font-medium opacity-90">
|
||||
Vänligen bege dig till <span className="underline decoration-2 underline-offset-4">Receptionsdisk 1</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-md p-6 rounded-2xl max-w-md mx-auto">
|
||||
<p className="text-sm font-bold">Vi väntar på dig. Din legitimation behöver visas upp vid ankomst.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Column: Wait time & Status */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<CardBox className="rounded-3xl shadow-xl border-0 overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row items-center">
|
||||
<div className="w-full md:w-1/2 p-10 flex flex-col items-center justify-center border-b md:border-b-0 md:border-r border-gray-100 bg-slate-50">
|
||||
<div className="relative mb-6">
|
||||
<LoadingSpinner className="w-32 h-32 text-[#004B87]" />
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4xl font-black text-[#004B87]">{queueInfo?.estimated_wait_minutes || '--'}</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Minuter</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-500 uppercase tracking-widest">Estimerad väntetid</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 p-10 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Din Status</p>
|
||||
<h3 className="text-2xl font-black text-slate-800">Väntar på din tur</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${triage.bg} ${triage.color}`}>
|
||||
<BaseIcon path={mdiShieldAccount} size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700">Inskriven & Prioriterad</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-blue-50 text-blue-600">
|
||||
<BaseIcon path={mdiAlertDecagram} size={20} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700">Digital triagering klar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden p-0.5">
|
||||
<div className="h-full bg-[#004B87] rounded-full animate-pulse transition-all duration-1000" style={{ width: '40%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{/* AI Advice/Reasoning */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CardBox className="rounded-3xl border-0 shadow-lg bg-blue-50">
|
||||
<div className="flex gap-4">
|
||||
<BaseIcon path={mdiInformationOutline} size={24} className="text-blue-600 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-bold text-blue-900">Bedömning</h4>
|
||||
<p className="text-sm text-blue-800 leading-relaxed italic">
|
||||
{aiData?.reasoning || 'Dina symtom analyseras för att ge dig rätt vårdnivå.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBox className="rounded-3xl border-0 shadow-lg bg-green-50">
|
||||
<div className="flex gap-4">
|
||||
<BaseIcon path={mdiChatQuestionOutline} size={24} className="text-green-600 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-bold text-green-900">Råd under väntetiden</h4>
|
||||
<p className="text-sm text-green-800 leading-relaxed font-medium">
|
||||
{aiData?.advice || 'Vila och håll dig i närheten. Kontakta personal om ditt tillstånd försämras.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Info & Steps */}
|
||||
<div className="space-y-8">
|
||||
<CardBox className="bg-slate-900 text-white rounded-3xl shadow-xl border-0">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-slate-300 uppercase tracking-widest text-xs">Patient-ID</h3>
|
||||
<span className="text-xs font-mono text-slate-500">{visit.id.substring(0, 8)}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-500">Symtom beskrivna:</p>
|
||||
<p className="text-sm italic text-slate-300 line-clamp-3">
|
||||
"{visit.symptom_text}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="bg-white p-8 rounded-3xl shadow-xl space-y-6">
|
||||
<h4 className="font-black text-slate-800 flex items-center gap-2">
|
||||
<BaseIcon path={mdiAccountGroup} size={20} className="text-[#004B87]" />
|
||||
Processen
|
||||
</h4>
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ label: 'Inloggning klar', status: 'done' },
|
||||
{ label: 'Symptomanalys klar', status: 'done' },
|
||||
{ label: 'Väntar på kallelse', status: 'current' },
|
||||
{ label: 'Möte med personal', status: 'pending' }
|
||||
].map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${s.status === 'done' ? 'bg-green-100 text-green-600' : s.status === 'current' ? 'bg-blue-600 text-white shadow-lg' : 'bg-gray-100 text-gray-400'}`}>
|
||||
{s.status === 'done' ? '✓' : i + 1}
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${s.status === 'done' ? 'text-gray-400 line-through' : s.status === 'current' ? 'text-slate-800' : 'text-gray-400'}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 p-6 rounded-3xl border border-red-100">
|
||||
<h5 className="text-red-700 font-bold text-sm mb-2 flex items-center gap-2">
|
||||
<BaseIcon path={mdiAlertCircle} size={18} />
|
||||
Om du mår sämre
|
||||
</h5>
|
||||
<p className="text-red-600 text-xs leading-relaxed">
|
||||
Kontakta omedelbart personalen på plats om dina symtom förvärras, om du får svårt att andas eller känner tryck över bröstet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
WaitingRoomPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default WaitingRoomPage;
|
||||
208
frontend/src/pages/staff/queue.tsx
Normal file
208
frontend/src/pages/staff/queue.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import axios from 'axios';
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../../config';
|
||||
import { mdiAccountMultiple, mdiBullhorn, mdiClockOutline, mdiAlert, mdiMedicalBag, mdiHistory, mdiAccountCircle } from '@mdi/js';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
|
||||
const StaffQueuePage = () => {
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [callingId, setCallingId] = useState<string | null>(null);
|
||||
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const response = await axios.get('/queue', {
|
||||
params: {
|
||||
called: false,
|
||||
limit: 100,
|
||||
sort: 'asc',
|
||||
field: 'triage_level'
|
||||
}
|
||||
});
|
||||
const sorted = response.data.rows.sort((a, b) => {
|
||||
if (a.triage_level !== b.triage_level) {
|
||||
return a.triage_level.localeCompare(b.triage_level);
|
||||
}
|
||||
return new Date(a.waiting_since).getTime() - new Date(b.waiting_since).getTime();
|
||||
});
|
||||
setQueue(sorted);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch queue:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue();
|
||||
const interval = setInterval(fetchQueue, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleCall = async (visitId: string) => {
|
||||
if (!visitId) return;
|
||||
setCallingId(visitId);
|
||||
try {
|
||||
await axios.post(`/visits/${visitId}/call`);
|
||||
await fetchQueue();
|
||||
} catch (error) {
|
||||
console.error('Failed to call patient:', error);
|
||||
alert('Kunde inte kalla patienten.');
|
||||
} finally {
|
||||
setCallingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getTriageColor = (level: string) => {
|
||||
switch (level) {
|
||||
case '1': return 'bg-red-600 text-white shadow-red-200';
|
||||
case '2': return 'bg-orange-500 text-white shadow-orange-200';
|
||||
case '3': return 'bg-yellow-400 text-black shadow-yellow-100';
|
||||
case '4': return 'bg-blue-500 text-white shadow-blue-200';
|
||||
case '5': return 'bg-green-500 text-white shadow-green-200';
|
||||
default: return 'bg-gray-200 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const getTriageLabel = (level: string) => {
|
||||
switch (level) {
|
||||
case '1': return 'Prio 1: AKUT';
|
||||
case '2': return 'Prio 2: BRÅDSKANDE';
|
||||
case '3': return 'Prio 3: STABIL';
|
||||
case '4': return 'Prio 4: MINDRE BRÅDSKANDE';
|
||||
case '5': return 'Prio 5: EJ AKUT';
|
||||
default: return `Nivå ${level}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Patientkö - Personal')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiAccountMultiple} title="Klinisk Översikt & Prioritering" main>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-500">
|
||||
<span className="w-3 h-3 bg-red-600 rounded-full animate-pulse"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{loading && queue.length === 0 ? (
|
||||
<div className="flex justify-center p-20">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{queue.length === 0 ? (
|
||||
<CardBox className="text-center p-20 text-gray-400 italic">
|
||||
Inga väntande patienter för tillfället.
|
||||
</CardBox>
|
||||
) : (
|
||||
queue.map((item: any) => {
|
||||
const visitId = item.visit?.id || item.visitId;
|
||||
const aiAnalysis = item.visit?.ai_analyses_visit?.[0];
|
||||
const aiData = aiAnalysis ? JSON.parse(aiAnalysis.raw_output) : null;
|
||||
|
||||
return (
|
||||
<CardBox key={item.id} className="rounded-3xl border-0 shadow-lg hover:shadow-2xl transition-all duration-300">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-8 p-2">
|
||||
{/* Prio Badge */}
|
||||
<div className={`flex-shrink-0 w-full lg:w-40 h-32 rounded-2xl flex flex-col items-center justify-center shadow-lg ${getTriageColor(item.triage_level)}`}>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-80 mb-1">Status</span>
|
||||
<span className="text-4xl font-black">{item.triage_level}</span>
|
||||
<span className="text-[10px] font-bold mt-1 opacity-90">{getTriageLabel(item.triage_level).split(':')[1]}</span>
|
||||
</div>
|
||||
|
||||
{/* Patient Info */}
|
||||
<div className="flex-grow space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BaseIcon path={mdiAccountCircle} size={20} className="text-[#004B87]" />
|
||||
<h3 className="text-2xl font-black text-slate-800">{item.label}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs font-bold text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<BaseIcon path={mdiClockOutline} size={14} />
|
||||
Väntat {Math.floor((new Date().getTime() - new Date(item.waiting_since).getTime()) / 60000)} min
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 rounded text-slate-500">
|
||||
REF: {visitId?.substring(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aiData?.level && (
|
||||
<div className="px-3 py-1 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold border border-blue-100">
|
||||
AI: {aiData.level}
|
||||
</div>
|
||||
)}
|
||||
{item.visit?.ai_fallback && (
|
||||
<div className="px-3 py-1 bg-yellow-50 text-yellow-700 rounded-lg text-xs font-bold border border-yellow-100">
|
||||
Fallback aktiv
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-1">
|
||||
<BaseIcon path={mdiAlert} size={12} /> Symtom
|
||||
</p>
|
||||
<p className="text-sm text-slate-700 line-clamp-2 italic">
|
||||
"{item.visit?.symptom_text}"
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-1">
|
||||
<BaseIcon path={mdiMedicalBag} size={12} /> AI Resonemang
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 line-clamp-2">
|
||||
{aiData?.reasoning || 'Ingen AI-analys tillgänglig.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex-shrink-0 flex lg:flex-col gap-3">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label={callingId === visitId ? 'Kallar...' : 'Kalla Patient'}
|
||||
icon={mdiBullhorn}
|
||||
onClick={() => handleCall(visitId)}
|
||||
disabled={callingId !== null}
|
||||
className="flex-grow lg:w-48 h-16 rounded-2xl font-black text-lg shadow-xl shadow-blue-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StaffQueuePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default StaffQueuePage;
|
||||
Loading…
x
Reference in New Issue
Block a user