Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
a5d0541689 Fixed 2026-01-29 14:22:51 +00:00
12 changed files with 1442 additions and 159 deletions

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

@ -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 rätt vård. Genom att triagera dig hemma minskar vi trycket 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">&quot;Symtom: Tryck över bröstet...&quot;</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>;
};
};

View 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 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 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 &quot;Skicka in&quot; 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;

View 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 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 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">
&quot;{visit.symptom_text}&quot;
</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 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;

View 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">
&quot;{item.visit?.symptom_text}&quot;
</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;