forked from RiseUP/riseup-squad20
563 lines
21 KiB
TypeScript
563 lines
21 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { useState, useCallback, useMemo } from "react"
|
|
|
|
interface PatientRecord {
|
|
id: string
|
|
fullName: string
|
|
contactNumber: string
|
|
cityLocation: string
|
|
stateRegion: string
|
|
lastVisitDate: string
|
|
nextAppointmentDate: string
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
interface NotificationState {
|
|
message: string
|
|
type: "success" | "error" | "info"
|
|
isVisible: boolean
|
|
}
|
|
|
|
interface FormData {
|
|
fullName: string
|
|
contactNumber: string
|
|
cityLocation: string
|
|
stateRegion: string
|
|
lastVisitDate: string
|
|
nextAppointmentDate: string
|
|
}
|
|
|
|
const useLocalStorage = <T,>(key: string, initialValue: T) => {
|
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
if (typeof window === "undefined") return initialValue
|
|
try {
|
|
const item = window.localStorage.getItem(key)
|
|
return item ? JSON.parse(item) : initialValue
|
|
} catch (error) {
|
|
console.error(`Error reading localStorage key "${key}":`, error)
|
|
return initialValue
|
|
}
|
|
})
|
|
|
|
const setValue = useCallback(
|
|
(value: T | ((val: T) => T)) => {
|
|
try {
|
|
const valueToStore = value instanceof Function ? value(storedValue) : value
|
|
setStoredValue(valueToStore)
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error setting localStorage key "${key}":`, error)
|
|
}
|
|
},
|
|
[key, storedValue],
|
|
)
|
|
|
|
return [storedValue, setValue] as const
|
|
}
|
|
|
|
const useNotification = () => {
|
|
const [notification, setNotification] = useState<NotificationState>({
|
|
message: "",
|
|
type: "info",
|
|
isVisible: false,
|
|
})
|
|
|
|
const showNotification = useCallback((message: string, type: NotificationState["type"] = "info") => {
|
|
setNotification({ message, type, isVisible: true })
|
|
setTimeout(() => {
|
|
setNotification((prev) => ({ ...prev, isVisible: false }))
|
|
}, 4000)
|
|
}, [])
|
|
|
|
return { notification, showNotification }
|
|
}
|
|
|
|
const generatePatientId = (): string => {
|
|
return `PT${Date.now().toString().slice(-6)}${Math.random().toString(36).substr(2, 3).toUpperCase()}`
|
|
}
|
|
|
|
const formatDateDisplay = (dateString: string): string => {
|
|
if (!dateString) return "Não informado"
|
|
try {
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString("pt-BR", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
})
|
|
} catch {
|
|
return "Data inválida"
|
|
}
|
|
}
|
|
|
|
const validateFormData = (data: FormData): string[] => {
|
|
const errors: string[] = []
|
|
if (!data.fullName.trim()) errors.push("Nome completo é obrigatório")
|
|
if (!data.contactNumber.trim()) errors.push("Número de contato é obrigatório")
|
|
if (!data.cityLocation.trim()) errors.push("Cidade é obrigatória")
|
|
if (data.fullName.trim().length < 2) errors.push("Nome deve ter pelo menos 2 caracteres")
|
|
if (data.contactNumber.trim().length < 10) errors.push("Número de contato deve ter pelo menos 10 dígitos")
|
|
return errors
|
|
}
|
|
|
|
export default function HospitalManagementSystem() {
|
|
const [patientRecords, setPatientRecords] = useLocalStorage<PatientRecord[]>("hospital_patients_v3", [])
|
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
const [editingRecord, setEditingRecord] = useState<PatientRecord | null>(null)
|
|
const [formData, setFormData] = useState<FormData>({
|
|
fullName: "",
|
|
contactNumber: "",
|
|
cityLocation: "",
|
|
stateRegion: "",
|
|
lastVisitDate: "",
|
|
nextAppointmentDate: "",
|
|
})
|
|
|
|
const { notification, showNotification } = useNotification()
|
|
|
|
const systemMetrics = useMemo(
|
|
() => ({
|
|
totalPatients: patientRecords.length,
|
|
scheduledAppointments: patientRecords.filter((record) => record.nextAppointmentDate).length,
|
|
contactsAvailable: patientRecords.filter((record) => record.contactNumber).length,
|
|
recentVisits: patientRecords.filter((record) => {
|
|
if (!record.lastVisitDate) return false
|
|
const visitDate = new Date(record.lastVisitDate)
|
|
const thirtyDaysAgo = new Date()
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
|
return visitDate >= thirtyDaysAgo
|
|
}).length,
|
|
}),
|
|
[patientRecords],
|
|
)
|
|
|
|
const resetFormState = useCallback(() => {
|
|
setFormData({
|
|
fullName: "",
|
|
contactNumber: "",
|
|
cityLocation: "",
|
|
stateRegion: "",
|
|
lastVisitDate: "",
|
|
nextAppointmentDate: "",
|
|
})
|
|
setEditingRecord(null)
|
|
setIsModalOpen(false)
|
|
}, [])
|
|
|
|
const handleInputChange = useCallback((field: keyof FormData, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
|
}, [])
|
|
|
|
const openCreateModal = useCallback(() => {
|
|
resetFormState()
|
|
setIsModalOpen(true)
|
|
}, [resetFormState])
|
|
|
|
const openEditModal = useCallback((record: PatientRecord) => {
|
|
setFormData({
|
|
fullName: record.fullName,
|
|
contactNumber: record.contactNumber,
|
|
cityLocation: record.cityLocation,
|
|
stateRegion: record.stateRegion,
|
|
lastVisitDate: record.lastVisitDate,
|
|
nextAppointmentDate: record.nextAppointmentDate,
|
|
})
|
|
setEditingRecord(record)
|
|
setIsModalOpen(true)
|
|
}, [])
|
|
|
|
const handleSubmitForm = useCallback(
|
|
(e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
const validationErrors = validateFormData(formData)
|
|
if (validationErrors.length > 0) {
|
|
showNotification(validationErrors[0], "error")
|
|
return
|
|
}
|
|
|
|
const timestamp = new Date().toISOString()
|
|
|
|
if (editingRecord) {
|
|
const updatedRecord: PatientRecord = {
|
|
...editingRecord,
|
|
...formData,
|
|
updatedAt: timestamp,
|
|
}
|
|
setPatientRecords((prev) => prev.map((record) => (record.id === editingRecord.id ? updatedRecord : record)))
|
|
showNotification("Dados do paciente atualizados com sucesso!", "success")
|
|
} else {
|
|
const newRecord: PatientRecord = {
|
|
id: generatePatientId(),
|
|
...formData,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
}
|
|
setPatientRecords((prev) => [...prev, newRecord])
|
|
showNotification("Paciente cadastrado no sistema!", "success")
|
|
}
|
|
|
|
resetFormState()
|
|
},
|
|
[formData, editingRecord, setPatientRecords, showNotification, resetFormState],
|
|
)
|
|
|
|
const handleDeleteRecord = useCallback(
|
|
(recordId: string) => {
|
|
const confirmDelete = window.confirm(
|
|
"Confirma a remoção deste paciente do sistema? Esta ação não pode ser desfeita.",
|
|
)
|
|
if (confirmDelete) {
|
|
setPatientRecords((prev) => prev.filter((record) => record.id !== recordId))
|
|
showNotification("Paciente removido do sistema.", "success")
|
|
}
|
|
},
|
|
[setPatientRecords, showNotification],
|
|
)
|
|
|
|
return (
|
|
<div className="medical-system">
|
|
<header className="system-header">
|
|
<div className="header-brand">
|
|
<div className="brand-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
</svg>
|
|
</div>
|
|
<div className="brand-text">
|
|
<h1>MedSystem Pro</h1>
|
|
<p>Sistema Integrado de Gestão Hospitalar</p>
|
|
</div>
|
|
</div>
|
|
<div className="header-actions">
|
|
<div className="user-info">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
<circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
Dr. Admin
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="main-content">
|
|
<section className="quick-stats">
|
|
<div className="stat-card patients">
|
|
<div className="stat-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
</div>
|
|
<div className="stat-content">
|
|
<h3>{systemMetrics.totalPatients}</h3>
|
|
<p>Total de Pacientes</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card appointments">
|
|
<div className="stat-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
</div>
|
|
<div className="stat-content">
|
|
<h3>{systemMetrics.scheduledAppointments}</h3>
|
|
<p>Consultas Agendadas</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card contacts">
|
|
<div className="stat-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
|
</svg>
|
|
</div>
|
|
<div className="stat-content">
|
|
<h3>{systemMetrics.contactsAvailable}</h3>
|
|
<p>Contatos Disponíveis</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card recent">
|
|
<div className="stat-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12,6 12,12 16,14" />
|
|
</svg>
|
|
</div>
|
|
<div className="stat-content">
|
|
<h3>{systemMetrics.recentVisits}</h3>
|
|
<p>Visitas Recentes</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="control-panel">
|
|
<h2 className="panel-title">Registro de Pacientes</h2>
|
|
<button className="add-patient-btn" onClick={openCreateModal}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
Novo Paciente
|
|
</button>
|
|
</div>
|
|
|
|
<section className="patients-section">
|
|
<div className="section-header">
|
|
<h2 className="section-title">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14,2 14,8 20,8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<polyline points="10,9 9,9 8,9" />
|
|
</svg>
|
|
Pacientes Cadastrados
|
|
<span className="patient-count">{patientRecords.length}</span>
|
|
</h2>
|
|
</div>
|
|
|
|
{patientRecords.length === 0 ? (
|
|
<div className="empty-state">
|
|
<svg className="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
<h3 className="empty-title">Nenhum paciente cadastrado</h3>
|
|
<p className="empty-description">Clique em "Novo Paciente" para começar</p>
|
|
</div>
|
|
) : (
|
|
<div className="table-wrapper">
|
|
<table className="patients-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Paciente</th>
|
|
<th>Contato</th>
|
|
<th>Localização</th>
|
|
<th>Última Consulta</th>
|
|
<th>Próxima Consulta</th>
|
|
<th>Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{patientRecords.map((record) => (
|
|
<tr key={record.id}>
|
|
<td>
|
|
<div className="patient-name">{record.fullName}</div>
|
|
<div className="patient-id">ID: {record.id}</div>
|
|
</td>
|
|
<td>
|
|
<div className="contact-info">
|
|
<div className="phone-number">{record.contactNumber}</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="location-info">
|
|
{record.cityLocation}
|
|
{record.stateRegion && `, ${record.stateRegion}`}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="date-info">
|
|
<div className="date-label">Última</div>
|
|
{formatDateDisplay(record.lastVisitDate)}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="date-info">
|
|
<div className="date-label">Próxima</div>
|
|
{formatDateDisplay(record.nextAppointmentDate)}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="action-buttons">
|
|
<button className="action-btn edit-btn" onClick={() => openEditModal(record)} title="Editar">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
className="action-btn delete-btn"
|
|
onClick={() => handleDeleteRecord(record.id)}
|
|
title="Excluir"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3,6 5,6 21,6" />
|
|
<path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2V6" />
|
|
<line x1="10" y1="11" x2="10" y2="17" />
|
|
<line x1="14" y1="11" x2="14" y2="17" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
|
|
<footer className="system-footer">
|
|
<div className="footer-text">🏥 MedSystem Pro - Sistema Hospitalar Integrado</div>
|
|
<div className="footer-version">Versão 2.1.0 | Desenvolvido com React & TypeScript</div>
|
|
</footer>
|
|
|
|
{isModalOpen && (
|
|
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && resetFormState()}>
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14,2 14,8 20,8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<polyline points="10,9 9,9 8,9" />
|
|
</svg>
|
|
<h2>{editingRecord ? "Editar Paciente" : "Novo Paciente"}</h2>
|
|
</div>
|
|
|
|
<div className="modal-body">
|
|
<form onSubmit={handleSubmitForm}>
|
|
<div className="form-grid">
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="fullName">
|
|
Nome Completo *
|
|
</label>
|
|
<input
|
|
id="fullName"
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.fullName}
|
|
onChange={(e) => handleInputChange("fullName", e.target.value)}
|
|
placeholder="Nome completo do paciente"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="contactNumber">
|
|
Telefone *
|
|
</label>
|
|
<input
|
|
id="contactNumber"
|
|
type="tel"
|
|
className="form-input"
|
|
value={formData.contactNumber}
|
|
onChange={(e) => handleInputChange("contactNumber", e.target.value)}
|
|
placeholder="(11) 99999-9999"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="cityLocation">
|
|
Cidade *
|
|
</label>
|
|
<input
|
|
id="cityLocation"
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.cityLocation}
|
|
onChange={(e) => handleInputChange("cityLocation", e.target.value)}
|
|
placeholder="Cidade"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="stateRegion">
|
|
Estado
|
|
</label>
|
|
<input
|
|
id="stateRegion"
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.stateRegion}
|
|
onChange={(e) => handleInputChange("stateRegion", e.target.value)}
|
|
placeholder="SP, RJ, MG..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="lastVisitDate">
|
|
Última Consulta
|
|
</label>
|
|
<input
|
|
id="lastVisitDate"
|
|
type="date"
|
|
className="form-input"
|
|
value={formData.lastVisitDate}
|
|
onChange={(e) => handleInputChange("lastVisitDate", e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label" htmlFor="nextAppointmentDate">
|
|
Próxima Consulta
|
|
</label>
|
|
<input
|
|
id="nextAppointmentDate"
|
|
type="date"
|
|
className="form-input"
|
|
value={formData.nextAppointmentDate}
|
|
onChange={(e) => handleInputChange("nextAppointmentDate", e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-actions">
|
|
<button type="submit" className="save-btn">
|
|
{editingRecord ? "Atualizar" : "Cadastrar"}
|
|
</button>
|
|
<button type="button" className="cancel-btn" onClick={resetFormState}>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{notification.isVisible && (
|
|
<div className="toast-container">
|
|
<div className={`toast ${notification.type}`}>
|
|
<div className="toast-message">{notification.message}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|