Start
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
postgres-data
|
||||||
|
uploads
|
||||||
|
logs
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-jwt-secret-key-here
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=jdbc:postgresql://localhost:5432/telemedicine
|
||||||
|
DATABASE_USERNAME=postgres
|
||||||
|
DATABASE_PASSWORD=your-database-password
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
MAIL_HOST=mail.gnxsoft.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=support@gnxsoft.com
|
||||||
|
MAIL_PASSWORD=your-email-password
|
||||||
|
|
||||||
|
# Gemini AI Configuration
|
||||||
|
GEMINI_API_KEY=your-gemini-api-key
|
||||||
|
|
||||||
|
# TURN Server Configuration
|
||||||
|
TURN_ENABLED=true
|
||||||
|
TURN_HOST=turn
|
||||||
|
TURN_PUBLIC_IP=your-public-ip
|
||||||
|
TURN_USERNAME=telemedicine
|
||||||
|
TURN_PASSWORD=your-turn-password
|
||||||
|
TURN_SECRET=your-turn-secret
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/mvnw text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
HELP.md
|
||||||
|
target/
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
!**/src/main/**/target/
|
||||||
|
!**/src/test/**/target/
|
||||||
|
.env
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
build/
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
2
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||||
|
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM maven:4.0.0-rc-4-eclipse-temurin-25-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn dependency:go-offline -B
|
||||||
|
|
||||||
|
ARG JAR_FILE=target/*.jar
|
||||||
|
COPY ${JAR_FILE} app.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
425
README.md
Normal file
425
README.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# Telemedicine Platform API
|
||||||
|
|
||||||
|
A comprehensive telemedicine platform API built with Spring Boot that enables secure patient-doctor interactions, appointment management, and AI-powered symptom triage services.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 **JWT-based Authentication & Authorization** - Secure user authentication with role-based access control
|
||||||
|
- 👥 **Multi-role User Management** - Support for Patients, Doctors, and Admins
|
||||||
|
- 📅 **Appointment Scheduling** - Complete appointment lifecycle management
|
||||||
|
- 🤖 **AI-Powered Triage** - Intelligent symptom analysis using Google Gemini AI
|
||||||
|
- 📧 **Email Notifications** - Automated email notifications for appointments
|
||||||
|
- 📊 **Admin Dashboard** - System statistics and audit trail management
|
||||||
|
- 📝 **API Documentation** - Interactive Swagger/OpenAPI documentation
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Spring Boot 3.5.6
|
||||||
|
- **Language**: Java 25
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Security**: Spring Security with JWT (jjwt 0.13.0)
|
||||||
|
- **AI Integration**: Google Gemini API 1.23.0
|
||||||
|
- **Email**: Spring Boot Starter Mail (Gmail SMTP)
|
||||||
|
- **Migration**: Flyway 11.13.1
|
||||||
|
- **Mapping**: MapStruct 1.6.3
|
||||||
|
- **Validation**: Spring Boot Starter Validation
|
||||||
|
- **Documentation**: SpringDoc OpenAPI 2.8.13
|
||||||
|
- **Configuration**: Spring DotEnv 4.0.0
|
||||||
|
- **Build Tool**: Maven
|
||||||
|
- **Testing**: JUnit 5, Mockito, Spring Boot Test
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Java 17 or higher (Java 25 recommended)
|
||||||
|
- PostgreSQL 12 or higher
|
||||||
|
- Maven 3.6+
|
||||||
|
- Gmail account (for email notifications)
|
||||||
|
- Google Gemini API key
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd telemedicine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database Setup
|
||||||
|
|
||||||
|
Create a PostgreSQL database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE telemedicine;
|
||||||
|
```
|
||||||
|
|
||||||
|
The database schema will be automatically created by Flyway migrations when you first run the application.
|
||||||
|
|
||||||
|
#### Database Migrations
|
||||||
|
|
||||||
|
The project uses Flyway for database version control. Migrations are located in `src/main/resources/db/migration/`:
|
||||||
|
|
||||||
|
- **V1__initial_migration.sql** - Creates the `users` table with UUID support
|
||||||
|
- **V2__add_patients_and_doctors.sql** - Creates `patients` and `doctors` tables
|
||||||
|
- **V3__add_appointments.sql** - Creates `appointments` table
|
||||||
|
- **V4__add_ai_triage_audits.sql** - Creates `ai_triage_audits` table
|
||||||
|
|
||||||
|
Migrations run automatically on application startup. To manually run Flyway commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations
|
||||||
|
mvn flyway:migrate
|
||||||
|
|
||||||
|
# Get migration status
|
||||||
|
mvn flyway:info
|
||||||
|
|
||||||
|
# Clean database (⚠️ WARNING: Deletes all data)
|
||||||
|
mvn flyway:clean
|
||||||
|
|
||||||
|
# Repair migration history
|
||||||
|
mvn flyway:repair
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Update database credentials in `pom.xml` under the `flyway-maven-plugin` configuration if needed.
|
||||||
|
|
||||||
|
### 3. Environment Configuration
|
||||||
|
|
||||||
|
Create a `.env` file in the project root (use `.env.example` as template):
|
||||||
|
|
||||||
|
```properties
|
||||||
|
JWT_SECRET=your-secret-key-at-least-32-characters
|
||||||
|
MAIL_USERNAME=your-gmail@gmail.com
|
||||||
|
MAIL_PASSWORD=your-app-specific-password
|
||||||
|
GEMINI_API_KEY=your-gemini-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: For Gmail, you need to generate an [App Password](https://support.google.com/accounts/answer/185833) instead of using your regular password.
|
||||||
|
|
||||||
|
### 4. Application Configuration
|
||||||
|
|
||||||
|
Update `src/main/resources/application.yml` with your database credentials:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://localhost:5432/telemedicine
|
||||||
|
username: postgres
|
||||||
|
password: your-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the project
|
||||||
|
mvn clean install
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
mvn spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will start on `http://localhost:8080`
|
||||||
|
|
||||||
|
### 6. Access API Documentation
|
||||||
|
|
||||||
|
Once the application is running, visit:
|
||||||
|
- Swagger UI: `http://localhost:8080/swagger-ui.html`
|
||||||
|
- OpenAPI Docs: `http://localhost:8080/v3/api-docs`
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
### Authentication Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|
|--------|---------------------------|---------------------|---------------|
|
||||||
|
| POST | `/auth/login` | User login | No |
|
||||||
|
| POST | `/users/register/doctor` | Register as doctor | No |
|
||||||
|
| POST | `/users/register/patient` | Register as patient | No |
|
||||||
|
| POST | `/users/register/admin` | Register as admin | No* |
|
||||||
|
|
||||||
|
*Admin registration requires email to match configured admin email
|
||||||
|
|
||||||
|
### Appointment Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Role |
|
||||||
|
|--------|-------------------------------|--------------------------|---------------|
|
||||||
|
| GET | `/appointments/patient/{id}` | Get patient appointments | Authenticated |
|
||||||
|
| GET | `/appointments/doctor/{id}` | Get doctor appointments | Authenticated |
|
||||||
|
| POST | `/appointments` | Create appointment | DOCTOR |
|
||||||
|
| PATCH | `/appointments/{id}/cancel` | Cancel appointment | Authenticated |
|
||||||
|
| PATCH | `/appointments/{id}/confirm` | Confirm appointment | Authenticated |
|
||||||
|
| PATCH | `/appointments/{id}/complete` | Complete appointment | Authenticated |
|
||||||
|
| DELETE | `/appointments/{id}` | Delete appointment | ADMIN |
|
||||||
|
|
||||||
|
### AI Triage Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Role |
|
||||||
|
|--------|----------------------------|--------------------------------|--------|
|
||||||
|
| POST | `/ai/triage/analyze` | Analyze patient symptoms | DOCTOR |
|
||||||
|
| POST | `/ai/triage/quick-analyze` | Quick analysis (no patient ID) | DOCTOR |
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------------------------------------|---------------------------|
|
||||||
|
| GET | `/admin/users` | Get all users |
|
||||||
|
| GET | `/admin/patients` | Get all patients |
|
||||||
|
| GET | `/admin/doctors` | Get all doctors |
|
||||||
|
| GET | `/admin/stats` | Get system statistics |
|
||||||
|
| GET | `/admin/triages` | Get AI triage audits |
|
||||||
|
| GET | `/admin/triage-audits/patient/{id}` | Get patient triage audits |
|
||||||
|
| GET | `/admin/triage-audits/urgency/{level}` | Get audits by urgency |
|
||||||
|
| PATCH | `/admin/users/{email}/activate` | Activate user |
|
||||||
|
| PATCH | `/admin/users/{email}/deactivate` | Deactivate user |
|
||||||
|
| PATCH | `/admin/doctors/{license}/verify` | Verify doctor |
|
||||||
|
| PATCH | `/admin/doctors/{license}/unverify` | Unverify doctor |
|
||||||
|
| DELETE | `/admin/users/{email}` | Delete user |
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
### PATIENT
|
||||||
|
- Book and manage appointments
|
||||||
|
- Use AI triage services (via doctor)
|
||||||
|
- View personal appointment history
|
||||||
|
|
||||||
|
### DOCTOR
|
||||||
|
- Create and manage appointments
|
||||||
|
- Access AI symptom triage tools
|
||||||
|
- View patient appointment history
|
||||||
|
- Analyze patient symptoms
|
||||||
|
|
||||||
|
### ADMIN
|
||||||
|
- Full system access
|
||||||
|
- User management (activate/deactivate/delete)
|
||||||
|
- Doctor verification
|
||||||
|
- View system statistics
|
||||||
|
- Access AI triage audit logs
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The application uses Flyway migrations to manage the database schema. The following tables are created:
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
**users** (V1__initial_migration.sql)
|
||||||
|
- Core user information for all user types
|
||||||
|
- Fields: id (UUID), email, password (encrypted), role, first_name, last_name, phone_number, is_active, created_at, updated_at
|
||||||
|
- Unique constraint on email
|
||||||
|
- Uses UUID extension for primary keys
|
||||||
|
|
||||||
|
**patients** (V2__add_patients_and_doctors.sql)
|
||||||
|
- Patient-specific data
|
||||||
|
- Fields: id (UUID), user_id (FK to users), emergency_contact_name, emergency_contact_phone, blood_type, allergies (array), created_at
|
||||||
|
- Blood type validation: A+, A-, B+, B-, AB+, AB-, O+, O-
|
||||||
|
- CASCADE delete on user deletion
|
||||||
|
|
||||||
|
**doctors** (V2__add_patients_and_doctors.sql)
|
||||||
|
- Doctor-specific data
|
||||||
|
- Fields: id (UUID), user_id (FK to users), medical_license_number (unique), specialization, years_of_experience, biography, consultation_fee, is_verified, created_at
|
||||||
|
- CASCADE delete on user deletion
|
||||||
|
|
||||||
|
**appointments** (V3__add_appointments.sql)
|
||||||
|
- Appointment scheduling and management
|
||||||
|
- Fields: id (UUID), patient_id (FK), doctor_id (FK), scheduled_date, scheduled_time, duration_minutes, status, created_at
|
||||||
|
- Status values: SCHEDULED, CANCELLED, CONFIRMED, COMPLETED
|
||||||
|
- CASCADE delete on patient/doctor deletion
|
||||||
|
|
||||||
|
**ai_triage_audits** (V4__add_ai_triage_audits.sql)
|
||||||
|
- AI analysis audit trail
|
||||||
|
- Fields: id (UUID), patient_id (FK), user_input (symptoms), ai_output (analysis), urgency_level, created_at
|
||||||
|
- Urgency levels: LOW, MEDIUM, HIGH, EMERGENCY
|
||||||
|
- CASCADE delete on patient deletion
|
||||||
|
|
||||||
|
### Database Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
users (1) ←→ (1) patients
|
||||||
|
users (1) ←→ (1) doctors
|
||||||
|
patients (1) ←→ (∞) appointments
|
||||||
|
doctors (1) ←→ (∞) appointments
|
||||||
|
patients (1) ←→ (∞) ai_triage_audits
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Management
|
||||||
|
|
||||||
|
To view the current migration status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn flyway:info
|
||||||
|
```
|
||||||
|
|
||||||
|
To apply pending migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn flyway:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
All migrations are idempotent and safe to run multiple times.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
telemedicine/
|
||||||
|
├── src/
|
||||||
|
│ ├── main/
|
||||||
|
│ │ ├── java/
|
||||||
|
│ │ │ └── com/gnx/telemedicine/
|
||||||
|
│ │ │ ├── config/ # Configuration(Swagger UI)
|
||||||
|
│ │ │ ├── controller/ # REST controllers
|
||||||
|
│ │ │ ├── dto/ # Data Transfer Objects
|
||||||
|
│ │ │ ├── exception/ # Custom exceptions
|
||||||
|
│ │ │ ├── mappers/ # MapStruct mappers
|
||||||
|
│ │ │ ├── model/ # JPA entities
|
||||||
|
│ │ │ ├── repository/ # Spring Data repositories
|
||||||
|
│ │ │ ├── security/ # Security configuration
|
||||||
|
│ │ │ └── service/ # Business logic
|
||||||
|
│ │ └── resources/
|
||||||
|
│ │ ├── db/migration/ # Flyway migrations
|
||||||
|
│ │ └── application.yml # Application configuration
|
||||||
|
│ └── test/ # Test files
|
||||||
|
├── .env # Environment variables
|
||||||
|
├── .env.example # Example for .env file
|
||||||
|
├── .gitignore
|
||||||
|
├── pom.xml # Maven configuration
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Triage System
|
||||||
|
|
||||||
|
The AI triage system uses Google Gemini to analyze patient symptoms and provides:
|
||||||
|
- **Urgency Level**: LOW, MEDIUM, HIGH, EMERGENCY
|
||||||
|
- **Recommended Specialty**: Suggested medical specialty
|
||||||
|
- **Key Questions**: Follow-up questions for doctors
|
||||||
|
- **Triage Notes**: Analysis summary
|
||||||
|
- **Audit Trail**: All analyses are logged for review
|
||||||
|
|
||||||
|
### Example Triage Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"patientId": "uuid-here",
|
||||||
|
"symptomDescription": "Patient experiencing chest pain and shortness of breath"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"patientId": "uuid-here",
|
||||||
|
"urgency": "EMERGENCY",
|
||||||
|
"recommendedSpecialty": "Cardiology",
|
||||||
|
"keyQuestions": [
|
||||||
|
"When did symptoms start?",
|
||||||
|
"Any history of heart disease?"
|
||||||
|
],
|
||||||
|
"triageNotes": "Severe symptoms requiring immediate attention",
|
||||||
|
"disclaimer": "AI analysis for triage assistance only. Not medical advice."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email Notifications
|
||||||
|
|
||||||
|
The system automatically sends email notifications for:
|
||||||
|
- Appointment confirmations (to both patient and doctor)
|
||||||
|
- Appointment cancellations
|
||||||
|
- Status updates
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
mvn test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
mvn test jacoco:report
|
||||||
|
```
|
||||||
|
|
||||||
|
The project includes:
|
||||||
|
- Unit tests for services
|
||||||
|
- Integration tests for controllers
|
||||||
|
- Repository tests
|
||||||
|
- Security tests
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Passwords are encrypted using BCrypt
|
||||||
|
- JWT tokens expire after 24 hours (configurable)
|
||||||
|
- Role-based access control (RBAC)
|
||||||
|
- CSRF protection disabled for REST API
|
||||||
|
- Stateless session management
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The API uses standard HTTP status codes:
|
||||||
|
- `200` - Success
|
||||||
|
- `201` - Created
|
||||||
|
- `204` - No Content
|
||||||
|
- `400` - Bad Request (validation errors)
|
||||||
|
- `401` - Unauthorized (authentication required)
|
||||||
|
- `403` - Forbidden (insufficient permissions)
|
||||||
|
- `500` - Internal Server Error
|
||||||
|
|
||||||
|
All errors return a consistent JSON format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "400",
|
||||||
|
"error": "Error Type",
|
||||||
|
"message": "Detailed error message",
|
||||||
|
"timestamp": "2025-10-22T10:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Properties
|
||||||
|
|
||||||
|
Key application properties in `application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET} # JWT signing key
|
||||||
|
expiration: 86400 # Token expiration (seconds)
|
||||||
|
|
||||||
|
gemini:
|
||||||
|
api-key: ${GEMINI_API_KEY} # Google Gemini API key
|
||||||
|
|
||||||
|
spring:
|
||||||
|
mail:
|
||||||
|
host: smtp.gmail.com
|
||||||
|
port: 587
|
||||||
|
username: ${MAIL_USERNAME}
|
||||||
|
password: ${MAIL_PASSWORD}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Tips
|
||||||
|
|
||||||
|
1. **Database Migrations**:
|
||||||
|
- Flyway automatically runs migrations on startup
|
||||||
|
- Never modify existing migration files
|
||||||
|
- Create new migration files with format: `V{version}__{description}.sql`
|
||||||
|
- Check migration status: `mvn flyway:info`
|
||||||
|
|
||||||
|
2. **API Testing**: Use Swagger UI for interactive API testing
|
||||||
|
|
||||||
|
3. **Logging**: Check console logs for detailed application flow
|
||||||
|
|
||||||
|
4. **Email Testing**: Use a test Gmail account for development
|
||||||
|
|
||||||
|
5. **Maven Compilation**: The project uses annotation processors for Lombok and MapStruct
|
||||||
|
```bash
|
||||||
|
# Clean build
|
||||||
|
mvn clean install
|
||||||
|
|
||||||
|
# Skip tests during build
|
||||||
|
mvn clean install -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
**GNX Soft LTD**
|
||||||
|
- Email: sales@gnxsoft.com
|
||||||
72
docker-compose.yml
Normal file
72
docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: telemedicine-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: telemedicine
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ./postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- telemedicine-network
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: telemedicine-app
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "18080:8080"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- turn
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/telemedicine
|
||||||
|
SPRING_DATASOURCE_USERNAME: postgres
|
||||||
|
SPRING_DATASOURCE_PASSWORD: password
|
||||||
|
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRATION: 86400
|
||||||
|
|
||||||
|
SPRING_MAIL_HOST: smtp.gmail.com
|
||||||
|
SPRING_MAIL_PORT: 587
|
||||||
|
SPRING_MAIL_USERNAME: ${MAIL_USERNAME}
|
||||||
|
SPRING_MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-dummy-key}
|
||||||
|
networks:
|
||||||
|
- telemedicine-network
|
||||||
|
turn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: telemedicine-turn
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3478:3478/udp"
|
||||||
|
- "3478:3478/tcp"
|
||||||
|
- "49152-49251:49152-49251/udp"
|
||||||
|
environment:
|
||||||
|
TURN_ENABLED: "true"
|
||||||
|
TURN_USERNAME: "telemedicine"
|
||||||
|
TURN_PASSWORD: ${TURN_PASSWORD:-changeme}
|
||||||
|
TURN_REALM: "localdomain"
|
||||||
|
NO_AUTH_RELAY: "false"
|
||||||
|
LISTENING_IP: "0.0.0.0"
|
||||||
|
MIN_PORT: "49152"
|
||||||
|
MAX_PORT: "49251"
|
||||||
|
VERBOSE: "true"
|
||||||
|
volumes:
|
||||||
|
- ./turn-config:/etc/turnserver
|
||||||
|
networks:
|
||||||
|
- telemedicine-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
telemedicine-network:
|
||||||
|
driver: bridge
|
||||||
53
frontend.log
Normal file
53
frontend.log
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
❯ Building...
|
||||||
|
✔ Building...
|
||||||
|
Initial chunk files | Names | Raw size
|
||||||
|
main.js | main | 737.30 kB |
|
||||||
|
polyfills.js | polyfills | 90.20 kB |
|
||||||
|
styles.css | styles | 506 bytes |
|
||||||
|
|
||||||
|
| Initial total | 828.01 kB
|
||||||
|
|
||||||
|
Application bundle generation complete. [7.974 seconds]
|
||||||
|
|
||||||
|
Watch mode enabled. Watching for file changes...
|
||||||
|
NOTE: Raw file sizes do not reflect development server per-request transformations.
|
||||||
|
➜ Local: http://localhost:4200/
|
||||||
|
❯ Changes detected. Rebuilding...
|
||||||
|
✔ Changes detected. Rebuilding...
|
||||||
|
Initial chunk files | Names | Raw size
|
||||||
|
main.js | main | 737.62 kB |
|
||||||
|
|
||||||
|
Application bundle generation complete. [5.294 seconds]
|
||||||
|
|
||||||
|
Page reload sent to client(s).
|
||||||
|
❯ Changes detected. Rebuilding...
|
||||||
|
✔ Changes detected. Rebuilding...
|
||||||
|
Initial chunk files | Names | Raw size
|
||||||
|
main.js | main | 737.78 kB |
|
||||||
|
|
||||||
|
Application bundle generation complete. [1.604 seconds]
|
||||||
|
|
||||||
|
Page reload sent to client(s).
|
||||||
|
❯ Changes detected. Rebuilding...
|
||||||
|
✔ Changes detected. Rebuilding...
|
||||||
|
Initial chunk files | Names | Raw size
|
||||||
|
main.js | main | 737.95 kB |
|
||||||
|
|
||||||
|
Application bundle generation complete. [1.189 seconds]
|
||||||
|
|
||||||
|
Page reload sent to client(s).
|
||||||
|
❯ Changes detected. Rebuilding...
|
||||||
|
✔ Changes detected. Rebuilding...
|
||||||
|
Initial chunk files | Names | Raw size
|
||||||
|
main.js | main | 738.12 kB |
|
||||||
|
|
||||||
|
Application bundle generation complete. [0.539 seconds]
|
||||||
|
|
||||||
|
Page reload sent to client(s).
|
||||||
|
❯ Changes detected. Rebuilding...
|
||||||
|
✔ Changes detected. Rebuilding...
|
||||||
|
|
||||||
|
No output file changes.
|
||||||
|
|
||||||
|
Application bundle generation complete. [1.312 seconds]
|
||||||
|
|
||||||
17
frontend/.editorconfig
Normal file
17
frontend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
42
frontend/.gitignore
vendored
Normal file
42
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
27
frontend/README.md
Normal file
27
frontend/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.21.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||||
|
|
||||||
|
## Further help
|
||||||
|
|
||||||
|
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
111
frontend/angular.json
Normal file
111
frontend/angular.json
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm",
|
||||||
|
"analytics": "645be366-fa02-4fa2-b192-dfa72b91a94d"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"frontend": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/frontend",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kB",
|
||||||
|
"maximumError": "4kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 4200,
|
||||||
|
"ssl": false
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "frontend:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "frontend:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/backend.pid
Normal file
1
frontend/backend.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
19034
|
||||||
24
frontend/ng-serve.log
Normal file
24
frontend/ng-serve.log
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
node:internal/modules/cjs/loader:1423
|
||||||
|
throw err;
|
||||||
|
^
|
||||||
|
|
||||||
|
Error: Cannot find module './bootstrap'
|
||||||
|
Require stack:
|
||||||
|
- /home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng
|
||||||
|
at Module._resolveFilename (node:internal/modules/cjs/loader:1420:15)
|
||||||
|
at defaultResolveImpl (node:internal/modules/cjs/loader:1058:19)
|
||||||
|
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1063:22)
|
||||||
|
at Module._load (node:internal/modules/cjs/loader:1226:37)
|
||||||
|
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
|
||||||
|
at wrapModuleLoad (node:internal/modules/cjs/loader:244:24)
|
||||||
|
at Module.require (node:internal/modules/cjs/loader:1503:12)
|
||||||
|
at require (node:internal/modules/helpers:152:16)
|
||||||
|
at Object.<anonymous> (/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng:70:3)
|
||||||
|
at Module._compile (node:internal/modules/cjs/loader:1760:14) {
|
||||||
|
code: 'MODULE_NOT_FOUND',
|
||||||
|
requireStack: [
|
||||||
|
'/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Node.js v24.11.0
|
||||||
28
frontend/npm-start.log
Normal file
28
frontend/npm-start.log
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
> frontend@0.0.0 start
|
||||||
|
> ng serve
|
||||||
|
|
||||||
|
node:internal/modules/cjs/loader:1423
|
||||||
|
throw err;
|
||||||
|
^
|
||||||
|
|
||||||
|
Error: Cannot find module './bootstrap'
|
||||||
|
Require stack:
|
||||||
|
- /home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng
|
||||||
|
at Module._resolveFilename (node:internal/modules/cjs/loader:1420:15)
|
||||||
|
at defaultResolveImpl (node:internal/modules/cjs/loader:1058:19)
|
||||||
|
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1063:22)
|
||||||
|
at Module._load (node:internal/modules/cjs/loader:1226:37)
|
||||||
|
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
|
||||||
|
at wrapModuleLoad (node:internal/modules/cjs/loader:244:24)
|
||||||
|
at Module.require (node:internal/modules/cjs/loader:1503:12)
|
||||||
|
at require (node:internal/modules/helpers:152:16)
|
||||||
|
at Object.<anonymous> (/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng:70:3)
|
||||||
|
at Module._compile (node:internal/modules/cjs/loader:1760:14) {
|
||||||
|
code: 'MODULE_NOT_FOUND',
|
||||||
|
requireStack: [
|
||||||
|
'/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Node.js v24.11.0
|
||||||
14808
frontend/package-lock.json
generated
Normal file
14808
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/package.json
Normal file
48
frontend/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "node ./node_modules/@angular/cli/bin/ng.js",
|
||||||
|
"start": "node ./node_modules/@angular/cli/bin/ng.js serve",
|
||||||
|
"build": "node ./node_modules/@angular/cli/bin/ng.js build",
|
||||||
|
"watch": "node ./node_modules/@angular/cli/bin/ng.js build --watch --configuration development",
|
||||||
|
"test": "node ./node_modules/@angular/cli/bin/ng.js test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^18.2.0",
|
||||||
|
"@angular/common": "^18.2.0",
|
||||||
|
"@angular/compiler": "^18.2.0",
|
||||||
|
"@angular/core": "^18.2.0",
|
||||||
|
"@angular/forms": "^18.2.0",
|
||||||
|
"@angular/platform-browser": "^18.2.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^18.2.0",
|
||||||
|
"@angular/router": "^18.2.0",
|
||||||
|
"@stomp/stompjs": "^7.0.0",
|
||||||
|
"axios": "^1.13.1",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"sockjs-client": "^1.6.1",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^18.2.21",
|
||||||
|
"@angular/cli": "^18.2.21",
|
||||||
|
"@angular/compiler-cli": "^18.2.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/node": "^24.9.2",
|
||||||
|
"@types/sockjs-client": "^1.5.4",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"jasmine-core": "~5.2.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"typescript": "~5.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
3
frontend/src/app/app.component.html
Normal file
3
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<router-outlet />
|
||||||
|
<app-modal></app-modal>
|
||||||
|
<app-gdpr-consent-banner></app-gdpr-consent-banner>
|
||||||
0
frontend/src/app/app.component.scss
Normal file
0
frontend/src/app/app.component.scss
Normal file
29
frontend/src/app/app.component.spec.ts
Normal file
29
frontend/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should have the 'frontend' title`, () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app.title).toEqual('frontend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/app/app.component.ts
Normal file
21
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { ModalComponent } from './components/modal/modal.component';
|
||||||
|
import { GdprConsentBannerComponent } from './components/gdpr-consent-banner/gdpr-consent-banner.component';
|
||||||
|
import { ErrorHandlerService } from './services/error-handler.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, ModalComponent, GdprConsentBannerComponent],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
title = 'frontend';
|
||||||
|
|
||||||
|
constructor(private errorHandler: ErrorHandlerService) {
|
||||||
|
// Initialize error handler to setup axios interceptor
|
||||||
|
// The service will automatically set up the interceptor in its constructor
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/src/app/app.config.ts
Normal file
8
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
|
||||||
|
};
|
||||||
23
frontend/src/app/app.routes.ts
Normal file
23
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { LoginComponent } from './pages/login/login.component';
|
||||||
|
import { AdminComponent } from './pages/admin/admin.component';
|
||||||
|
import { DoctorComponent } from './pages/doctor/doctor.component';
|
||||||
|
import { PatientComponent } from './pages/patient/patient.component';
|
||||||
|
import { DoctorRegisterComponent } from './pages/register/doctor/doctor-register.component';
|
||||||
|
import { PatientRegisterComponent } from './pages/register/patient/patient-register.component';
|
||||||
|
import { ForgotPasswordComponent } from './pages/forgot-password/forgot-password.component';
|
||||||
|
import { ResetPasswordComponent } from './pages/reset-password/reset-password.component';
|
||||||
|
import { authGuard } from './guards/auth.guard';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: 'login', component: LoginComponent },
|
||||||
|
{ path: 'forgot-password', component: ForgotPasswordComponent },
|
||||||
|
{ path: 'reset-password', component: ResetPasswordComponent },
|
||||||
|
{ path: 'register/doctor', component: DoctorRegisterComponent },
|
||||||
|
{ path: 'register/patient', component: PatientRegisterComponent },
|
||||||
|
{ path: 'admin', component: AdminComponent, canActivate: [authGuard] },
|
||||||
|
{ path: 'doctor', component: DoctorComponent, canActivate: [authGuard] },
|
||||||
|
{ path: 'patient', component: PatientComponent, canActivate: [authGuard] },
|
||||||
|
{ path: '', pathMatch: 'full', redirectTo: 'login' },
|
||||||
|
{ path: '**', redirectTo: 'login' },
|
||||||
|
];
|
||||||
117
frontend/src/app/components/call/call.component.html
Normal file
117
frontend/src/app/components/call/call.component.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<div class="call-container" *ngIf="shouldShowCall()">
|
||||||
|
<!-- Incoming Call / Active Call / Outgoing Call UI -->
|
||||||
|
<div class="call-overlay" *ngIf="currentCall">
|
||||||
|
<!-- Video/Audio Display Area -->
|
||||||
|
<div class="call-media-area" [class.audio-only]="!isVideoCall() || !isCallActive">
|
||||||
|
<!-- Remote Video -->
|
||||||
|
<video
|
||||||
|
#remoteVideo
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
class="remote-video"
|
||||||
|
[class.hidden]="!isVideoCall() || !isCallActive"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- Local Video (Picture-in-Picture) -->
|
||||||
|
<video
|
||||||
|
#localVideo
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
class="local-video"
|
||||||
|
[class.hidden]="!isVideoCall() || !isCallActive || !isVideoEnabled"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- Audio Call UI -->
|
||||||
|
<div class="audio-call-ui" *ngIf="!isVideoCall() || !isCallActive">
|
||||||
|
<div class="avatar-large">
|
||||||
|
<img
|
||||||
|
*ngIf="getOtherUserAvatarUrl()"
|
||||||
|
[src]="userService.getAvatarUrl(getOtherUserAvatarUrl()!)"
|
||||||
|
[alt]="getOtherUserName()"
|
||||||
|
class="avatar-image-large">
|
||||||
|
<div class="avatar-circle-large"
|
||||||
|
[style.display]="getOtherUserAvatarUrl() ? 'none' : 'flex'">
|
||||||
|
{{ getOtherUserName().charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="call-user-info">
|
||||||
|
<h3 class="call-user-name">{{ getOtherUserName() }}</h3>
|
||||||
|
<p class="call-status" *ngIf="isRinging && isIncoming">Incoming {{ currentCall.callType }} call</p>
|
||||||
|
<p class="call-status" *ngIf="isRinging && !isIncoming">Calling...</p>
|
||||||
|
<p class="call-status" *ngIf="isCallActive">{{ currentCall.callType }} call</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Call Controls -->
|
||||||
|
<div class="call-controls">
|
||||||
|
<!-- Outgoing Call Controls (caller waiting for answer) -->
|
||||||
|
<div class="outgoing-controls" *ngIf="isRinging && !isIncoming">
|
||||||
|
<div class="call-status-text">Calling...</div>
|
||||||
|
<button class="call-btn call-btn-end" (click)="endCall()" title="Cancel call">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18" stroke-linecap="round"/>
|
||||||
|
<path d="M6 6L18 18" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incoming Call Controls -->
|
||||||
|
<div class="incoming-controls" *ngIf="isRinging && isIncoming">
|
||||||
|
<button class="call-btn call-btn-reject" (click)="rejectCall()" title="Reject">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="call-btn call-btn-answer" (click)="answerCall()" title="Answer">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 16.92V19.92C22 20.6204 21.719 21.2922 21.219 21.7922C20.719 22.2922 20.0474 22.5734 19.347 22.5134C15.8482 22.2324 12.4895 21.0003 9.6 18.86C7.21358 17.0896 5.35036 14.7065 4.2 12C3.04964 9.29352 2.66265 6.44787 3.07 3.64C3.13826 2.94623 3.43566 2.29903 3.91368 1.80642C4.39171 1.31381 5.01885 1.00758 5.7 1H8.7C9.29674 0.994966 9.87908 1.16796 10.3813 1.49754C10.8835 1.82712 11.2839 2.29965 11.53 2.86L13.08 6.58C13.2833 7.06943 13.3459 7.60609 13.2612 8.12757C13.1765 8.64906 12.9484 9.13502 12.6 9.53L11 11.13C12.7613 13.3728 15.1272 15.2387 17.37 17L18.97 15.4C19.365 15.0516 19.851 14.8235 20.3724 14.7388C20.8939 14.6541 21.4306 14.7167 21.92 14.92L25.64 16.47C26.1928 16.7132 26.6677 17.1107 26.9989 17.6098C27.3301 18.1088 27.5035 18.6879 27.5 19.28L27.5 22.28C27.4913 22.9658 27.2056 23.6191 26.7058 24.1051C26.206 24.5912 25.5326 24.8734 24.83 24.89C22.8968 24.9587 20.9658 24.7498 19.1 24.27" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Call Controls -->
|
||||||
|
<div class="active-controls" *ngIf="isCallActive">
|
||||||
|
<button
|
||||||
|
class="call-btn call-btn-secondary"
|
||||||
|
[class.active]="!isMuted"
|
||||||
|
(click)="toggleMute()"
|
||||||
|
title="{{ isMuted ? 'Unmute' : 'Mute' }}">
|
||||||
|
<svg *ngIf="!isMuted" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 1V23M5 8V16C5 17.0609 5.42143 18.0783 6.17157 18.8284C6.92172 19.5786 7.93913 20 9 20H11L18 13V11L11 4H9C7.93913 4 6.92172 4.42143 6.17157 5.17157C5.42143 5.92172 5 6.93913 5 8Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="isMuted" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 1V23M5 8V16C5 17.0609 5.42143 18.0783 6.17157 18.8284C6.92172 19.5786 7.93913 20 9 20H11L18 13V11L11 4H9C7.93913 4 6.92172 4.42143 6.17157 5.17157C5.42143 5.92172 5 6.93913 5 8Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="23" y1="9" x2="17" y2="15" stroke-linecap="round"/>
|
||||||
|
<line x1="17" y1="9" x2="23" y2="15" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
*ngIf="isVideoCall()"
|
||||||
|
class="call-btn call-btn-secondary"
|
||||||
|
[class.active]="isVideoEnabled"
|
||||||
|
(click)="toggleVideo()"
|
||||||
|
title="{{ isVideoEnabled ? 'Turn off video' : 'Turn on video' }}">
|
||||||
|
<svg *ngIf="isVideoEnabled" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M23 7L16 12L23 17V7Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 5H3C1.89543 5 1 5.89543 1 7V17C1 18.1046 1.89543 19 3 19H14C15.1046 19 16 18.1046 16 17V7C16 5.89543 15.1046 5 14 5Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="!isVideoEnabled" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M16 16L1 1M23 7L16 12L23 17V7Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1 7V17C1 18.1046 1.89543 19 3 19H14C15.1046 19 16 18.1046 16 17V7C16 5.89543 15.1046 5 14 5H3C1.89543 5 1 5.89543 1 7Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="16" x2="23" y2="23" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="call-btn call-btn-end" (click)="endCall()" title="End call">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
697
frontend/src/app/components/call/call.component.scss
Normal file
697
frontend/src/app/components/call/call.component.scss
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
// Enterprise-Grade Call Component Styling
|
||||||
|
// Color Palette
|
||||||
|
$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
$primary-blue: #0066cc;
|
||||||
|
$primary-blue-light: #e6f2ff;
|
||||||
|
$primary-blue-dark: #0052a3;
|
||||||
|
$accent-teal: #0099a1;
|
||||||
|
$accent-purple: #8b5cf6;
|
||||||
|
$text-dark: #1a1a1a;
|
||||||
|
$text-medium: #4a4a4a;
|
||||||
|
$text-light: #6b6b6b;
|
||||||
|
$text-lighter: #9ca3af;
|
||||||
|
$border-color: #e8eaed;
|
||||||
|
$white: #ffffff;
|
||||||
|
$success-green: #10b981;
|
||||||
|
$success-green-light: #d1fae5;
|
||||||
|
$success-green-dark: #059669;
|
||||||
|
$error-red: #ef4444;
|
||||||
|
$error-red-light: #fee2e2;
|
||||||
|
$error-red-dark: #dc2626;
|
||||||
|
$warning-orange: #f59e0b;
|
||||||
|
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
$border-radius-sm: 8px;
|
||||||
|
$border-radius-md: 12px;
|
||||||
|
$border-radius-lg: 16px;
|
||||||
|
$border-radius-xl: 20px;
|
||||||
|
$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.call-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10001;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-overlay {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.95) 0%, rgba(30, 41, 59, 0.98) 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.1) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 100px,
|
||||||
|
rgba(255, 255, 255, 0.01) 100px,
|
||||||
|
rgba(255, 255, 255, 0.01) 200px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-media-area {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&.audio-only {
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #1e293b 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradientShift 8s ease infinite;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 40%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 70% 60%, rgba(118, 75, 162, 0.2) 0%, transparent 50%);
|
||||||
|
animation: pulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: $text-dark;
|
||||||
|
filter: brightness(1.05) contrast(1.02);
|
||||||
|
transition: filter $transition-slow;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-video {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 120px;
|
||||||
|
right: 24px;
|
||||||
|
width: 240px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
background: $text-dark;
|
||||||
|
z-index: 10;
|
||||||
|
transition: all $transition-base;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: $shadow-2xl, 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-call-ui {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
color: $white;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 40px;
|
||||||
|
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-large {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow:
|
||||||
|
$shadow-2xl,
|
||||||
|
0 0 0 8px rgba(255, 255, 255, 0.05),
|
||||||
|
0 0 40px rgba(102, 126, 234, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-gradient;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
z-index: -1;
|
||||||
|
filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow:
|
||||||
|
$shadow-2xl,
|
||||||
|
0 0 0 8px rgba(255, 255, 255, 0.1),
|
||||||
|
0 0 60px rgba(102, 126, 234, 0.5);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image-large {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle-large {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 80px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $white;
|
||||||
|
background: $primary-gradient;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.2) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-user-name {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: $white;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0.9) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-status {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
text-transform: capitalize;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls {
|
||||||
|
padding: 32px 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.6) 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming-controls,
|
||||||
|
.outgoing-controls,
|
||||||
|
.active-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outgoing-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-status-text {
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: pulseText 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseText {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
min-width: 72px;
|
||||||
|
min-height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all $transition-base;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
filter: blur(12px);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: $shadow-2xl;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn-answer {
|
||||||
|
background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%);
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, $success-green-dark 0%, #047857 100%);
|
||||||
|
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.5);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn-reject,
|
||||||
|
.call-btn-end {
|
||||||
|
background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%);
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, $error-red-dark 0%, #b91c1c 100%);
|
||||||
|
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn-secondary {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 64px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: $white;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: scale(1.08);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation for incoming call
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow:
|
||||||
|
$shadow-2xl,
|
||||||
|
0 0 0 8px rgba(255, 255, 255, 0.05),
|
||||||
|
0 0 40px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
opacity: 0.95;
|
||||||
|
box-shadow:
|
||||||
|
$shadow-2xl,
|
||||||
|
0 0 0 12px rgba(255, 255, 255, 0.1),
|
||||||
|
0 0 60px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.isRinging {
|
||||||
|
.avatar-large {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn-answer {
|
||||||
|
animation: pulseButton 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseButton {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.6);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive Design
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.call-user-name {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-status {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-large {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle-large {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 64px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn-secondary {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
min-width: 56px;
|
||||||
|
min-height: 56px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-video {
|
||||||
|
width: 180px;
|
||||||
|
height: 135px;
|
||||||
|
bottom: 100px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls {
|
||||||
|
padding: 24px 20px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming-controls,
|
||||||
|
.outgoing-controls,
|
||||||
|
.active-controls {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.avatar-large {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle-large {
|
||||||
|
font-size: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-user-name {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
min-width: 56px;
|
||||||
|
min-height: 56px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-btn-secondary {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced focus states for accessibility
|
||||||
|
.call-btn:focus-visible {
|
||||||
|
outline: 3px solid rgba(255, 255, 255, 0.6);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth transitions
|
||||||
|
.call-container * {
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
.call-overlay,
|
||||||
|
.call-media-area,
|
||||||
|
.remote-video,
|
||||||
|
.local-video {
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
214
frontend/src/app/components/call/call.component.ts
Normal file
214
frontend/src/app/components/call/call.component.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CallService, CallInfo } from '../../services/call.service';
|
||||||
|
import { UserService } from '../../services/user.service';
|
||||||
|
import { LoggerService } from '../../services/logger.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-call',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './call.component.html',
|
||||||
|
styleUrl: './call.component.scss'
|
||||||
|
})
|
||||||
|
export class CallComponent implements OnInit, OnDestroy {
|
||||||
|
@ViewChild('localVideo') localVideoRef!: ElementRef<HTMLVideoElement>;
|
||||||
|
@ViewChild('remoteVideo') remoteVideoRef!: ElementRef<HTMLVideoElement>;
|
||||||
|
|
||||||
|
currentCall: CallInfo | null = null;
|
||||||
|
isRinging: boolean = false;
|
||||||
|
isCallActive: boolean = false;
|
||||||
|
isMuted: boolean = false;
|
||||||
|
isVideoEnabled: boolean = true;
|
||||||
|
isIncoming: boolean = false;
|
||||||
|
private subscriptions: Subscription[] = [];
|
||||||
|
private currentUser: any = null; // Cache current user to avoid repeated API calls
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public callService: CallService,
|
||||||
|
public userService: UserService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
// Cache current user on init to avoid repeated calls
|
||||||
|
this.currentUser = await this.userService.getCurrentUser();
|
||||||
|
|
||||||
|
const callSub = this.callService.call$.subscribe(callInfo => {
|
||||||
|
this.handleCallUpdate(callInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
const signalSub = this.callService.signal$.subscribe(signal => {
|
||||||
|
if (signal.signalData?.remoteStreamReady && this.remoteVideoRef) {
|
||||||
|
const remoteStream = this.callService.getRemoteStream();
|
||||||
|
if (remoteStream && this.remoteVideoRef.nativeElement) {
|
||||||
|
this.remoteVideoRef.nativeElement.srcObject = remoteStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.push(callSub, signalSub);
|
||||||
|
|
||||||
|
// Update video streams periodically
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
this.updateVideoStreams();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
this.subscriptions.push(new Subscription(() => clearInterval(interval)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCallUpdate(callInfo: CallInfo | null): Promise<void> {
|
||||||
|
this.logger.debug('Call component received call update:', callInfo);
|
||||||
|
|
||||||
|
// Handle null callInfo (when call is cleared)
|
||||||
|
if (!callInfo) {
|
||||||
|
this.logger.debug('Call info is null, clearing call state');
|
||||||
|
this.currentCall = null;
|
||||||
|
this.isRinging = false;
|
||||||
|
this.isCallActive = false;
|
||||||
|
if (this.localVideoRef?.nativeElement) {
|
||||||
|
this.localVideoRef.nativeElement.srcObject = null;
|
||||||
|
}
|
||||||
|
if (this.remoteVideoRef?.nativeElement) {
|
||||||
|
this.remoteVideoRef.nativeElement.srcObject = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentCall = callInfo;
|
||||||
|
|
||||||
|
// Ensure we have current user (use cache if available, fetch if not)
|
||||||
|
if (!this.currentUser) {
|
||||||
|
this.currentUser = await this.userService.getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is an incoming call (we are the receiver) or outgoing call (we are the sender)
|
||||||
|
if (this.currentUser) {
|
||||||
|
const isSender = callInfo.senderId === this.currentUser.id;
|
||||||
|
const isReceiver = callInfo.receiverId === this.currentUser.id;
|
||||||
|
|
||||||
|
// It's incoming if we are the receiver (not the sender) and the call is ringing
|
||||||
|
// It's outgoing if we are the sender (not the receiver) and the call is ringing
|
||||||
|
this.isIncoming = isReceiver && !isSender && callInfo.callStatus === 'ringing';
|
||||||
|
|
||||||
|
this.logger.debug('Current user ID:', this.currentUser.id,
|
||||||
|
'Sender ID:', callInfo.senderId,
|
||||||
|
'Receiver ID:', callInfo.receiverId,
|
||||||
|
'Is sender:', isSender,
|
||||||
|
'Is receiver:', isReceiver,
|
||||||
|
'Is incoming:', this.isIncoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callInfo.callStatus === 'ringing') {
|
||||||
|
this.logger.debug('Call is ringing - showing UI');
|
||||||
|
this.isRinging = true;
|
||||||
|
this.isCallActive = false;
|
||||||
|
// Update UI immediately
|
||||||
|
this.updateVideoStreams();
|
||||||
|
} else if (callInfo.callStatus === 'accepted') {
|
||||||
|
this.logger.debug('Call accepted - starting active call');
|
||||||
|
this.isRinging = false;
|
||||||
|
this.isCallActive = true;
|
||||||
|
setTimeout(() => this.updateVideoStreams(), 500);
|
||||||
|
} else {
|
||||||
|
this.isRinging = false;
|
||||||
|
this.isCallActive = false;
|
||||||
|
if (callInfo.callStatus === 'ended' || callInfo.callStatus === 'rejected' || callInfo.callStatus === 'cancelled') {
|
||||||
|
this.logger.debug('Call terminated:', callInfo.callStatus);
|
||||||
|
this.currentCall = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVideoStreams(): void {
|
||||||
|
if (this.localVideoRef?.nativeElement) {
|
||||||
|
const localStream = this.callService.getLocalStream();
|
||||||
|
if (localStream) {
|
||||||
|
this.localVideoRef.nativeElement.srcObject = localStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.remoteVideoRef?.nativeElement && this.isCallActive) {
|
||||||
|
const remoteStream = this.callService.getRemoteStream();
|
||||||
|
if (remoteStream) {
|
||||||
|
this.remoteVideoRef.nativeElement.srcObject = remoteStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async answerCall(): Promise<void> {
|
||||||
|
await this.callService.answerCall();
|
||||||
|
this.isRinging = false;
|
||||||
|
this.isCallActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectCall(): Promise<void> {
|
||||||
|
await this.callService.rejectCall();
|
||||||
|
this.isRinging = false;
|
||||||
|
this.currentCall = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async endCall(): Promise<void> {
|
||||||
|
// If call is still ringing and we're the caller, cancel the call
|
||||||
|
if (this.isRinging && !this.isIncoming && this.currentCall) {
|
||||||
|
await this.callService.cancelCall();
|
||||||
|
} else {
|
||||||
|
await this.callService.endCall();
|
||||||
|
}
|
||||||
|
this.isCallActive = false;
|
||||||
|
this.currentCall = null;
|
||||||
|
this.isRinging = false;
|
||||||
|
if (this.localVideoRef?.nativeElement) {
|
||||||
|
this.localVideoRef.nativeElement.srcObject = null;
|
||||||
|
}
|
||||||
|
if (this.remoteVideoRef?.nativeElement) {
|
||||||
|
this.remoteVideoRef.nativeElement.srcObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleMute(): Promise<void> {
|
||||||
|
this.isMuted = !await this.callService.toggleMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleVideo(): Promise<void> {
|
||||||
|
this.isVideoEnabled = !await this.callService.toggleVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
getOtherUserName(): string {
|
||||||
|
if (!this.currentCall) return '';
|
||||||
|
// Use cached currentUser if available, otherwise return based on call info
|
||||||
|
// If we're the sender (incoming=false), show receiver name
|
||||||
|
// If we're the receiver (incoming=true), show sender name
|
||||||
|
if (this.isIncoming && this.currentCall.senderName) {
|
||||||
|
return this.currentCall.senderName;
|
||||||
|
} else if (!this.isIncoming && this.currentCall.receiverName) {
|
||||||
|
return this.currentCall.receiverName;
|
||||||
|
}
|
||||||
|
return this.currentCall.senderName || this.currentCall.receiverName || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
getOtherUserAvatarUrl(): string | undefined {
|
||||||
|
if (!this.currentCall) return undefined;
|
||||||
|
// Return sender's avatar if we're the receiver (incoming call)
|
||||||
|
// Return receiver's avatar if we're the sender (outgoing call)
|
||||||
|
if (this.isIncoming) {
|
||||||
|
return this.currentCall.senderAvatarUrl;
|
||||||
|
}
|
||||||
|
// For outgoing calls, return the receiver's avatar
|
||||||
|
return this.currentCall.receiverAvatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVideoCall(): boolean {
|
||||||
|
return this.currentCall?.callType === 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShowCall(): boolean {
|
||||||
|
return this.currentCall !== null && (this.isRinging || this.isCallActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
317
frontend/src/app/components/chat/chat.component.html
Normal file
317
frontend/src/app/components/chat/chat.component.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<div class="chat-container">
|
||||||
|
<!-- Main Chat Layout -->
|
||||||
|
<div class="chat-layout">
|
||||||
|
<!-- Main Chat Area -->
|
||||||
|
<main class="chat-main" *ngIf="selectedConversation">
|
||||||
|
<!-- Chat Header -->
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="chat-header-info">
|
||||||
|
<div class="chat-user-avatar">
|
||||||
|
<img
|
||||||
|
*ngIf="selectedConversation.otherUserAvatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(selectedConversation.otherUserAvatarUrl)"
|
||||||
|
[alt]="selectedConversation.otherUserName"
|
||||||
|
(error)="onImageError($event)">
|
||||||
|
<div class="avatar-fallback"
|
||||||
|
[style.display]="!selectedConversation.otherUserAvatarUrl ? 'flex' : 'none'">
|
||||||
|
{{ selectedConversation.otherUserName.charAt(0) || 'U' }}
|
||||||
|
</div>
|
||||||
|
<div class="online-indicator"
|
||||||
|
*ngIf="selectedConversation.isOnline && selectedConversation.otherUserStatus === 'ONLINE'"
|
||||||
|
[title]="'Last seen ' + getLastSeenTime(selectedConversation.lastSeen)"></div>
|
||||||
|
<div class="status-badge"
|
||||||
|
*ngIf="selectedConversation.otherUserStatus === 'BUSY'"
|
||||||
|
title="Busy">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="status-badge offline-badge"
|
||||||
|
*ngIf="!selectedConversation.isOnline || selectedConversation.otherUserStatus === 'OFFLINE'"
|
||||||
|
title="Offline">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-user-info">
|
||||||
|
<div class="chat-user-name">{{ selectedConversation.otherUserName }}</div>
|
||||||
|
<div class="chat-user-status">
|
||||||
|
<span *ngIf="selectedConversation.isOnline && selectedConversation.otherUserStatus === 'ONLINE'">Online</span>
|
||||||
|
<span *ngIf="selectedConversation.otherUserStatus === 'BUSY'">Busy</span>
|
||||||
|
<span *ngIf="!selectedConversation.isOnline || selectedConversation.otherUserStatus === 'OFFLINE'">
|
||||||
|
Last seen {{ getLastSeenTime(selectedConversation.lastSeen) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-header-actions">
|
||||||
|
<!-- Status Badge Selector -->
|
||||||
|
<div class="status-selector">
|
||||||
|
<button
|
||||||
|
class="status-badge-button"
|
||||||
|
(click)="showStatusMenu = !showStatusMenu"
|
||||||
|
title="Change Status"
|
||||||
|
aria-label="Change Status">
|
||||||
|
<div class="status-badge-indicator"
|
||||||
|
[class.status-online]="currentUserStatus === 'ONLINE'"
|
||||||
|
[class.status-busy]="currentUserStatus === 'BUSY'"
|
||||||
|
[class.status-offline]="currentUserStatus === 'OFFLINE'">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="status-label">{{ getStatusLabel(currentUserStatus) }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="status-menu-dropdown" *ngIf="showStatusMenu" (click)="$event.stopPropagation()">
|
||||||
|
<button
|
||||||
|
class="status-menu-item"
|
||||||
|
[class.active]="currentUserStatus === 'ONLINE'"
|
||||||
|
(click)="setUserStatus('ONLINE')">
|
||||||
|
<div class="status-menu-indicator status-online">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>Online</span>
|
||||||
|
<svg *ngIf="currentUserStatus === 'ONLINE'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="status-menu-item"
|
||||||
|
[class.active]="currentUserStatus === 'BUSY'"
|
||||||
|
(click)="setUserStatus('BUSY')">
|
||||||
|
<div class="status-menu-indicator status-busy">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>Busy</span>
|
||||||
|
<svg *ngIf="currentUserStatus === 'BUSY'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="status-menu-item"
|
||||||
|
[class.active]="currentUserStatus === 'OFFLINE'"
|
||||||
|
(click)="setUserStatus('OFFLINE')">
|
||||||
|
<div class="status-menu-indicator status-offline">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>Offline</span>
|
||||||
|
<svg *ngIf="currentUserStatus === 'OFFLINE'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="icon-button call-button"
|
||||||
|
(click)="startAudioCall()"
|
||||||
|
title="Audio Call"
|
||||||
|
aria-label="Start Audio Call">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button call-button"
|
||||||
|
(click)="startVideoCall()"
|
||||||
|
title="Video Call"
|
||||||
|
aria-label="Start Video Call">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M23 7l-7 5 7 5V7z"></path>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="more-menu">
|
||||||
|
<button
|
||||||
|
class="icon-button more-button"
|
||||||
|
(click)="showMoreMenu = !showMoreMenu"
|
||||||
|
title="More Options"
|
||||||
|
aria-label="More Options">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="19" cy="12" r="1"></circle>
|
||||||
|
<circle cx="5" cy="12" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="more-menu-dropdown" *ngIf="showMoreMenu">
|
||||||
|
<button
|
||||||
|
class="more-menu-item"
|
||||||
|
(click)="blockUser(); showMoreMenu = false"
|
||||||
|
*ngIf="!isUserBlocked">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Block User</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="more-menu-item"
|
||||||
|
(click)="unblockSelectedUser(); showMoreMenu = false"
|
||||||
|
*ngIf="isUserBlocked">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Unblock User</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="more-menu-item danger"
|
||||||
|
(click)="deleteConversation(); showMoreMenu = false">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<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 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Delete Conversation</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Messages Area -->
|
||||||
|
<div class="messages-container" #messagesContainer>
|
||||||
|
<div class="messages-empty" *ngIf="messages.length === 0">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No messages yet</p>
|
||||||
|
<p class="empty-subtitle">Start the conversation by sending a message</p>
|
||||||
|
</div>
|
||||||
|
<div class="messages-list" *ngIf="messages.length > 0">
|
||||||
|
<div
|
||||||
|
class="message-wrapper"
|
||||||
|
*ngFor="let message of messages; trackBy: trackByMessageId"
|
||||||
|
[class.message-sent]="isMyMessage(message)"
|
||||||
|
[class.message-received]="!isMyMessage(message)">
|
||||||
|
<div class="message-bubble">
|
||||||
|
<div class="message-content">{{ message.content }}</div>
|
||||||
|
<div class="message-footer">
|
||||||
|
<span class="message-time">{{ getMessageTime(message.createdAt) }}</span>
|
||||||
|
<svg
|
||||||
|
*ngIf="isMyMessage(message)"
|
||||||
|
class="read-indicator"
|
||||||
|
[class.read]="message.isRead"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5" *ngIf="message.isRead"></path>
|
||||||
|
<path d="M9 12l2 2 4-4" *ngIf="!message.isRead"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="message-actions-button"
|
||||||
|
(click)="showMessageActions = message.id"
|
||||||
|
*ngIf="isMyMessage(message) && message.id"
|
||||||
|
title="Message Options">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="12" cy="5" r="1"></circle>
|
||||||
|
<circle cx="12" cy="19" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="message-actions-menu" *ngIf="showMessageActions === message.id">
|
||||||
|
<button
|
||||||
|
class="message-action-item danger"
|
||||||
|
(click)="deleteMessage(message); showMessageActions = null">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<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 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Typing Indicator -->
|
||||||
|
<div class="typing-indicator" *ngIf="isOtherUserTyping">
|
||||||
|
<div class="typing-dots">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<span class="typing-text">{{ typingUserName }} is typing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Input Area -->
|
||||||
|
<div class="message-input-area" *ngIf="!isUserBlocked">
|
||||||
|
<form class="message-form" (ngSubmit)="sendMessage()">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
class="message-input"
|
||||||
|
[(ngModel)]="newMessage"
|
||||||
|
(input)="onMessageInput()"
|
||||||
|
(keydown)="onMessageKeyDown($event)"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows="1"
|
||||||
|
[disabled]="!selectedConversation"
|
||||||
|
name="messageInput"></textarea>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="send-button"
|
||||||
|
[disabled]="!newMessage.trim() || !selectedConversation"
|
||||||
|
title="Send Message"
|
||||||
|
aria-label="Send Message">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-notice" *ngIf="isUserBlocked">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<p>You have blocked this user. Unblock them to send messages.</p>
|
||||||
|
<button class="unblock-action-button" (click)="unblockSelectedUser()">
|
||||||
|
Unblock User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Empty State - No Conversation Selected -->
|
||||||
|
<div class="chat-empty" *ngIf="!selectedConversation">
|
||||||
|
<div class="empty-content">
|
||||||
|
<svg width="96" height="96" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<h2>No conversation selected</h2>
|
||||||
|
<p>Start a conversation to begin messaging</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal-overlay" *ngIf="showDeleteModal" (click)="closeDeleteModal()">
|
||||||
|
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ getDeleteModalTitle() }}</h3>
|
||||||
|
<button class="modal-close" (click)="closeDeleteModal()">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{ getDeleteModalMessage() }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="modal-button secondary" (click)="closeDeleteModal()">Cancel</button>
|
||||||
|
<button class="modal-button primary danger" (click)="confirmDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
1184
frontend/src/app/components/chat/chat.component.scss
Normal file
1184
frontend/src/app/components/chat/chat.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
870
frontend/src/app/components/chat/chat.component.ts
Normal file
870
frontend/src/app/components/chat/chat.component.ts
Normal file
@@ -0,0 +1,870 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ChatService, ChatUser, Conversation, Message } from '../../services/chat.service';
|
||||||
|
import { NotificationService } from '../../services/notification.service';
|
||||||
|
import { UserService, UserInfo } from '../../services/user.service';
|
||||||
|
import { CallService } from '../../services/call.service';
|
||||||
|
import { LoggerService } from '../../services/logger.service';
|
||||||
|
import { CallComponent } from '../call/call.component';
|
||||||
|
import { Subscription, debounceTime, distinctUntilChanged, Subject } from 'rxjs';
|
||||||
|
import { ModalService } from '../../services/modal.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, CallComponent],
|
||||||
|
templateUrl: './chat.component.html',
|
||||||
|
styleUrl: './chat.component.scss'
|
||||||
|
})
|
||||||
|
export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked {
|
||||||
|
@ViewChild('messagesContainer') private messagesContainer!: ElementRef;
|
||||||
|
|
||||||
|
conversations: Conversation[] = [];
|
||||||
|
selectedConversation: Conversation | null = null;
|
||||||
|
messages: Message[] = [];
|
||||||
|
newMessage: string = '';
|
||||||
|
unreadCount: number = 0;
|
||||||
|
isOtherUserTyping: boolean = false;
|
||||||
|
typingUserName: string = '';
|
||||||
|
private subscriptions: Subscription[] = [];
|
||||||
|
private shouldScrollToBottom = false;
|
||||||
|
private typingTimeout: any = null;
|
||||||
|
private stopTypingTimeout: any = null;
|
||||||
|
private lastTypingNotification: number = 0;
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
showDeleteModal: boolean = false;
|
||||||
|
deleteModalType: 'message' | 'conversation' = 'conversation';
|
||||||
|
messageToDelete: Message | null = null;
|
||||||
|
|
||||||
|
currentUserId: string | null = null;
|
||||||
|
currentUser: UserInfo | null = null;
|
||||||
|
|
||||||
|
// User status
|
||||||
|
currentUserStatus: 'ONLINE' | 'OFFLINE' | 'BUSY' = 'ONLINE';
|
||||||
|
showStatusMenu: boolean = false;
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
showSearch: boolean = false;
|
||||||
|
searchQuery: string = '';
|
||||||
|
searchResults: ChatUser[] = [];
|
||||||
|
isSearching: boolean = false;
|
||||||
|
private searchSubject = new Subject<string>();
|
||||||
|
|
||||||
|
// Blocked users functionality
|
||||||
|
showBlockedUsers: boolean = false;
|
||||||
|
blockedUsers: ChatUser[] = [];
|
||||||
|
isLoadingBlockedUsers: boolean = false;
|
||||||
|
isUserBlocked: boolean = false;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
showMoreMenu: boolean = false;
|
||||||
|
showMessageActions: string | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private chatService: ChatService,
|
||||||
|
private notificationService: NotificationService,
|
||||||
|
public userService: UserService,
|
||||||
|
private callService: CallService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.currentUser = await this.userService.getCurrentUser();
|
||||||
|
if (this.currentUser) {
|
||||||
|
this.currentUserId = this.currentUser.id;
|
||||||
|
// Load current user status from backend or default to ONLINE
|
||||||
|
this.currentUserStatus = (this.currentUser.status as 'ONLINE' | 'OFFLINE' | 'BUSY') || 'ONLINE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close status menu when clicking outside
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (this.showStatusMenu && !target.closest('.status-selector')) {
|
||||||
|
this.showStatusMenu = false;
|
||||||
|
}
|
||||||
|
if (this.showMoreMenu && !target.closest('.more-menu')) {
|
||||||
|
this.showMoreMenu = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await this.chatService.connect();
|
||||||
|
|
||||||
|
// Setup search debounce
|
||||||
|
const searchSub = this.searchSubject.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged()
|
||||||
|
).subscribe(query => {
|
||||||
|
this.performSearch(query);
|
||||||
|
});
|
||||||
|
this.subscriptions.push(searchSub);
|
||||||
|
|
||||||
|
// Subscribe to new messages
|
||||||
|
const messageSub = this.chatService.messages$.subscribe((message: Message) => {
|
||||||
|
if (this.selectedConversation &&
|
||||||
|
(message.senderId === this.selectedConversation.otherUserId ||
|
||||||
|
message.receiverId === this.selectedConversation.otherUserId)) {
|
||||||
|
// Remove optimistic message if exists (same content)
|
||||||
|
const existingIndex = this.messages.findIndex(m =>
|
||||||
|
!m.id && m.content === message.content && m.senderId === this.currentUserId
|
||||||
|
);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
this.messages[existingIndex] = message;
|
||||||
|
} else {
|
||||||
|
this.messages.push(message);
|
||||||
|
}
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
}
|
||||||
|
this.refreshConversations();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to online status updates
|
||||||
|
const statusSub = this.chatService.onlineStatus$.subscribe(status => {
|
||||||
|
this.logger.debug('Received online status update:', status);
|
||||||
|
|
||||||
|
// Update current user's own status if it's their update
|
||||||
|
if (status.userId === this.currentUserId && status.status) {
|
||||||
|
this.currentUserStatus = status.status as 'ONLINE' | 'OFFLINE' | 'BUSY';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation status for other users
|
||||||
|
const conversation = this.conversations.find(c => c.otherUserId === status.userId);
|
||||||
|
if (conversation) {
|
||||||
|
conversation.isOnline = status.isOnline;
|
||||||
|
conversation.otherUserStatus = status.status as 'ONLINE' | 'OFFLINE' | 'BUSY';
|
||||||
|
conversation.lastSeen = status.lastSeen;
|
||||||
|
// Trigger change detection
|
||||||
|
this.conversations = [...this.conversations];
|
||||||
|
}
|
||||||
|
if (this.selectedConversation?.otherUserId === status.userId) {
|
||||||
|
this.selectedConversation.isOnline = status.isOnline;
|
||||||
|
this.selectedConversation.otherUserStatus = status.status as 'ONLINE' | 'OFFLINE' | 'BUSY';
|
||||||
|
this.selectedConversation.lastSeen = status.lastSeen;
|
||||||
|
// Trigger change detection for selected conversation
|
||||||
|
this.selectedConversation = { ...this.selectedConversation };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to conversations updates
|
||||||
|
const conversationsSub = this.chatService.conversations$.subscribe(conversations => {
|
||||||
|
// Deduplicate conversations by otherUserId to prevent duplicate entries
|
||||||
|
const uniqueConversations = conversations.reduce((acc, conversation) => {
|
||||||
|
const existingIndex = acc.findIndex(c => c.otherUserId === conversation.otherUserId);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
acc.push(conversation);
|
||||||
|
} else {
|
||||||
|
// If duplicate exists, keep the one with more recent lastMessage or higher unreadCount
|
||||||
|
const existing = acc[existingIndex];
|
||||||
|
if (conversation.lastMessage && (!existing.lastMessage ||
|
||||||
|
new Date(conversation.lastMessage.createdAt) > new Date(existing.lastMessage.createdAt))) {
|
||||||
|
acc[existingIndex] = conversation;
|
||||||
|
} else if (conversation.unreadCount > existing.unreadCount) {
|
||||||
|
acc[existingIndex] = conversation;
|
||||||
|
} else {
|
||||||
|
// Update status if the duplicate has a newer status
|
||||||
|
if (conversation.otherUserStatus && (!existing.otherUserStatus ||
|
||||||
|
conversation.isOnline !== existing.isOnline)) {
|
||||||
|
existing.otherUserStatus = conversation.otherUserStatus;
|
||||||
|
existing.isOnline = conversation.isOnline;
|
||||||
|
existing.lastSeen = conversation.lastSeen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as Conversation[]);
|
||||||
|
|
||||||
|
this.conversations = uniqueConversations;
|
||||||
|
this.unreadCount = uniqueConversations.reduce((sum, c) => sum + c.unreadCount, 0);
|
||||||
|
|
||||||
|
// Update current user status from conversation data if available
|
||||||
|
// This is a workaround - ideally we'd get it from user profile
|
||||||
|
// For now, we'll initialize from the first conversation that has status
|
||||||
|
if (uniqueConversations.length > 0 && !this.currentUserStatus) {
|
||||||
|
// Status will be initialized when user logs in
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to typing notifications
|
||||||
|
const typingSub = this.chatService.typing$.subscribe(typingData => {
|
||||||
|
if (this.selectedConversation &&
|
||||||
|
typingData.userId === this.selectedConversation.otherUserId) {
|
||||||
|
this.isOtherUserTyping = typingData.isTyping;
|
||||||
|
this.typingUserName = typingData.senderName || this.selectedConversation.otherUserName;
|
||||||
|
|
||||||
|
// Auto-hide typing indicator after 3 seconds if no new typing event
|
||||||
|
if (typingData.isTyping) {
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
}
|
||||||
|
// Scroll to bottom when typing indicator appears
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
// Also scroll immediately and after a small delay to ensure typing indicator is visible
|
||||||
|
setTimeout(() => this.scrollToBottom(), 100);
|
||||||
|
this.typingTimeout = setTimeout(() => {
|
||||||
|
this.isOtherUserTyping = false;
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
// Clear timeout if typing stops
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
this.typingTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.push(messageSub, statusSub, conversationsSub, typingSub);
|
||||||
|
this.loadConversations();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewChecked(): void {
|
||||||
|
if (this.shouldScrollToBottom) {
|
||||||
|
// Use setTimeout to ensure DOM is fully updated before scrolling
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
this.shouldScrollToBottom = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Stop typing indicator when component is destroyed
|
||||||
|
if (this.selectedConversation) {
|
||||||
|
this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear active conversation in notification service
|
||||||
|
this.notificationService.clearActiveConversation();
|
||||||
|
|
||||||
|
// Clear typing timeouts
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
this.typingTimeout = null;
|
||||||
|
}
|
||||||
|
if (this.stopTypingTimeout) {
|
||||||
|
clearTimeout(this.stopTypingTimeout);
|
||||||
|
this.stopTypingTimeout = null;
|
||||||
|
}
|
||||||
|
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||||
|
// Don't disconnect - keep connection alive while app is running
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConversations(): Promise<void> {
|
||||||
|
this.conversations = await this.chatService.getConversations();
|
||||||
|
this.unreadCount = this.conversations.reduce((sum, c) => sum + c.unreadCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectConversation(conversation: Conversation): Promise<void> {
|
||||||
|
// Stop any typing indicators from previous conversation
|
||||||
|
if (this.selectedConversation) {
|
||||||
|
this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedConversation = conversation;
|
||||||
|
|
||||||
|
// Set active conversation in notification service to prevent notifications when chat is open
|
||||||
|
if (conversation.otherUserId) {
|
||||||
|
this.notificationService.setActiveConversation(conversation.otherUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messages = await this.chatService.getConversation(conversation.otherUserId);
|
||||||
|
this.isOtherUserTyping = false; // Reset typing indicator
|
||||||
|
await this.chatService.markAsRead(conversation.otherUserId);
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
|
||||||
|
// Check if user is blocked
|
||||||
|
if (conversation.otherUserId) {
|
||||||
|
this.isUserBlocked = await this.chatService.isBlocked(conversation.otherUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshConversations();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(): Promise<void> {
|
||||||
|
if (!this.newMessage.trim() || !this.selectedConversation) return;
|
||||||
|
|
||||||
|
const content = this.newMessage.trim();
|
||||||
|
this.newMessage = '';
|
||||||
|
|
||||||
|
// Stop typing indicator
|
||||||
|
if (this.selectedConversation) {
|
||||||
|
this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear typing timeouts
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
this.typingTimeout = null;
|
||||||
|
}
|
||||||
|
if (this.stopTypingTimeout) {
|
||||||
|
clearTimeout(this.stopTypingTimeout);
|
||||||
|
this.stopTypingTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chatService.sendMessage(this.selectedConversation!.otherUserId, content);
|
||||||
|
|
||||||
|
// Optimistically add message to UI - will be replaced by server response
|
||||||
|
if (this.currentUserId) {
|
||||||
|
const optimisticMessage: Message = {
|
||||||
|
senderId: this.currentUserId,
|
||||||
|
senderName: 'You',
|
||||||
|
receiverId: this.selectedConversation!.otherUserId,
|
||||||
|
receiverName: this.selectedConversation!.otherUserName,
|
||||||
|
content,
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
this.messages.push(optimisticMessage);
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessageInput(): void {
|
||||||
|
if (!this.selectedConversation) return;
|
||||||
|
|
||||||
|
// Clear any existing stop typing timeout
|
||||||
|
if (this.stopTypingTimeout) {
|
||||||
|
clearTimeout(this.stopTypingTimeout);
|
||||||
|
this.stopTypingTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// Debounce typing notifications - send max once per second
|
||||||
|
if (now - this.lastTypingNotification > 1000) {
|
||||||
|
this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, true);
|
||||||
|
this.lastTypingNotification = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user stops typing for 2 seconds, send stop typing notification
|
||||||
|
this.stopTypingTimeout = setTimeout(() => {
|
||||||
|
if (this.selectedConversation) {
|
||||||
|
this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessageKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (!this.selectedConversation) return;
|
||||||
|
|
||||||
|
// When Enter is pressed without Shift, send message and prevent new line
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault(); // Prevent default behavior (new line)
|
||||||
|
// Stop typing indicator
|
||||||
|
this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false);
|
||||||
|
// Send message if there's content
|
||||||
|
if (this.newMessage.trim()) {
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shift+Enter will allow default behavior (new line)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshConversations(): Promise<void> {
|
||||||
|
await this.chatService.refreshConversations();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSeenTime(lastSeen?: string): string {
|
||||||
|
if (!lastSeen) return 'recently';
|
||||||
|
|
||||||
|
const lastSeenDate = new Date(lastSeen);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - lastSeenDate.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||||
|
|
||||||
|
return lastSeenDate.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageTime(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMyMessage(message: Message): boolean {
|
||||||
|
if (!this.currentUserId) return false;
|
||||||
|
return message.senderId === this.currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
try {
|
||||||
|
if (this.messagesContainer) {
|
||||||
|
this.messagesContainer.nativeElement.scrollTop =
|
||||||
|
this.messagesContainer.nativeElement.scrollHeight;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error scrolling to bottom:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getConversationPreview(conversation: Conversation): string {
|
||||||
|
if (!conversation.lastMessage) return 'No messages yet';
|
||||||
|
const preview = conversation.lastMessage.content.substring(0, 50);
|
||||||
|
return preview.length < conversation.lastMessage.content.length ? preview + '...' : preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSearch(): void {
|
||||||
|
this.showSearch = !this.showSearch;
|
||||||
|
if (this.showSearch) {
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = [];
|
||||||
|
this.loadAllUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchInput(): void {
|
||||||
|
this.searchSubject.next(this.searchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performSearch(query: string): Promise<void> {
|
||||||
|
if (!query.trim()) {
|
||||||
|
await this.loadAllUsers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSearching = true;
|
||||||
|
try {
|
||||||
|
if (this.currentUser?.role === 'PATIENT') {
|
||||||
|
this.searchResults = await this.chatService.searchDoctors(query);
|
||||||
|
} else if (this.currentUser?.role === 'DOCTOR') {
|
||||||
|
this.searchResults = await this.chatService.searchPatients(query);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Search error:', error);
|
||||||
|
this.searchResults = [];
|
||||||
|
} finally {
|
||||||
|
this.isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAllUsers(): Promise<void> {
|
||||||
|
this.isSearching = true;
|
||||||
|
try {
|
||||||
|
if (this.currentUser?.role === 'PATIENT') {
|
||||||
|
this.searchResults = await this.chatService.searchDoctors('');
|
||||||
|
} else if (this.currentUser?.role === 'DOCTOR') {
|
||||||
|
this.searchResults = await this.chatService.searchPatients('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error loading users:', error);
|
||||||
|
this.searchResults = [];
|
||||||
|
} finally {
|
||||||
|
this.isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startConversation(user: ChatUser): Promise<void> {
|
||||||
|
// Check if conversation already exists
|
||||||
|
const existing = this.conversations.find(c => c.otherUserId === user.userId);
|
||||||
|
if (existing) {
|
||||||
|
this.selectConversation(existing);
|
||||||
|
this.showSearch = false;
|
||||||
|
this.searchQuery = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new conversation
|
||||||
|
const conversation = await this.chatService.startConversationWithUser(user.userId);
|
||||||
|
if (conversation) {
|
||||||
|
// Add to conversations list
|
||||||
|
this.conversations.unshift(conversation);
|
||||||
|
this.selectConversation(conversation);
|
||||||
|
this.showSearch = false;
|
||||||
|
this.searchQuery = '';
|
||||||
|
// Load messages for this conversation
|
||||||
|
this.messages = await this.chatService.getConversation(user.userId);
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserFullName(user: ChatUser): string {
|
||||||
|
return `${user.firstName} ${user.lastName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByUserId(index: number, user: ChatUser): string {
|
||||||
|
return user.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByMessageId(index: number, message: Message): string {
|
||||||
|
return message.id || `${message.senderId}-${message.createdAt}-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Public method to open a conversation by userId (called from parent components)
|
||||||
|
async openConversation(userId: string): Promise<void> {
|
||||||
|
this.logger.debug('ChatComponent: Opening conversation for user:', userId);
|
||||||
|
|
||||||
|
// Check if conversation already exists
|
||||||
|
const existingConversation = this.conversations.find(c => c.otherUserId === userId);
|
||||||
|
if (existingConversation) {
|
||||||
|
await this.selectConversation(existingConversation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If conversation doesn't exist, try to start a new one
|
||||||
|
try {
|
||||||
|
const conversation = await this.chatService.startConversationWithUser(userId);
|
||||||
|
if (conversation) {
|
||||||
|
// Add to conversations if not already there
|
||||||
|
if (!this.conversations.find(c => c.otherUserId === userId)) {
|
||||||
|
this.conversations.unshift(conversation);
|
||||||
|
}
|
||||||
|
await this.selectConversation(conversation);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error opening conversation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageError(event: Event): void {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
if (img) {
|
||||||
|
// Hide the failed image
|
||||||
|
img.style.display = 'none';
|
||||||
|
// Show the avatar fallback (first letter) when image fails to load
|
||||||
|
const avatarContainer = img.parentElement;
|
||||||
|
if (avatarContainer) {
|
||||||
|
const fallback = avatarContainer.querySelector('.avatar-fallback') as HTMLElement;
|
||||||
|
if (fallback) {
|
||||||
|
fallback.style.display = 'flex';
|
||||||
|
// Remove the data-visible attribute to allow manual control
|
||||||
|
fallback.removeAttribute('data-visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMessage(message: Message): Promise<void> {
|
||||||
|
if (!message.id) {
|
||||||
|
this.logger.warn('Cannot delete message without ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messageToDelete = message;
|
||||||
|
this.deleteModalType = 'message';
|
||||||
|
this.showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConversation(): Promise<void> {
|
||||||
|
if (!this.selectedConversation) return;
|
||||||
|
|
||||||
|
this.deleteModalType = 'conversation';
|
||||||
|
this.showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmDelete(): Promise<void> {
|
||||||
|
if (this.deleteModalType === 'message' && this.messageToDelete?.id) {
|
||||||
|
try {
|
||||||
|
await this.chatService.deleteMessage(this.messageToDelete.id);
|
||||||
|
// Remove message from local list
|
||||||
|
this.messages = this.messages.filter(m => m.id !== this.messageToDelete?.id);
|
||||||
|
// Refresh conversations to update unread counts
|
||||||
|
await this.refreshConversations();
|
||||||
|
this.closeDeleteModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error deleting message:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.response?.data?.message || 'Failed to delete message. Please try again.',
|
||||||
|
'error',
|
||||||
|
'Delete Error'
|
||||||
|
);
|
||||||
|
this.closeDeleteModal();
|
||||||
|
}
|
||||||
|
} else if (this.deleteModalType === 'conversation' && this.selectedConversation) {
|
||||||
|
try {
|
||||||
|
// Delete conversation for current user only (backend handles this)
|
||||||
|
await this.chatService.deleteConversation(this.selectedConversation.otherUserId);
|
||||||
|
// Clear messages from current user's view
|
||||||
|
this.messages = [];
|
||||||
|
// Deselect conversation
|
||||||
|
this.selectedConversation = null;
|
||||||
|
// Clear active conversation in notification service
|
||||||
|
this.notificationService.clearActiveConversation();
|
||||||
|
// Refresh conversations list (conversation will be removed for current user only)
|
||||||
|
await this.refreshConversations();
|
||||||
|
this.closeDeleteModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error deleting conversation:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.response?.data?.message || 'Failed to delete conversation. Please try again.',
|
||||||
|
'error',
|
||||||
|
'Delete Error'
|
||||||
|
);
|
||||||
|
this.closeDeleteModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDeleteModal(): void {
|
||||||
|
this.showDeleteModal = false;
|
||||||
|
this.messageToDelete = null;
|
||||||
|
this.deleteModalType = 'conversation';
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeleteModalTitle(): string {
|
||||||
|
if (this.deleteModalType === 'message') {
|
||||||
|
return 'Delete Message';
|
||||||
|
}
|
||||||
|
return 'Delete All Messages';
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeleteModalMessage(): string {
|
||||||
|
if (this.deleteModalType === 'message') {
|
||||||
|
return 'Are you sure you want to delete this message? This action cannot be undone.';
|
||||||
|
}
|
||||||
|
if (this.selectedConversation) {
|
||||||
|
return `Are you sure you want to delete this conversation? This will remove all messages from your chat history. The other person will still be able to see their messages. This action cannot be undone.`;
|
||||||
|
}
|
||||||
|
return 'Are you sure you want to delete all messages?';
|
||||||
|
}
|
||||||
|
|
||||||
|
async startVideoCall(): Promise<void> {
|
||||||
|
if (!this.selectedConversation) {
|
||||||
|
await this.modalService.alert(
|
||||||
|
'Please select a conversation first',
|
||||||
|
'warning',
|
||||||
|
'No Conversation Selected'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the other user is offline
|
||||||
|
if (!this.selectedConversation.isOnline || this.selectedConversation.otherUserStatus === 'OFFLINE') {
|
||||||
|
await this.modalService.alert(
|
||||||
|
`${this.selectedConversation.otherUserName} is currently offline and cannot receive calls. Please try again later.`,
|
||||||
|
'warning',
|
||||||
|
'User Offline'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug('Starting video call to:', this.selectedConversation.otherUserId);
|
||||||
|
await this.callService.initiateCall(this.selectedConversation.otherUserId, 'video');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error starting video call:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.message || 'Failed to start video call. Please ensure you are connected and try again.',
|
||||||
|
'error',
|
||||||
|
'Call Error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startAudioCall(): Promise<void> {
|
||||||
|
if (!this.selectedConversation) {
|
||||||
|
await this.modalService.alert(
|
||||||
|
'Please select a conversation first',
|
||||||
|
'warning',
|
||||||
|
'No Conversation Selected'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the other user is offline
|
||||||
|
if (!this.selectedConversation.isOnline || this.selectedConversation.otherUserStatus === 'OFFLINE') {
|
||||||
|
await this.modalService.alert(
|
||||||
|
`${this.selectedConversation.otherUserName} is currently offline and cannot receive calls. Please try again later.`,
|
||||||
|
'warning',
|
||||||
|
'User Offline'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug('Starting audio call to:', this.selectedConversation.otherUserId);
|
||||||
|
await this.callService.initiateCall(this.selectedConversation.otherUserId, 'audio');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error starting audio call:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.message || 'Failed to start audio call. Please ensure you are connected and try again.',
|
||||||
|
'error',
|
||||||
|
'Call Error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setUserStatus(status: 'ONLINE' | 'OFFLINE' | 'BUSY'): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.chatService.updateUserStatus(status);
|
||||||
|
// Update local state immediately for responsive UI
|
||||||
|
this.currentUserStatus = status;
|
||||||
|
// Clear user cache to force reload of user profile with new status
|
||||||
|
this.userService.clearUserCache();
|
||||||
|
// Update current user object if available
|
||||||
|
if (this.currentUser) {
|
||||||
|
this.currentUser.status = status;
|
||||||
|
this.currentUser.isOnline = (status === 'ONLINE' || status === 'BUSY');
|
||||||
|
}
|
||||||
|
this.showStatusMenu = false;
|
||||||
|
this.logger.debug('User status updated to:', status);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error updating user status:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.message || 'Failed to update status. Please try again.',
|
||||||
|
'error',
|
||||||
|
'Update Error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status?: 'ONLINE' | 'OFFLINE' | 'BUSY'): string {
|
||||||
|
if (!status) {
|
||||||
|
return 'Offline';
|
||||||
|
}
|
||||||
|
switch (status) {
|
||||||
|
case 'ONLINE':
|
||||||
|
return 'Online';
|
||||||
|
case 'BUSY':
|
||||||
|
return 'Busy';
|
||||||
|
case 'OFFLINE':
|
||||||
|
default:
|
||||||
|
return 'Offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBlockedUsers(): void {
|
||||||
|
this.showBlockedUsers = !this.showBlockedUsers;
|
||||||
|
if (this.showBlockedUsers) {
|
||||||
|
this.loadBlockedUsers();
|
||||||
|
}
|
||||||
|
// Close search if open
|
||||||
|
if (this.showSearch) {
|
||||||
|
this.showSearch = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadBlockedUsers(): Promise<void> {
|
||||||
|
this.isLoadingBlockedUsers = true;
|
||||||
|
try {
|
||||||
|
this.blockedUsers = await this.chatService.getBlockedUsersWithDetails();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error loading blocked users:', error);
|
||||||
|
this.blockedUsers = [];
|
||||||
|
} finally {
|
||||||
|
this.isLoadingBlockedUsers = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async blockUser(): Promise<void> {
|
||||||
|
if (!this.selectedConversation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
`Are you sure you want to block ${this.selectedConversation.otherUserName}? You won't be able to send or receive messages from them.`,
|
||||||
|
'Block User',
|
||||||
|
'Block',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await this.chatService.blockUser(this.selectedConversation.otherUserId);
|
||||||
|
this.isUserBlocked = true;
|
||||||
|
|
||||||
|
// Refresh conversations and blocked users list
|
||||||
|
await this.refreshConversations();
|
||||||
|
if (this.showBlockedUsers) {
|
||||||
|
await this.loadBlockedUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
await this.modalService.alert(
|
||||||
|
`${this.selectedConversation.otherUserName} has been blocked successfully.`,
|
||||||
|
'success',
|
||||||
|
'User Blocked'
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error blocking user:', error);
|
||||||
|
let errorMessage = 'Failed to block user. Please try again.';
|
||||||
|
if (error?.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
} else if (error?.response?.data?.error) {
|
||||||
|
errorMessage = error.response.data.error;
|
||||||
|
} else if (error?.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.modalService.alert(errorMessage, 'error', 'Error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unblockUser(user: ChatUser): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Unblocking user:', user.userId, user);
|
||||||
|
// Ensure userId is a string and trim any whitespace
|
||||||
|
const userId = String(user.userId).trim();
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Invalid user ID');
|
||||||
|
}
|
||||||
|
await this.chatService.unblockUser(userId);
|
||||||
|
|
||||||
|
// Update isUserBlocked if this is the currently selected user
|
||||||
|
if (this.selectedConversation && this.selectedConversation.otherUserId === userId) {
|
||||||
|
this.isUserBlocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from blocked users list
|
||||||
|
this.blockedUsers = this.blockedUsers.filter(u => u.userId !== user.userId);
|
||||||
|
// Refresh conversations to show unblocked user
|
||||||
|
await this.refreshConversations();
|
||||||
|
// Show success message
|
||||||
|
const userName = user.firstName && user.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user.userId || 'User';
|
||||||
|
await this.modalService.alert(
|
||||||
|
`${userName} has been unblocked successfully.`,
|
||||||
|
'success',
|
||||||
|
'User Unblocked'
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error unblocking user:', error, {
|
||||||
|
message: error?.message,
|
||||||
|
response: error?.response?.data,
|
||||||
|
status: error?.response?.status
|
||||||
|
});
|
||||||
|
let errorMessage = 'Failed to unblock user. Please try again.';
|
||||||
|
if (error?.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
} else if (error?.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
await this.modalService.alert(
|
||||||
|
errorMessage,
|
||||||
|
'error',
|
||||||
|
'Unblock Error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unblockSelectedUser(): Promise<void> {
|
||||||
|
if (!this.selectedConversation) return;
|
||||||
|
|
||||||
|
const user: ChatUser = {
|
||||||
|
userId: this.selectedConversation.otherUserId,
|
||||||
|
firstName: this.selectedConversation.otherUserName.split(' ')[0] || '',
|
||||||
|
lastName: this.selectedConversation.otherUserName.split(' ').slice(1).join(' ') || '',
|
||||||
|
avatarUrl: this.selectedConversation.otherUserAvatarUrl || '',
|
||||||
|
isOnline: this.selectedConversation.isOnline || false,
|
||||||
|
status: this.selectedConversation.otherUserStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.unblockUser(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<div class="gdpr-consent-banner" *ngIf="showBanner">
|
||||||
|
<div class="banner-content">
|
||||||
|
<div class="banner-header">
|
||||||
|
<h3>Cookie & Privacy Consent</h3>
|
||||||
|
<p>We use cookies and process personal data to provide our services and comply with GDPR and HIPAA regulations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="consent-options" *ngIf="!showDetails">
|
||||||
|
<label class="consent-option">
|
||||||
|
<input type="checkbox" [(ngModel)]="consents.privacyPolicy" disabled checked>
|
||||||
|
<span>Privacy Policy (Required)</span>
|
||||||
|
</label>
|
||||||
|
<label class="consent-option">
|
||||||
|
<input type="checkbox" [(ngModel)]="consents.cookies">
|
||||||
|
<span>Cookies</span>
|
||||||
|
</label>
|
||||||
|
<label class="consent-option">
|
||||||
|
<input type="checkbox" [(ngModel)]="consents.dataProcessing" checked>
|
||||||
|
<span>Data Processing (Required)</span>
|
||||||
|
</label>
|
||||||
|
<label class="consent-option">
|
||||||
|
<input type="checkbox" [(ngModel)]="consents.analytics">
|
||||||
|
<span>Analytics</span>
|
||||||
|
</label>
|
||||||
|
<label class="consent-option">
|
||||||
|
<input type="checkbox" [(ngModel)]="consents.marketing">
|
||||||
|
<span>Marketing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn btn-secondary" (click)="toggleDetails()" [disabled]="isLoading">
|
||||||
|
Learn More
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" (click)="acceptSelected()" [disabled]="isLoading">
|
||||||
|
Accept Selected
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" (click)="acceptAll()" [disabled]="isLoading">
|
||||||
|
Accept All
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-link" (click)="rejectAll()" [disabled]="isLoading">
|
||||||
|
Reject All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.gdpr-consent-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 3px solid #007bff;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-options {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
.consent-option {
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gdpr-consent-banner {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { GdprService, ConsentType } from '../../services/gdpr.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { LoggerService } from '../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-gdpr-consent-banner',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './gdpr-consent-banner.component.html',
|
||||||
|
styleUrl: './gdpr-consent-banner.component.scss'
|
||||||
|
})
|
||||||
|
export class GdprConsentBannerComponent implements OnInit, OnDestroy {
|
||||||
|
showBanner = false;
|
||||||
|
isLoading = false;
|
||||||
|
showDetails = false;
|
||||||
|
|
||||||
|
consents = {
|
||||||
|
privacyPolicy: true, // Required
|
||||||
|
cookies: false,
|
||||||
|
dataProcessing: true, // Required
|
||||||
|
analytics: false,
|
||||||
|
marketing: false
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private gdprService: GdprService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.checkConsentStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
// Cleanup if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConsentStatus() {
|
||||||
|
const isAuthenticated = this.authService.isAuthenticated();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// For unauthenticated users, check if banner was shown
|
||||||
|
this.showBanner = this.gdprService.shouldShowConsentBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasRequiredConsents = await this.gdprService.hasRequiredConsents();
|
||||||
|
if (!hasRequiredConsents) {
|
||||||
|
this.showBanner = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error checking consent status:', error);
|
||||||
|
// Show banner if there's an error
|
||||||
|
this.showBanner = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptAll() {
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const isAuthenticated = this.authService.isAuthenticated();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Grant all consents
|
||||||
|
await Promise.all([
|
||||||
|
this.gdprService.grantConsent(ConsentType.PRIVACY_POLICY, '1.0'),
|
||||||
|
this.gdprService.grantConsent(ConsentType.COOKIES, '1.0'),
|
||||||
|
this.gdprService.grantConsent(ConsentType.DATA_PROCESSING, '1.0'),
|
||||||
|
this.gdprService.grantConsent(ConsentType.ANALYTICS, '1.0'),
|
||||||
|
this.gdprService.grantConsent(ConsentType.MARKETING, '1.0')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark banner as shown
|
||||||
|
this.gdprService.markConsentBannerShown();
|
||||||
|
this.showBanner = false;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error accepting consents:', error);
|
||||||
|
alert('Error accepting consents. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptSelected() {
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const isAuthenticated = this.authService.isAuthenticated();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
|
if (this.consents.privacyPolicy) {
|
||||||
|
promises.push(this.gdprService.grantConsent(ConsentType.PRIVACY_POLICY, '1.0'));
|
||||||
|
}
|
||||||
|
if (this.consents.cookies) {
|
||||||
|
promises.push(this.gdprService.grantConsent(ConsentType.COOKIES, '1.0'));
|
||||||
|
}
|
||||||
|
if (this.consents.dataProcessing) {
|
||||||
|
promises.push(this.gdprService.grantConsent(ConsentType.DATA_PROCESSING, '1.0'));
|
||||||
|
}
|
||||||
|
if (this.consents.analytics) {
|
||||||
|
promises.push(this.gdprService.grantConsent(ConsentType.ANALYTICS, '1.0'));
|
||||||
|
}
|
||||||
|
if (this.consents.marketing) {
|
||||||
|
promises.push(this.gdprService.grantConsent(ConsentType.MARKETING, '1.0'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark banner as shown
|
||||||
|
this.gdprService.markConsentBannerShown();
|
||||||
|
this.showBanner = false;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error accepting selected consents:', error);
|
||||||
|
alert('Error accepting consents. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAll() {
|
||||||
|
// Mark banner as shown even if rejecting
|
||||||
|
this.gdprService.markConsentBannerShown();
|
||||||
|
this.showBanner = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDetails() {
|
||||||
|
this.showDetails = !this.showDetails;
|
||||||
|
// Optionally navigate to privacy policy page
|
||||||
|
// window.location.href = '/privacy-policy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
43
frontend/src/app/components/modal/modal.component.html
Normal file
43
frontend/src/app/components/modal/modal.component.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<div class="modal-overlay" *ngIf="config" (click)="cancel()">
|
||||||
|
<div class="modal-container" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header" [class]="'modal-header-' + (config.type || 'info')">
|
||||||
|
<div class="modal-icon-wrapper">
|
||||||
|
<svg [attr.viewBox]="getIconViewBox()" fill="none" xmlns="http://www.w3.org/2000/svg" class="modal-icon">
|
||||||
|
<path *ngIf="config.type === 'success'" [attr.d]="getIconPath()" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle *ngIf="config.type !== 'success'" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path *ngIf="config.type !== 'success'" [attr.d]="getIconPath()" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="modal-title" *ngIf="config.title">{{ config.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="modal-message">{{ config.message }}</p>
|
||||||
|
<div *ngIf="config.type === 'prompt'" class="modal-input-group">
|
||||||
|
<label *ngIf="config.inputLabel" class="modal-input-label">{{ config.inputLabel }}</label>
|
||||||
|
<input
|
||||||
|
type="{{ config.inputType || 'text' }}"
|
||||||
|
[(ngModel)]="inputValue"
|
||||||
|
[placeholder]="config.inputPlaceholder || ''"
|
||||||
|
class="modal-input"
|
||||||
|
(keyup.enter)="confirm()"
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
*ngIf="config.showCancel"
|
||||||
|
class="modal-btn modal-btn-cancel"
|
||||||
|
(click)="cancel()">
|
||||||
|
{{ config.cancelText || 'Cancel' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-confirm"
|
||||||
|
[class]="'modal-btn-' + (config.type || 'info')"
|
||||||
|
[disabled]="config.type === 'prompt' && !inputValue"
|
||||||
|
(click)="confirm()">
|
||||||
|
{{ config.confirmText || 'OK' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
300
frontend/src/app/components/modal/modal.component.scss
Normal file
300
frontend/src/app/components/modal/modal.component.scss
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-info {
|
||||||
|
border-bottom-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-success {
|
||||||
|
border-bottom-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-warning {
|
||||||
|
border-bottom-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-error {
|
||||||
|
border-bottom-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-confirm {
|
||||||
|
border-bottom-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-prompt {
|
||||||
|
border-bottom-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-info .modal-icon {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-success .modal-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-warning .modal-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-error .modal-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-confirm .modal-icon {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-prompt .modal-icon {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #4b5563;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input-group {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #111827;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel:hover {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-confirm {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-confirm:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-info {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-info:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-success {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-success:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-warning {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-warning:hover {
|
||||||
|
background-color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-error {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-error:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-confirm {
|
||||||
|
background-color: #6366f1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-confirm:hover {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-confirm:disabled {
|
||||||
|
background-color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-confirm:disabled:hover {
|
||||||
|
background-color: #9ca3af;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-container {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 20px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 14px 20px;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
76
frontend/src/app/components/modal/modal.component.ts
Normal file
76
frontend/src/app/components/modal/modal.component.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ModalService, ModalConfig } from '../../services/modal.service';
|
||||||
|
import { LoggerService } from '../../services/logger.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './modal.component.html',
|
||||||
|
styleUrl: './modal.component.scss'
|
||||||
|
})
|
||||||
|
export class ModalComponent implements OnInit, OnDestroy {
|
||||||
|
config: ModalConfig | null = null;
|
||||||
|
inputValue: string = '';
|
||||||
|
private subscription?: Subscription;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.subscription = this.modalService.getModal().subscribe(config => {
|
||||||
|
this.logger.debug('ModalComponent: Received config:', config);
|
||||||
|
this.config = config;
|
||||||
|
if (config && config.type === 'prompt') {
|
||||||
|
this.inputValue = config.inputValue || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.subscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
this.logger.debug('ModalComponent: confirm() called, config:', this.config);
|
||||||
|
if (this.config) {
|
||||||
|
if (this.config.type === 'prompt') {
|
||||||
|
this.modalService.closeModal({ confirmed: true, inputValue: this.inputValue });
|
||||||
|
} else {
|
||||||
|
this.modalService.closeModal({ confirmed: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.logger.debug('ModalComponent: cancel() called');
|
||||||
|
this.modalService.closeModal({ confirmed: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconPath(): string {
|
||||||
|
if (!this.config) return '';
|
||||||
|
switch (this.config.type) {
|
||||||
|
case 'error':
|
||||||
|
return 'M12 9V11M12 15H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z';
|
||||||
|
case 'warning':
|
||||||
|
return 'M12 9V11M12 15H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z';
|
||||||
|
case 'success':
|
||||||
|
return 'M20 6L9 17L4 12';
|
||||||
|
case 'confirm':
|
||||||
|
return 'M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z';
|
||||||
|
default:
|
||||||
|
return 'M13 16H12V12H11M12 8H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconViewBox(): string {
|
||||||
|
if (!this.config) return '0 0 24 24';
|
||||||
|
return this.config.type === 'success' ? '0 0 24 24' : '0 0 24 24';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
41
frontend/src/app/config/api.config.ts
Normal file
41
frontend/src/app/config/api.config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// API Configuration
|
||||||
|
// Use localhost when accessing from the same machine
|
||||||
|
// Use the server's IP when accessing from other devices on the network
|
||||||
|
export const API_CONFIG = {
|
||||||
|
// Automatically detect if we're on localhost or network access
|
||||||
|
// If accessed via IP address, use that IP for backend too
|
||||||
|
// For localhost, always use HTTP to avoid SSL certificate issues with self-signed certs
|
||||||
|
getBaseUrl(): string {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
// Always use HTTP for localhost to avoid SSL certificate validation issues
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||||
|
return 'http://localhost:8080';
|
||||||
|
}
|
||||||
|
// For network access, use same protocol as frontend
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
return `${protocol}//${hostname}:8080`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getWsUrl(): string {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
// SockJS uses HTTP/HTTPS, not WS/WSS directly
|
||||||
|
// It will upgrade to WebSocket automatically
|
||||||
|
// Always use HTTP for localhost to avoid SSL certificate validation issues
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||||
|
return 'http://localhost:8080/ws';
|
||||||
|
}
|
||||||
|
// For network access, use same protocol as frontend
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
return `${protocol}//${hostname}:8080/ws`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export functions that compute URLs dynamically at call time
|
||||||
|
// This ensures the URL is always computed based on current window.location
|
||||||
|
export const getBaseUrl = () => API_CONFIG.getBaseUrl();
|
||||||
|
export const getWsUrl = () => API_CONFIG.getWsUrl();
|
||||||
|
|
||||||
|
// For backward compatibility, export as constants (but they'll be computed at runtime)
|
||||||
|
export const BASE_URL = getBaseUrl();
|
||||||
|
export const WS_URL = getWsUrl();
|
||||||
|
|
||||||
10
frontend/src/app/guards/auth.guard.ts
Normal file
10
frontend/src/app/guards/auth.guard.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = () => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const token = localStorage.getItem('jwtToken');
|
||||||
|
if (token) return true;
|
||||||
|
router.navigateByUrl('/login');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
1011
frontend/src/app/pages/admin/admin.component.html
Normal file
1011
frontend/src/app/pages/admin/admin.component.html
Normal file
File diff suppressed because it is too large
Load Diff
1587
frontend/src/app/pages/admin/admin.component.scss
Normal file
1587
frontend/src/app/pages/admin/admin.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/src/app/pages/admin/admin.component.spec.ts
Normal file
23
frontend/src/app/pages/admin/admin.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AdminComponent } from './admin.component';
|
||||||
|
|
||||||
|
describe('AdminComponent', () => {
|
||||||
|
let component: AdminComponent;
|
||||||
|
let fixture: ComponentFixture<AdminComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AdminComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AdminComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
348
frontend/src/app/pages/admin/admin.component.ts
Normal file
348
frontend/src/app/pages/admin/admin.component.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { AdminService } from '../../services/admin.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { AppointmentService, Appointment } from '../../services/appointment.service';
|
||||||
|
import { UserService, UserInfo, UserUpdateRequest } from '../../services/user.service';
|
||||||
|
import { HipaaAuditService, HipaaAuditLog, PhiAccessLog, BreachNotification } from '../../services/hipaa-audit.service';
|
||||||
|
import { ModalService } from '../../services/modal.service';
|
||||||
|
import { LoggerService } from '../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './admin.component.html',
|
||||||
|
styleUrl: './admin.component.scss'
|
||||||
|
})
|
||||||
|
export class AdminComponent implements OnInit {
|
||||||
|
stats: any = null;
|
||||||
|
metrics: any = null;
|
||||||
|
users: any[] = [];
|
||||||
|
doctors: any[] = [];
|
||||||
|
patients: any[] = [];
|
||||||
|
appointments: Appointment[] = [];
|
||||||
|
loading = false;
|
||||||
|
error: string | null = null;
|
||||||
|
activeTab: string = 'overview';
|
||||||
|
currentUser: UserInfo | null = null;
|
||||||
|
showEditProfile = false;
|
||||||
|
editUserData: UserUpdateRequest = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
phoneNumber: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// HIPAA Audit data
|
||||||
|
selectedPatientIdForAudit: string | null = null;
|
||||||
|
selectedUserIdForAudit: string | null = null;
|
||||||
|
hipaaAuditLogs: HipaaAuditLog[] = [];
|
||||||
|
phiAccessLogs: PhiAccessLog[] = [];
|
||||||
|
breachNotifications: BreachNotification[] = [];
|
||||||
|
showCreateBreachNotification = false;
|
||||||
|
newBreachNotification = {
|
||||||
|
breachType: 'UNAUTHORIZED_ACCESS' as 'UNAUTHORIZED_ACCESS' | 'DISCLOSURE' | 'LOSS' | 'THEFT',
|
||||||
|
description: '',
|
||||||
|
affectedPatientsCount: 0,
|
||||||
|
mitigationSteps: '',
|
||||||
|
incidentDate: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
|
discoveryDate: new Date().toISOString().split('T')[0] // Today's date in YYYY-MM-DD format
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private admin: AdminService,
|
||||||
|
private auth: AuthService,
|
||||||
|
private router: Router,
|
||||||
|
private appointmentService: AppointmentService,
|
||||||
|
private userService: UserService,
|
||||||
|
private hipaaAuditService: HipaaAuditService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasObjectKeys(obj: any): boolean {
|
||||||
|
return obj && typeof obj === 'object' && Object.keys(obj).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
this.currentUser = await this.userService.getCurrentUserProfile();
|
||||||
|
const [stats, metrics, users, doctors, patients] = await Promise.all([
|
||||||
|
this.admin.getStats(),
|
||||||
|
this.admin.getMetrics(),
|
||||||
|
this.admin.getUsers(),
|
||||||
|
this.admin.getDoctors(),
|
||||||
|
this.admin.getPatients()
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.stats = stats;
|
||||||
|
this.metrics = metrics;
|
||||||
|
this.users = users;
|
||||||
|
this.doctors = doctors;
|
||||||
|
this.patients = patients;
|
||||||
|
if (this.activeTab === 'appointments') {
|
||||||
|
await this.loadAppointments();
|
||||||
|
}
|
||||||
|
if (this.activeTab === 'hipaa-audit') {
|
||||||
|
await this.loadBreachNotifications();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.status === 401) {
|
||||||
|
await this.auth.logout();
|
||||||
|
this.router.navigateByUrl('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to load admin data';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleUserStatus(user: any) {
|
||||||
|
try {
|
||||||
|
if (user.isActive) {
|
||||||
|
await this.admin.deactivateUser(user.email);
|
||||||
|
} else {
|
||||||
|
await this.admin.activateUser(user.email);
|
||||||
|
}
|
||||||
|
await this.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to update user status';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(user: any) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
`Are you sure you want to delete user ${user.email}? This action cannot be undone.`,
|
||||||
|
'Delete User',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.admin.deleteUser(user.email);
|
||||||
|
await this.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to delete user';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyDoctor(doctor: any) {
|
||||||
|
if (!doctor.medicalLicenseNumber) {
|
||||||
|
this.error = 'Medical license number not available for this doctor';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
`Are you sure you want to verify Dr. ${doctor.firstName} ${doctor.lastName}?\n\nMedical License: ${doctor.medicalLicenseNumber}`,
|
||||||
|
'Verify Doctor',
|
||||||
|
'Verify',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.admin.verifyDoctor(doctor.medicalLicenseNumber);
|
||||||
|
await this.refresh();
|
||||||
|
this.error = null;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to verify doctor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unverifyDoctor(doctor: any) {
|
||||||
|
if (!doctor.medicalLicenseNumber) {
|
||||||
|
this.error = 'Medical license number not available for this doctor';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
`Are you sure you want to unverify Dr. ${doctor.firstName} ${doctor.lastName}?\n\nMedical License: ${doctor.medicalLicenseNumber}\n\nThis will remove the verified status from this doctor.`,
|
||||||
|
'Unverify Doctor',
|
||||||
|
'Unverify',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.admin.unverifyDoctor(doctor.medicalLicenseNumber);
|
||||||
|
await this.refresh();
|
||||||
|
this.error = null;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to unverify doctor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAppointment(appointment: Appointment) {
|
||||||
|
if (!appointment.id) {
|
||||||
|
this.error = 'Appointment ID not available';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this appointment? This action cannot be undone.',
|
||||||
|
'Delete Appointment',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.admin.deleteAppointment(appointment.id);
|
||||||
|
await this.loadAppointments();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to delete appointment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAppointments() {
|
||||||
|
try {
|
||||||
|
this.appointments = await this.admin.getAllAppointments();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Failed to load appointments:', e);
|
||||||
|
this.appointments = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab(tab: string) {
|
||||||
|
this.activeTab = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await this.auth.logout();
|
||||||
|
this.router.navigateByUrl('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateString: string): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserProfile() {
|
||||||
|
try {
|
||||||
|
await this.userService.updateUserProfile(this.editUserData);
|
||||||
|
await this.refresh();
|
||||||
|
this.showEditProfile = false;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to update profile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editProfile() {
|
||||||
|
// Ensure currentUser is loaded
|
||||||
|
if (!this.currentUser) {
|
||||||
|
this.logger.error('Current user not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.editUserData = {
|
||||||
|
firstName: this.currentUser.firstName || '',
|
||||||
|
lastName: this.currentUser.lastName || '',
|
||||||
|
phoneNumber: this.currentUser.phoneNumber || ''
|
||||||
|
};
|
||||||
|
this.showEditProfile = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HIPAA Audit methods
|
||||||
|
async loadHipaaAuditLogsByPatient(patientId: string) {
|
||||||
|
try {
|
||||||
|
this.hipaaAuditLogs = await this.hipaaAuditService.getHipaaAuditLogsByPatientId(patientId);
|
||||||
|
this.selectedPatientIdForAudit = patientId;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading HIPAA audit logs:', e);
|
||||||
|
this.hipaaAuditLogs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadHipaaAuditLogsByUser(userId: string) {
|
||||||
|
try {
|
||||||
|
this.hipaaAuditLogs = await this.hipaaAuditService.getHipaaAuditLogsByUserId(userId);
|
||||||
|
this.selectedUserIdForAudit = userId;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading HIPAA audit logs:', e);
|
||||||
|
this.hipaaAuditLogs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPhiAccessLogsByPatient(patientId: string) {
|
||||||
|
try {
|
||||||
|
this.phiAccessLogs = await this.hipaaAuditService.getPhiAccessLogsByPatientId(patientId);
|
||||||
|
this.selectedPatientIdForAudit = patientId;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading PHI access logs:', e);
|
||||||
|
this.phiAccessLogs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadBreachNotifications() {
|
||||||
|
try {
|
||||||
|
this.breachNotifications = await this.hipaaAuditService.getBreachNotifications();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading breach notifications:', e);
|
||||||
|
this.breachNotifications = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBreachNotification() {
|
||||||
|
// Ensure dates are set
|
||||||
|
if (!this.newBreachNotification.incidentDate) {
|
||||||
|
this.newBreachNotification.incidentDate = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
if (!this.newBreachNotification.discoveryDate) {
|
||||||
|
this.newBreachNotification.discoveryDate = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.newBreachNotification.description || !this.newBreachNotification.mitigationSteps ||
|
||||||
|
!this.newBreachNotification.incidentDate || !this.newBreachNotification.discoveryDate) {
|
||||||
|
this.error = 'Please fill in all required fields';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug('Creating breach notification with data:', this.newBreachNotification);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.hipaaAuditService.createBreachNotification(this.newBreachNotification);
|
||||||
|
this.showCreateBreachNotification = false;
|
||||||
|
this.newBreachNotification = {
|
||||||
|
breachType: 'UNAUTHORIZED_ACCESS',
|
||||||
|
description: '',
|
||||||
|
affectedPatientsCount: 0,
|
||||||
|
mitigationSteps: '',
|
||||||
|
incidentDate: new Date().toISOString().split('T')[0],
|
||||||
|
discoveryDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
await this.loadBreachNotifications();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error creating breach notification:', e);
|
||||||
|
if (e?.response?.status === 401 || e?.message?.includes('Authentication required')) {
|
||||||
|
// Check if token exists
|
||||||
|
const token = localStorage.getItem('jwtToken');
|
||||||
|
if (!token || token === 'null' || token === 'undefined') {
|
||||||
|
this.error = 'You are not logged in. Please log in to continue.';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.auth.logout();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Token exists but is invalid/expired - give user option to retry or login
|
||||||
|
this.error = 'Your session may have expired. Please try refreshing the page or logging in again.';
|
||||||
|
// Don't auto-logout - let user decide
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.error = e?.response?.data?.error || e?.response?.data?.message || e?.message || 'Failed to create breach notification';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<section class="appointments-section">
|
||||||
|
<h2 class="section-title">My Appointments</h2>
|
||||||
|
|
||||||
|
<!-- Upcoming Appointments -->
|
||||||
|
<div class="appointment-group" *ngIf="getUpcomingAppointments().length > 0">
|
||||||
|
<h3 class="group-title">Upcoming</h3>
|
||||||
|
<div class="appointments-grid">
|
||||||
|
<div class="appointment-card" *ngFor="let apt of getUpcomingAppointments()">
|
||||||
|
<div class="appointment-header">
|
||||||
|
<div class="appointment-info">
|
||||||
|
<h4 class="patient-name">{{ apt.patientFirstName }} {{ apt.patientLastName }}</h4>
|
||||||
|
<p class="appointment-date">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ formatDate(apt.scheduledDate) }} at {{ formatTime(apt.scheduledTime) }}
|
||||||
|
</p>
|
||||||
|
<p class="appointment-duration">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ apt.durationInMinutes }} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" [ngClass]="getStatusClass(apt.status)">
|
||||||
|
{{ apt.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="appointment-actions" *ngIf="apt.status === 'SCHEDULED'">
|
||||||
|
<button class="action-btn action-btn-confirm" (click)="confirmAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-cancel" (click)="cancelAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-delete" (click)="deleteAppointment(apt)" [disabled]="!apt.id" title="Delete Appointment">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="appointment-actions" *ngIf="apt.status === 'CONFIRMED'">
|
||||||
|
<button class="action-btn action-btn-complete" (click)="completeAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
Mark Complete
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-cancel" (click)="cancelAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-delete" (click)="deleteAppointment(apt)" [disabled]="!apt.id" title="Delete Appointment">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Past Appointments -->
|
||||||
|
<div class="appointment-group" *ngIf="getPastAppointments().length > 0">
|
||||||
|
<h3 class="group-title">Past</h3>
|
||||||
|
<div class="appointments-grid">
|
||||||
|
<div class="appointment-card" *ngFor="let apt of getPastAppointments()">
|
||||||
|
<div class="appointment-header">
|
||||||
|
<div class="appointment-info">
|
||||||
|
<h4 class="patient-name">{{ apt.patientFirstName }} {{ apt.patientLastName }}</h4>
|
||||||
|
<p class="appointment-date">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ formatDate(apt.scheduledDate) }} at {{ formatTime(apt.scheduledTime) }}
|
||||||
|
</p>
|
||||||
|
<p class="appointment-duration">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ apt.durationInMinutes }} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" [ngClass]="getStatusClass(apt.status)">
|
||||||
|
{{ apt.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="appointment-actions">
|
||||||
|
<button class="action-btn action-btn-delete" (click)="deleteAppointment(apt)" [disabled]="!apt.id" title="Delete Appointment">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="empty-state" *ngIf="appointments.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Appointments</h3>
|
||||||
|
<p>You don't have any appointments scheduled yet.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Enterprise Appointments Component Styles
|
||||||
|
// Modern, Professional, and Responsive Styling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Root & Variables
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
:host,
|
||||||
|
app-appointments {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Section Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointments-section {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Title
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-3xl, 1.875rem);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-2xl, 3rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
margin-bottom: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Appointment Group
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointment-group {
|
||||||
|
margin-bottom: var(--space-3xl, 4rem);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0 0 var(--space-lg, 1.5rem) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(180deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Appointments Grid
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointments-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Appointment Card
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointment-card {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
padding: var(--space-lg, 1.5rem);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06));
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05));
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Appointment Header
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Name
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-name {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-sm, 0.5rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Appointment Date & Duration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointment-date,
|
||||||
|
.appointment-duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0 0 var(--space-xs, 0.25rem) 0;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-duration {
|
||||||
|
color: var(--text-tertiary, #6b7280);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-accent, #8b5cf6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Badge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
// Status-specific styles
|
||||||
|
&.status-scheduled {
|
||||||
|
background: var(--color-info-light, #dbeafe);
|
||||||
|
color: var(--color-info, #3b82f6);
|
||||||
|
border: 1px solid var(--color-info, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-confirmed {
|
||||||
|
background: var(--color-success-light, #d1fae5);
|
||||||
|
color: var(--color-success, #10b981);
|
||||||
|
border: 1px solid var(--color-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-completed {
|
||||||
|
background: var(--color-gray-100, #f3f4f6);
|
||||||
|
color: var(--color-gray-700, #374151);
|
||||||
|
border: 1px solid var(--color-gray-300, #d1d5db);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-cancelled {
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
border: 1px solid var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Appointment Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
margin-top: var(--space-md, 1rem);
|
||||||
|
padding-top: var(--space-md, 1rem);
|
||||||
|
border-top: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Action Buttons
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm Button
|
||||||
|
&.action-btn-confirm {
|
||||||
|
background: linear-gradient(135deg, var(--color-success, #10b981) 0%, var(--color-secondary-dark, #059669) 100%);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--color-secondary-dark, #059669) 0%, var(--color-success, #10b981) 100%);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete Button
|
||||||
|
&.action-btn-complete {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary-dark, #1e40af) 0%, var(--color-primary, #2563eb) 100%);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel Button
|
||||||
|
&.action-btn-cancel {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
border: 2px solid var(--color-danger, #ef4444);
|
||||||
|
box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Empty State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl, 4rem) var(--space-xl, 2rem);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
border: 2px dashed var(--color-gray-300, #d1d5db);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
color: var(--color-gray-400, #9ca3af);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-sm, 0.5rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading State (if needed)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointments-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl, 4rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error State (if needed)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.appointment-error {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
border: 1px solid var(--color-danger, #ef4444);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '⚠';
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responsive Enhancements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.appointment-card {
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-md, 1rem);
|
||||||
|
right: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-info {
|
||||||
|
padding-right: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Print Styles (Optional)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.appointments-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-card {
|
||||||
|
break-inside: avoid;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--color-gray-300, #d1d5db);
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AppointmentService, Appointment } from '../../../../services/appointment.service';
|
||||||
|
import { ModalService } from '../../../../services/modal.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-appointments',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './appointments.component.html',
|
||||||
|
styleUrl: './appointments.component.scss'
|
||||||
|
})
|
||||||
|
export class AppointmentsComponent implements OnInit {
|
||||||
|
@Input() appointments: Appointment[] = [];
|
||||||
|
@Input() error: string | null = null;
|
||||||
|
@Output() refreshRequested = new EventEmitter<void>();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private appointmentService: AppointmentService,
|
||||||
|
private modalService: ModalService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {}
|
||||||
|
|
||||||
|
formatDate(dateString: string): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(timeString: string): string {
|
||||||
|
if (!timeString) return 'N/A';
|
||||||
|
return timeString.substring(0, 5); // Format HH:MM
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusClass(status: string): string {
|
||||||
|
const statusLower = status?.toLowerCase() || '';
|
||||||
|
if (statusLower === 'completed') return 'status-completed';
|
||||||
|
if (statusLower === 'confirmed') return 'status-confirmed';
|
||||||
|
if (statusLower === 'cancelled') return 'status-cancelled';
|
||||||
|
return 'status-scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
|
getUpcomingAppointments(): Appointment[] {
|
||||||
|
const now = new Date();
|
||||||
|
return this.appointments
|
||||||
|
.filter(apt => {
|
||||||
|
const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`);
|
||||||
|
return aptDate >= now && apt.status !== 'CANCELLED' && apt.status !== 'COMPLETED';
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`);
|
||||||
|
const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`);
|
||||||
|
return dateA.getTime() - dateB.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPastAppointments(): Appointment[] {
|
||||||
|
const now = new Date();
|
||||||
|
return this.appointments
|
||||||
|
.filter(apt => {
|
||||||
|
const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`);
|
||||||
|
return aptDate < now || apt.status === 'CANCELLED' || apt.status === 'COMPLETED';
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`);
|
||||||
|
const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`);
|
||||||
|
return dateB.getTime() - dateA.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmAppointment(appointment: Appointment) {
|
||||||
|
if (!appointment.id) {
|
||||||
|
this.error = 'Appointment ID not available. Backend needs to include ID in response.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.appointmentService.confirmAppointment(appointment.id);
|
||||||
|
this.refreshRequested.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to confirm appointment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelAppointment(appointment: Appointment) {
|
||||||
|
if (!appointment.id) {
|
||||||
|
this.error = 'Appointment ID not available. Backend needs to include ID in response.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to cancel this appointment?',
|
||||||
|
'Cancel Appointment',
|
||||||
|
'Cancel Appointment',
|
||||||
|
'Keep Appointment'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.appointmentService.cancelAppointment(appointment.id);
|
||||||
|
this.refreshRequested.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to cancel appointment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeAppointment(appointment: Appointment) {
|
||||||
|
if (!appointment.id) {
|
||||||
|
this.error = 'Appointment ID not available. Backend needs to include ID in response.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.appointmentService.completeAppointment(appointment.id);
|
||||||
|
this.refreshRequested.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to complete appointment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAppointment(appointment: Appointment) {
|
||||||
|
if (!appointment.id) {
|
||||||
|
this.error = 'Appointment ID not available. Backend needs to include ID in response.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this appointment? This action cannot be undone.',
|
||||||
|
'Delete Appointment',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.appointmentService.deleteAppointment(appointment.id);
|
||||||
|
this.refreshRequested.emit();
|
||||||
|
this.error = null;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || e?.message || 'Failed to delete appointment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<section class="availability-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">My Availability</h2>
|
||||||
|
<div class="section-controls">
|
||||||
|
<div class="filter-controls">
|
||||||
|
<select class="filter-select" [(ngModel)]="dayFilter" (change)="onDayFilterChange()">
|
||||||
|
<option value="all">All Days</option>
|
||||||
|
<option value="MONDAY">Monday</option>
|
||||||
|
<option value="TUESDAY">Tuesday</option>
|
||||||
|
<option value="WEDNESDAY">Wednesday</option>
|
||||||
|
<option value="THURSDAY">Thursday</option>
|
||||||
|
<option value="FRIDAY">Friday</option>
|
||||||
|
<option value="SATURDAY">Saturday</option>
|
||||||
|
<option value="SUNDAY">Sunday</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons-group">
|
||||||
|
<button class="request-btn" (click)="showAvailabilityForm = !showAvailabilityForm" *ngIf="!showAvailabilityForm && !showBulkAvailabilityForm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Add Availability Slot
|
||||||
|
</button>
|
||||||
|
<button class="request-btn" (click)="showBulkAvailabilityForm = !showBulkAvailabilityForm" *ngIf="!showAvailabilityForm && !showBulkAvailabilityForm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Bulk Add Availability
|
||||||
|
</button>
|
||||||
|
<button class="request-btn request-btn-danger" (click)="deleteAllAvailability()" *ngIf="availability.length > 0">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Delete All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Availability Form -->
|
||||||
|
<div class="request-form-container" *ngIf="showAvailabilityForm">
|
||||||
|
<div class="create-form-container">
|
||||||
|
<h3>Add Availability Slot</h3>
|
||||||
|
<div class="form-error-message" *ngIf="error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
<button type="button" (click)="error = null">×</button>
|
||||||
|
</div>
|
||||||
|
<form class="create-form" (ngSubmit)="createAvailability()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dayOfWeek" class="form-label">Day of Week</label>
|
||||||
|
<select id="dayOfWeek" [(ngModel)]="newAvailability.dayOfWeek" name="dayOfWeek" class="form-input" required>
|
||||||
|
<option value="MONDAY">Monday</option>
|
||||||
|
<option value="TUESDAY">Tuesday</option>
|
||||||
|
<option value="WEDNESDAY">Wednesday</option>
|
||||||
|
<option value="THURSDAY">Thursday</option>
|
||||||
|
<option value="FRIDAY">Friday</option>
|
||||||
|
<option value="SATURDAY">Saturday</option>
|
||||||
|
<option value="SUNDAY">Sunday</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="startTime" class="form-label">Start Time</label>
|
||||||
|
<input
|
||||||
|
id="startTime"
|
||||||
|
type="time"
|
||||||
|
[(ngModel)]="newAvailability.startTime"
|
||||||
|
name="startTime"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="endTime" class="form-label">End Time</label>
|
||||||
|
<input
|
||||||
|
id="endTime"
|
||||||
|
type="time"
|
||||||
|
[(ngModel)]="newAvailability.endTime"
|
||||||
|
name="endTime"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-btn" (click)="showAvailabilityForm = false; error = null">Cancel</button>
|
||||||
|
<button type="submit" class="submit-button">Add Availability</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Add Availability Form -->
|
||||||
|
<div class="request-form-container" *ngIf="showBulkAvailabilityForm">
|
||||||
|
<div class="create-form-container">
|
||||||
|
<h3>Bulk Add Availability</h3>
|
||||||
|
<div class="form-error-message" *ngIf="error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
<button type="button" (click)="error = null">×</button>
|
||||||
|
</div>
|
||||||
|
<form class="create-form" (ngSubmit)="createBulkAvailability()">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="bulk-slots-header">
|
||||||
|
<h4>Availability Slots</h4>
|
||||||
|
<button type="button" class="add-slot-btn" (click)="addBulkAvailabilitySlot()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Add Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bulk-slots-container" *ngIf="bulkAvailability.availabilities.length > 0">
|
||||||
|
<div class="bulk-slot-item" *ngFor="let slot of bulkAvailability.availabilities; let i = index">
|
||||||
|
<div class="slot-item-header">
|
||||||
|
<span class="slot-number">Slot {{ i + 1 }}</span>
|
||||||
|
<button type="button" class="remove-slot-btn" (click)="removeBulkAvailabilitySlot(i)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Day</label>
|
||||||
|
<select [(ngModel)]="slot.dayOfWeek" [name]="'dayOfWeek' + i" class="form-input" required>
|
||||||
|
<option value="MONDAY">Monday</option>
|
||||||
|
<option value="TUESDAY">Tuesday</option>
|
||||||
|
<option value="WEDNESDAY">Wednesday</option>
|
||||||
|
<option value="THURSDAY">Thursday</option>
|
||||||
|
<option value="FRIDAY">Friday</option>
|
||||||
|
<option value="SATURDAY">Saturday</option>
|
||||||
|
<option value="SUNDAY">Sunday</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Start Time</label>
|
||||||
|
<input type="time" [(ngModel)]="slot.startTime" [name]="'startTime' + i" class="form-input" required/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">End Time</label>
|
||||||
|
<input type="time" [(ngModel)]="slot.endTime" [name]="'endTime' + i" class="form-input" required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-bulk-slots" *ngIf="bulkAvailability.availabilities.length === 0">
|
||||||
|
<p>No slots added yet. Click "Add Slot" to create availability slots.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-btn" (click)="showBulkAvailabilityForm = false; bulkAvailability = { doctorId: doctorId || '', availabilities: [] }; error = null">Cancel</button>
|
||||||
|
<button type="submit" class="submit-button" [disabled]="bulkAvailability.availabilities.length === 0">Create All Slots</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Availability List -->
|
||||||
|
<div class="availability-grid" *ngIf="availability.length > 0">
|
||||||
|
<div class="availability-card" *ngFor="let slot of availability">
|
||||||
|
<div class="availability-header">
|
||||||
|
<div class="availability-info">
|
||||||
|
<h4 class="day-name">{{ slot.dayOfWeek }}</h4>
|
||||||
|
<p class="time-range">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ formatTime(slot.startTime) }} - {{ formatTime(slot.endTime) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" [ngClass]="slot.isAvailable ? 'status-confirmed' : 'status-cancelled'">
|
||||||
|
{{ slot.isAvailable ? 'Available' : 'Unavailable' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="availability-actions">
|
||||||
|
<button class="action-btn action-btn-cancel" (click)="deleteAvailability(slot.id)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="empty-state" *ngIf="availability.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Availability Slots</h3>
|
||||||
|
<p>Add your availability slots so patients can book appointments with you.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,957 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Enterprise Availability Component Styles
|
||||||
|
// Modern, Professional, and Responsive Styling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Root & Variables
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
:host,
|
||||||
|
app-availability {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Section Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.availability-section {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Header
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
margin-bottom: var(--space-2xl, 3rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
margin-bottom: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-3xl, 1.875rem);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Controls
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.section-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.filter-controls,
|
||||||
|
.action-buttons-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filter Controls
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.filter-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||||
|
padding-right: var(--space-2xl, 3rem);
|
||||||
|
border: 2px solid var(--color-gray-300, #d1d5db);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right var(--space-sm, 0.5rem) center;
|
||||||
|
background-size: 20px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
option {
|
||||||
|
padding: var(--space-sm, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Action Buttons Group
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.action-buttons-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.request-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request Buttons
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.request-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem);
|
||||||
|
border: 2px solid var(--color-primary, #2563eb);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary, #2563eb);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Danger variant
|
||||||
|
&.request-btn-danger {
|
||||||
|
border-color: var(--color-danger, #ef4444);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.request-form-container {
|
||||||
|
margin-bottom: var(--space-2xl, 3rem);
|
||||||
|
animation: slideDown var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-container {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-lg, 1.5rem) 0;
|
||||||
|
padding-bottom: var(--space-md, 1rem);
|
||||||
|
border-bottom: 2px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Elements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin-bottom: var(--space-xs, 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||||
|
border: 2px solid var(--color-gray-300, #d1d5db);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
font-family: var(--font-family-base, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-gray-400, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--color-gray-100, #f3f4f6);
|
||||||
|
color: var(--text-disabled, #9ca3af);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Error Message
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.form-error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
border: 1px solid var(--color-danger, #ef4444);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm, 0.375rem);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
padding-top: var(--space-md, 1rem);
|
||||||
|
border-top: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-xl, 2rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-primary, #2563eb) 100%);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-xl, 2rem);
|
||||||
|
border: 2px solid var(--color-gray-300, #d1d5db);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-gray-400, #9ca3af);
|
||||||
|
background: var(--color-gray-50, #f9fafb);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bulk Availability Form
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.bulk-slots-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-slot-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||||
|
border: 2px dashed var(--color-primary, #2563eb);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--color-primary, #2563eb);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-slots-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-slot-item {
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-number {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-slot-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm, 0.375rem);
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-bulk-slots {
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border: 2px dashed var(--color-gray-300, #d1d5db);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Availability Grid
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.availability-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Availability Card
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.availability-card {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
padding: var(--space-lg, 1.5rem);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Availability Header
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.availability-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-name {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-sm, 0.5rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Badge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
&.status-confirmed {
|
||||||
|
background: var(--color-success-light, #d1fae5);
|
||||||
|
color: var(--color-success, #10b981);
|
||||||
|
border: 1px solid var(--color-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-cancelled {
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
border: 1px solid var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Availability Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.availability-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
margin-top: var(--space-md, 1rem);
|
||||||
|
padding-top: var(--space-md, 1rem);
|
||||||
|
border-top: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-cancel {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
border: 2px solid var(--color-danger, #ef4444);
|
||||||
|
box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Empty State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl, 4rem) var(--space-xl, 2rem);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
border: 2px dashed var(--color-gray-300, #d1d5db);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
color: var(--color-gray-400, #9ca3af);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-sm, 0.5rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responsive Enhancements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.availability-card {
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-md, 1rem);
|
||||||
|
right: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-info {
|
||||||
|
padding-right: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Print Styles (Optional)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.availability-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-controls,
|
||||||
|
.availability-actions,
|
||||||
|
.request-form-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-card {
|
||||||
|
break-inside: avoid;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--color-gray-300, #d1d5db);
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AvailabilityService, AvailabilityRequest, AvailabilityResponse, BulkAvailabilityRequest } from '../../../../services/availability.service';
|
||||||
|
import { ModalService } from '../../../../services/modal.service';
|
||||||
|
import { LoggerService } from '../../../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-availability',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './availability.component.html',
|
||||||
|
styleUrl: './availability.component.scss'
|
||||||
|
})
|
||||||
|
export class AvailabilityComponent implements OnInit, OnChanges {
|
||||||
|
@Input() doctorId: string | null = null;
|
||||||
|
@Output() availabilityChanged = new EventEmitter<void>();
|
||||||
|
|
||||||
|
availability: AvailabilityResponse[] = [];
|
||||||
|
showAvailabilityForm = false;
|
||||||
|
showBulkAvailabilityForm = false;
|
||||||
|
dayFilter: string = 'all';
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
newAvailability: AvailabilityRequest = {
|
||||||
|
doctorId: '',
|
||||||
|
dayOfWeek: 'MONDAY',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '17:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
bulkAvailability: BulkAvailabilityRequest = {
|
||||||
|
doctorId: '',
|
||||||
|
availabilities: []
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private availabilityService: AvailabilityService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.doctorId) {
|
||||||
|
this.newAvailability.doctorId = this.doctorId;
|
||||||
|
this.bulkAvailability.doctorId = this.doctorId;
|
||||||
|
this.loadAvailability();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
|
if (changes['doctorId'] && this.doctorId) {
|
||||||
|
this.newAvailability.doctorId = this.doctorId;
|
||||||
|
this.bulkAvailability.doctorId = this.doctorId;
|
||||||
|
this.loadAvailability();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(timeString: string): string {
|
||||||
|
if (!timeString) return 'N/A';
|
||||||
|
return timeString.substring(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAvailability() {
|
||||||
|
if (!this.doctorId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.dayFilter !== 'all') {
|
||||||
|
this.availability = await this.availabilityService.getAvailabilityByDay(this.doctorId, this.dayFilter);
|
||||||
|
} else {
|
||||||
|
this.availability = await this.availabilityService.getDoctorAvailability(this.doctorId);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading availability:', e);
|
||||||
|
this.availability = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDayFilterChange() {
|
||||||
|
this.loadAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAvailability() {
|
||||||
|
if (!this.newAvailability.doctorId || !this.newAvailability.startTime || !this.newAvailability.endTime) {
|
||||||
|
this.error = 'Please fill in all required fields';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.doctorId) {
|
||||||
|
this.error = 'Doctor ID not available';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.newAvailability.doctorId = this.doctorId;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.availabilityService.createAvailability(this.newAvailability);
|
||||||
|
this.showAvailabilityForm = false;
|
||||||
|
this.newAvailability = {
|
||||||
|
doctorId: this.doctorId,
|
||||||
|
dayOfWeek: 'MONDAY',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '17:00'
|
||||||
|
};
|
||||||
|
this.error = null;
|
||||||
|
await this.loadAvailability();
|
||||||
|
this.availabilityChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to create availability slot';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAvailability(availabilityId: string) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this availability slot?',
|
||||||
|
'Delete Availability Slot',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.availabilityService.deleteAvailability(availabilityId);
|
||||||
|
await this.loadAvailability();
|
||||||
|
this.availabilityChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to delete availability slot';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllAvailability() {
|
||||||
|
if (!this.doctorId) {
|
||||||
|
this.error = 'Doctor ID not available';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete ALL availability slots? This action cannot be undone.',
|
||||||
|
'Delete All Availability Slots',
|
||||||
|
'Delete All',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.availabilityService.deleteAllDoctorAvailability(this.doctorId);
|
||||||
|
await this.loadAvailability();
|
||||||
|
this.availabilityChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to delete all availability slots';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBulkAvailability() {
|
||||||
|
if (!this.bulkAvailability.doctorId || !this.bulkAvailability.availabilities.length) {
|
||||||
|
this.error = 'Please add at least one availability slot';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.doctorId) {
|
||||||
|
this.error = 'Doctor ID not available';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bulkAvailability.doctorId = this.doctorId;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.availabilityService.createBulkAvailability(this.bulkAvailability);
|
||||||
|
this.showBulkAvailabilityForm = false;
|
||||||
|
this.bulkAvailability = {
|
||||||
|
doctorId: this.doctorId,
|
||||||
|
availabilities: []
|
||||||
|
};
|
||||||
|
this.error = null;
|
||||||
|
await this.loadAvailability();
|
||||||
|
this.availabilityChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to create bulk availability';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addBulkAvailabilitySlot() {
|
||||||
|
this.bulkAvailability.availabilities.push({
|
||||||
|
dayOfWeek: 'MONDAY',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '17:00'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBulkAvailabilitySlot(index: number) {
|
||||||
|
this.bulkAvailability.availabilities.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<section class="create-section">
|
||||||
|
<h2 class="section-title">Create New Appointment</h2>
|
||||||
|
<div class="create-form-container">
|
||||||
|
<!-- Info Message -->
|
||||||
|
<div class="info-message" *ngIf="patients.length === 0">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 16H12.01M12 8V12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Note:</strong> Patient list is extracted from your appointments. To create appointments for new patients, you may need to enter the patient ID manually or contact an administrator.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="create-form" (ngSubmit)="createAppointment()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="patientId" class="form-label">Patient</label>
|
||||||
|
|
||||||
|
<!-- Patient Search -->
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label for="patientSearch" class="form-label" style="font-size: 0.875rem; color: #666; margin-bottom: 0.5rem; display: block;">
|
||||||
|
Search Patient
|
||||||
|
<span *ngIf="loadingPatients" style="color: #999; font-weight: normal;">(Loading all patients...)</span>
|
||||||
|
</label>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<input
|
||||||
|
id="patientSearch"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="patientSearchQuery"
|
||||||
|
(input)="onPatientSearchChange()"
|
||||||
|
name="patientSearch"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
style="padding-right: 2.5rem;"
|
||||||
|
[disabled]="loadingPatients"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
*ngIf="patientSearchQuery"
|
||||||
|
type="button"
|
||||||
|
(click)="clearPatientSearch()"
|
||||||
|
style="position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; padding: 0.25rem; color: #666;"
|
||||||
|
title="Clear search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1rem; height: 1rem;">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<svg
|
||||||
|
*ngIf="!patientSearchQuery"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="position: absolute; right: 0.75rem; top: 50%; transform: translateY(-50%); width: 1rem; height: 1rem; color: #999; pointer-events: none;">
|
||||||
|
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Dropdown -->
|
||||||
|
<select id="patientId" [(ngModel)]="newAppointment.patientId" name="patientId" class="form-input" [disabled]="loadingPatients" (change)="onPatientSelect(newAppointment.patientId)">
|
||||||
|
<option value="">{{ loadingPatients ? 'Loading patients...' : 'Select a patient (or enter ID manually)' }}</option>
|
||||||
|
<option *ngFor="let patient of filteredPatients" [value]="patient.id">
|
||||||
|
{{ patient.displayName || (patient.firstName + ' ' + patient.lastName) }}{{ patient.email ? ' (' + patient.email + ')' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<!-- Patient Found Indicator -->
|
||||||
|
<div *ngIf="newAppointment.patientId && selectedPatientName" style="margin-top: 0.5rem; padding: 0.75rem; background-color: #e8f5e9; border-left: 3px solid #4caf50; border-radius: 4px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1.25rem; height: 1.25rem; color: #4caf50; flex-shrink: 0;">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<span style="color: #2e7d32; font-size: 0.875rem; font-weight: 600; display: block;">
|
||||||
|
Patient Found & Selected
|
||||||
|
</span>
|
||||||
|
<span style="color: #2e7d32; font-size: 0.875rem; display: block; margin-top: 0.25rem;">
|
||||||
|
{{ selectedPatientName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p *ngIf="loadingPatients" style="margin-top: 0.5rem; color: #666; font-size: 0.875rem;">
|
||||||
|
Loading all registered patients from the system...
|
||||||
|
</p>
|
||||||
|
<p *ngIf="!loadingPatients && patientSearchQuery && filteredPatients.length === 0" style="margin-top: 0.5rem; color: #666; font-size: 0.875rem;">
|
||||||
|
No patients found matching "{{ patientSearchQuery }}"
|
||||||
|
</p>
|
||||||
|
<p *ngIf="!loadingPatients && !patientSearchQuery && filteredPatients.length === 0" style="margin-top: 0.5rem; color: #666; font-size: 0.875rem;">
|
||||||
|
No patients available
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Manual Patient ID Input -->
|
||||||
|
<input
|
||||||
|
*ngIf="shouldShowManualPatientIdInput()"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newAppointment.patientId"
|
||||||
|
name="patientIdManual"
|
||||||
|
class="form-input form-input-margin"
|
||||||
|
placeholder="Or enter Patient ID manually"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduledDate" class="form-label">Date</label>
|
||||||
|
<input
|
||||||
|
id="scheduledDate"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="newAppointment.scheduledDate"
|
||||||
|
name="scheduledDate"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
[min]="getTodayDate()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduledTime" class="form-label">Time</label>
|
||||||
|
<input
|
||||||
|
id="scheduledTime"
|
||||||
|
type="time"
|
||||||
|
[(ngModel)]="newAppointment.scheduledTime"
|
||||||
|
name="scheduledTime"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="durationInMinutes" class="form-label">Duration (minutes)</label>
|
||||||
|
<input
|
||||||
|
id="durationInMinutes"
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="newAppointment.durationInMinutes"
|
||||||
|
name="durationInMinutes"
|
||||||
|
class="form-input"
|
||||||
|
min="15"
|
||||||
|
max="120"
|
||||||
|
step="15"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Message (Inline) - Always visible when successMessage exists -->
|
||||||
|
<div *ngIf="successMessage" class="form-success-message" style="margin-bottom: 1rem; padding: 1rem; background-color: #e8f5e9; border-left: 4px solid #4caf50; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: block !important; visibility: visible !important;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1.5rem; height: 1.5rem; color: #4caf50; flex-shrink: 0;">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<strong style="color: #2e7d32; font-size: 1rem; font-weight: 600; display: block; margin-bottom: 0.25rem;">✓ Appointment Created Successfully!</strong>
|
||||||
|
<span style="color: #2e7d32; font-size: 0.9rem; display: block;">{{ successMessage }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" (click)="successMessage = null" style="background: none; border: none; cursor: pointer; color: #2e7d32; padding: 0.25rem; opacity: 0.7; transition: opacity 0.2s;" title="Dismiss">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1.25rem; height: 1.25rem;">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message (Inline) - Always visible when error exists -->
|
||||||
|
<div class="form-error-message" *ngIf="error" style="margin-bottom: 1rem; padding: 1rem; background-color: #ffebee; border-left: 4px solid #f44336; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1.5rem; height: 1.5rem; color: #f44336; flex-shrink: 0;">
|
||||||
|
<path d="M12 8V12M12 16H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<strong style="color: #d32f2f; font-size: 1rem; font-weight: 600; display: block; margin-bottom: 0.25rem;">✗ Failed to Create Appointment</strong>
|
||||||
|
<span style="color: #d32f2f; font-size: 0.9rem; display: block;">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" (click)="error = null" style="background: none; border: none; cursor: pointer; color: #d32f2f; padding: 0.25rem; opacity: 0.7; transition: opacity 0.2s;" title="Dismiss">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1.25rem; height: 1.25rem;">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-button" [disabled]="creatingAppointment">
|
||||||
|
<span *ngIf="!creatingAppointment">Create Appointment</span>
|
||||||
|
<span *ngIf="creatingAppointment" style="display: flex; align-items: center; gap: 0.5rem; justify-content: center;">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1rem; height: 1rem; animation: spin 1s linear infinite;">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="32" stroke-dashoffset="32">
|
||||||
|
<animate attributeName="stroke-dasharray" dur="2s" values="0 32;16 16;0 32;0 32" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-16;-32;-32" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
Creating Appointment...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success/Error Popup - Fixed position, always on top -->
|
||||||
|
<div class="notification-popup" *ngIf="showPopup" [ngClass]="{'popup-success': popupType === 'success', 'popup-error': popupType === 'error'}" style="position: fixed !important; top: 20px !important; right: 20px !important; z-index: 99999 !important; min-width: 350px; max-width: 500px; background: white !important; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.2) !important; border: 2px solid; animation: slideInRight 0.3s ease-out; display: block !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important;">
|
||||||
|
<div class="popup-content" style="display: flex; align-items: flex-start; gap: 1rem; padding: 1.5rem; position: relative;">
|
||||||
|
<div class="popup-icon" style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: rgba(76, 175, 80, 0.1);" *ngIf="popupType === 'success'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 24px; height: 24px; color: #4caf50;">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="popup-icon" style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: rgba(244, 67, 54, 0.1);" *ngIf="popupType === 'error'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 24px; height: 24px; color: #f44336;">
|
||||||
|
<path d="M12 8V12M12 16H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="popup-message" style="flex: 1;">
|
||||||
|
<h3 class="popup-title" style="margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600; color: #333;">{{ popupType === 'success' ? '✓ Success!' : '✗ Error!' }}</h3>
|
||||||
|
<p class="popup-text" style="margin: 0; font-size: 0.95rem; color: #666; line-height: 1.5;">{{ popupType === 'success' ? successMessage : error }}</p>
|
||||||
|
<button
|
||||||
|
*ngIf="popupType === 'error' && (error?.includes('session') || error?.includes('expired') || error?.includes('log in'))"
|
||||||
|
type="button"
|
||||||
|
class="popup-refresh-btn"
|
||||||
|
(click)="refreshPage()"
|
||||||
|
style="margin-top: 0.75rem; padding: 0.5rem 1rem; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; display: inline-flex; align-items: center; gap: 0.5rem;"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 16px; height: 16px;">
|
||||||
|
<path d="M3 12C3 7.03 7.03 3 12 3C16.97 3 21 7.03 21 12C21 16.97 16.97 21 12 21C7.03 21 3 16.97 3 12Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="popup-close" (click)="closePopup()" aria-label="Close" style="position: absolute; top: 0.75rem; right: 0.75rem; background: none; border: none; cursor: pointer; padding: 0.25rem; color: #999; opacity: 0.7; transition: opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 20px; height: 20px;">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="popup-progress" *ngIf="popupType === 'success'" style="height: 4px; background: linear-gradient(90deg, #4caf50, #66bb6a); animation: progressBar 5s linear;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes progressBar {
|
||||||
|
from {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.popup-success {
|
||||||
|
border-color: #4caf50;
|
||||||
|
}
|
||||||
|
.popup-error {
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Enterprise Create Appointment Component Styles
|
||||||
|
// Modern, Professional, and Responsive Styling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Root & Variables
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
:host,
|
||||||
|
app-create-appointment {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Section Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.create-section {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Title
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-3xl, 1.875rem);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-2xl, 3rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
margin-bottom: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.create-form-container {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgba(0, 0, 0, 0.1));
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-lg, 1.5rem);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Info Message
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.info-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
padding: var(--space-md, 1rem) var(--space-lg, 1.5rem);
|
||||||
|
background: var(--color-info-light, #dbeafe);
|
||||||
|
border: 1px solid var(--color-info, #3b82f6);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
color: var(--color-info, #3b82f6);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
line-height: var(--line-height-relaxed, 1.75);
|
||||||
|
margin-bottom: var(--space-xl, 2rem);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: var(--space-xs, 0.25rem);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-xs, 0.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Elements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl, 2rem);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin-bottom: var(--space-xs, 0.25rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '*';
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
margin-left: var(--space-xs, 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove asterisk for non-required fields if needed
|
||||||
|
&.optional::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||||
|
border: 2px solid var(--color-gray-300, #d1d5db);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
font-family: var(--font-family-base, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-gray-400, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--color-gray-100, #f3f4f6);
|
||||||
|
color: var(--text-disabled, #9ca3af);
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--color-gray-200, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number input styling
|
||||||
|
&[type="number"] {
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
opacity: 1;
|
||||||
|
height: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date and time input styling
|
||||||
|
&[type="date"],
|
||||||
|
&[type="time"] {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::-webkit-calendar-picker-indicator {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select dropdown specific styling
|
||||||
|
select.form-input {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right var(--space-sm, 0.5rem) center;
|
||||||
|
background-size: 20px;
|
||||||
|
padding-right: var(--space-2xl, 3rem);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
option {
|
||||||
|
padding: var(--space-sm, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual input margin
|
||||||
|
.form-input-margin {
|
||||||
|
margin-top: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Error Message
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.form-error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
padding: var(--space-md, 1rem) var(--space-lg, 1.5rem);
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
border: 1px solid var(--color-danger, #ef4444);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
margin-top: calc(var(--space-lg, 1.5rem) * -1);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
animation: slideDown var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm, 0.375rem);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Submit Button
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
padding: var(--space-md, 1rem) var(--space-2xl, 3rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
align-self: flex-start;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-primary, #2563eb) 100%);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state (if needed)
|
||||||
|
&.loading {
|
||||||
|
position: relative;
|
||||||
|
color: transparent;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: var(--text-inverse, #ffffff);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
width: 100%;
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-md, 1rem) var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form Validation States
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
&.has-error {
|
||||||
|
.form-input {
|
||||||
|
border-color: var(--color-danger, #ef4444);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-success {
|
||||||
|
.form-input {
|
||||||
|
border-color: var(--color-success, #10b981);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Selection Enhancement
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
// Special styling for patient selection with manual input
|
||||||
|
&:has(input.form-input-margin) {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.form-input-margin {
|
||||||
|
border-top: none;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-color: var(--color-gray-300, #d1d5db);
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: 'OR';
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
padding: 0 var(--space-sm, 0.5rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responsive Enhancements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.create-form-container {
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.create-section {
|
||||||
|
padding: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-container {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Focus Visible for Accessibility
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.form-input:focus-visible,
|
||||||
|
.submit-button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Print Styles (Optional)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.create-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-container {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--color-gray-300, #d1d5db);
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button,
|
||||||
|
.info-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
border: 1px solid var(--color-gray-400, #9ca3af);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading State (if needed)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.create-form-container {
|
||||||
|
&.loading {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Notification Popup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.notification-popup {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-xl, 2rem);
|
||||||
|
right: var(--space-xl, 2rem);
|
||||||
|
z-index: var(--z-modal, 1050);
|
||||||
|
min-width: 350px;
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
box-shadow: var(--shadow-2xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25));
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
animation: slideInRight var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
top: var(--space-md, 1rem);
|
||||||
|
right: var(--space-md, 1rem);
|
||||||
|
left: var(--space-md, 1rem);
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
padding: var(--space-lg, 1.5rem);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-success .popup-icon {
|
||||||
|
background: var(--color-success-light, #d1fae5);
|
||||||
|
color: var(--color-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-error .popup-icon {
|
||||||
|
background: var(--color-danger-light, #fee2e2);
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-message {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-xs, 0.25rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-text {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm, 0.375rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-gray-100, #f3f4f6);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-refresh-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
margin-top: var(--space-md, 1rem);
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||||
|
border: 2px solid var(--color-primary, #2563eb);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary, #2563eb);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--color-success, #10b981) 0%, var(--color-secondary-dark, #059669) 100%);
|
||||||
|
animation: progressBar 3s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressBar {
|
||||||
|
from {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popup variants
|
||||||
|
.popup-success {
|
||||||
|
border-left: 4px solid var(--color-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-error {
|
||||||
|
border-left: 4px solid var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.notification-popup {
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-content {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-text {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AppointmentService } from '../../../../services/appointment.service';
|
||||||
|
import { UserService } from '../../../../services/user.service';
|
||||||
|
import { LoggerService } from '../../../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-create-appointment',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './create-appointment.component.html',
|
||||||
|
styleUrl: './create-appointment.component.scss'
|
||||||
|
})
|
||||||
|
export class CreateAppointmentComponent implements OnInit, OnChanges {
|
||||||
|
@Input() patients: any[] = [];
|
||||||
|
@Input() doctorId: string | null = null;
|
||||||
|
@Input() selectedPatientId: string | null = null;
|
||||||
|
@Output() appointmentCreated = new EventEmitter<void>();
|
||||||
|
|
||||||
|
error: string | null = null;
|
||||||
|
successMessage: string | null = null;
|
||||||
|
showPopup: boolean = false;
|
||||||
|
popupType: 'success' | 'error' = 'success';
|
||||||
|
creatingAppointment: boolean = false; // Loading state during creation
|
||||||
|
|
||||||
|
// Patient search
|
||||||
|
patientSearchQuery: string = '';
|
||||||
|
filteredPatients: any[] = [];
|
||||||
|
allRegisteredPatients: any[] = []; // All patients in the system
|
||||||
|
loadingPatients: boolean = false;
|
||||||
|
selectedPatientName: string = ''; // Store selected patient name for display
|
||||||
|
|
||||||
|
newAppointment = {
|
||||||
|
patientId: '',
|
||||||
|
scheduledDate: '',
|
||||||
|
scheduledTime: '',
|
||||||
|
durationInMinutes: 30
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private appointmentService: AppointmentService,
|
||||||
|
private userService: UserService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
// Load all registered patients from the system first
|
||||||
|
await this.loadAllPatients();
|
||||||
|
|
||||||
|
// Auto-fill patient ID if provided
|
||||||
|
if (this.selectedPatientId) {
|
||||||
|
this.newAppointment.patientId = this.selectedPatientId;
|
||||||
|
this.logger.debug('[CreateAppointmentComponent] Auto-filled patient ID on init:', this.selectedPatientId);
|
||||||
|
// Find and set the patient name
|
||||||
|
const allPatients = [...this.allRegisteredPatients, ...(this.patients || [])];
|
||||||
|
const selectedPatient = allPatients.find(p => p.id === this.selectedPatientId);
|
||||||
|
if (selectedPatient) {
|
||||||
|
this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize filtered patients
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
|
// Auto-fill patient ID when selectedPatientId changes
|
||||||
|
if (changes['selectedPatientId'] && this.selectedPatientId) {
|
||||||
|
this.newAppointment.patientId = this.selectedPatientId;
|
||||||
|
this.logger.debug('[CreateAppointmentComponent] Auto-filled patient ID on change:', this.selectedPatientId);
|
||||||
|
// Find and set the patient name
|
||||||
|
const allPatients = [...this.allRegisteredPatients, ...(this.patients || [])];
|
||||||
|
const selectedPatient = allPatients.find(p => p.id === this.selectedPatientId);
|
||||||
|
if (selectedPatient) {
|
||||||
|
this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update filtered patients when patients list changes (for backward compatibility)
|
||||||
|
if (changes['patients']) {
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllPatients() {
|
||||||
|
try {
|
||||||
|
this.loadingPatients = true;
|
||||||
|
const allPatients = await this.userService.getAllPatients();
|
||||||
|
// Convert PatientProfile to the format expected by the component
|
||||||
|
this.allRegisteredPatients = allPatients.map(patient => ({
|
||||||
|
id: patient.id,
|
||||||
|
userId: patient.userId,
|
||||||
|
firstName: patient.firstName,
|
||||||
|
lastName: patient.lastName,
|
||||||
|
displayName: `${patient.firstName} ${patient.lastName}`,
|
||||||
|
email: patient.email,
|
||||||
|
phoneNumber: patient.phoneNumber
|
||||||
|
}));
|
||||||
|
this.logger.debug('[CreateAppointmentComponent] Loaded all registered patients:', this.allRegisteredPatients.length);
|
||||||
|
// Update filtered patients after loading
|
||||||
|
this.filterPatients();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('[CreateAppointmentComponent] Failed to load all patients:', e);
|
||||||
|
// Fall back to the patients passed from parent if loading fails
|
||||||
|
this.allRegisteredPatients = this.patients || [];
|
||||||
|
} finally {
|
||||||
|
this.loadingPatients = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTodayDate(): string {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShowManualPatientIdInput(): boolean {
|
||||||
|
if (!this.newAppointment.patientId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check both all registered patients and the passed patients list
|
||||||
|
const allPatients = [...this.allRegisteredPatients, ...(this.patients || [])];
|
||||||
|
return !allPatients.some(p => p.id === this.newAppointment.patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAppointment() {
|
||||||
|
// Clear previous messages - but keep selectedPatientName for display
|
||||||
|
this.error = null;
|
||||||
|
this.successMessage = null;
|
||||||
|
this.showPopup = false; // Ensure popup is closed
|
||||||
|
|
||||||
|
if (!this.newAppointment.patientId || !this.newAppointment.scheduledDate || !this.newAppointment.scheduledTime) {
|
||||||
|
this.error = 'Please fill in all required fields';
|
||||||
|
this.showErrorPopup('Please fill in all required fields: Patient, Date, and Time are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.doctorId) {
|
||||||
|
this.error = 'Doctor ID not available. Please refresh the page.';
|
||||||
|
this.showErrorPopup('Doctor ID not available. Please refresh the page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingAppointment = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.appointmentService.createAppointment({
|
||||||
|
patientId: this.newAppointment.patientId,
|
||||||
|
doctorId: this.doctorId,
|
||||||
|
scheduledDate: this.newAppointment.scheduledDate,
|
||||||
|
scheduledTime: this.newAppointment.scheduledTime,
|
||||||
|
durationMinutes: this.newAppointment.durationInMinutes || 30
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success - clear form and show success message
|
||||||
|
const patientName = this.selectedPatientName || 'Patient';
|
||||||
|
const appointmentDate = new Date(this.newAppointment.scheduledDate).toLocaleDateString();
|
||||||
|
const appointmentTime = this.newAppointment.scheduledTime;
|
||||||
|
|
||||||
|
this.newAppointment = {
|
||||||
|
patientId: '',
|
||||||
|
scheduledDate: '',
|
||||||
|
scheduledTime: '',
|
||||||
|
durationInMinutes: 30
|
||||||
|
};
|
||||||
|
this.selectedPatientName = '';
|
||||||
|
this.patientSearchQuery = '';
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
// Show comprehensive success message
|
||||||
|
const successMsg = `Appointment created successfully! Scheduled for ${appointmentDate} at ${appointmentTime} with ${patientName}.`;
|
||||||
|
this.logger.debug('[CreateAppointment] Showing success popup:', successMsg);
|
||||||
|
|
||||||
|
// Set success message first (for inline display)
|
||||||
|
this.successMessage = successMsg;
|
||||||
|
this.popupType = 'success';
|
||||||
|
this.showPopup = true;
|
||||||
|
|
||||||
|
// Force change detection immediately
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
|
||||||
|
this.logger.debug('[CreateAppointment] showPopup after setting:', this.showPopup);
|
||||||
|
this.logger.debug('[CreateAppointment] successMessage after setting:', this.successMessage);
|
||||||
|
|
||||||
|
// Verify popup element exists in DOM after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
// Try to find popup in the component's view first
|
||||||
|
const popupElement = document.querySelector('app-create-appointment .notification-popup') ||
|
||||||
|
document.querySelector('.notification-popup');
|
||||||
|
this.logger.debug('[CreateAppointment] Popup element in DOM:', popupElement);
|
||||||
|
if (popupElement) {
|
||||||
|
const styles = window.getComputedStyle(popupElement);
|
||||||
|
this.logger.debug('[CreateAppointment] Popup computed styles:', {
|
||||||
|
display: styles.display,
|
||||||
|
visibility: styles.visibility,
|
||||||
|
opacity: styles.opacity,
|
||||||
|
zIndex: styles.zIndex,
|
||||||
|
position: styles.position,
|
||||||
|
top: styles.top,
|
||||||
|
right: styles.right
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force show if hidden
|
||||||
|
if (styles.display === 'none' || styles.visibility === 'hidden' || parseFloat(styles.opacity) === 0) {
|
||||||
|
this.logger.warn('[CreateAppointment] Popup is hidden! Forcing visibility...');
|
||||||
|
(popupElement as HTMLElement).style.setProperty('display', 'block', 'important');
|
||||||
|
(popupElement as HTMLElement).style.setProperty('visibility', 'visible', 'important');
|
||||||
|
(popupElement as HTMLElement).style.setProperty('opacity', '1', 'important');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.error('[CreateAppointment] Popup element NOT found in DOM!');
|
||||||
|
this.logger.debug('[CreateAppointment] showPopup value:', this.showPopup);
|
||||||
|
this.logger.debug('[CreateAppointment] popupType value:', this.popupType);
|
||||||
|
this.logger.debug('[CreateAppointment] successMessage value:', this.successMessage);
|
||||||
|
|
||||||
|
// Try to manually create and append popup to body as fallback
|
||||||
|
this.createFallbackPopup(successMsg);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
this.appointmentCreated.emit();
|
||||||
|
|
||||||
|
// Auto-close popup after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.closePopup();
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Reload patients after creating appointment
|
||||||
|
await this.loadAllPatients();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error creating appointment:', e);
|
||||||
|
|
||||||
|
// Extract error message from various possible locations
|
||||||
|
let errorMessage = 'Failed to create appointment';
|
||||||
|
let shouldRedirect = false;
|
||||||
|
|
||||||
|
if (e?.response?.data) {
|
||||||
|
errorMessage = e.response.data.message ||
|
||||||
|
e.response.data.error ||
|
||||||
|
e.response.data.details ||
|
||||||
|
(typeof e.response.data === 'string' ? e.response.data : errorMessage);
|
||||||
|
} else if (e?.message) {
|
||||||
|
errorMessage = e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide more specific error messages based on status code
|
||||||
|
if (e?.response?.status === 401) {
|
||||||
|
errorMessage = 'Your session has expired. Please refresh the page or log in again.';
|
||||||
|
// Don't auto-redirect - let user decide
|
||||||
|
shouldRedirect = false; // Changed: don't auto-redirect, let user handle it
|
||||||
|
} else if (e?.response?.status === 400) {
|
||||||
|
if (!errorMessage || errorMessage === 'Failed to create appointment') {
|
||||||
|
errorMessage = 'Invalid appointment details. Please check all fields and try again.';
|
||||||
|
}
|
||||||
|
} else if (e?.response?.status === 403) {
|
||||||
|
errorMessage = 'You do not have permission to create appointments.';
|
||||||
|
} else if (e?.response?.status === 404) {
|
||||||
|
errorMessage = 'Doctor or patient not found. Please refresh the page.';
|
||||||
|
} else if (e?.response?.status === 409) {
|
||||||
|
errorMessage = errorMessage || 'This appointment conflicts with an existing appointment.';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error = errorMessage;
|
||||||
|
// Show comprehensive error message
|
||||||
|
const errorMsg = `Failed to create appointment: ${errorMessage}`;
|
||||||
|
this.showErrorPopup(errorMsg);
|
||||||
|
|
||||||
|
// Only redirect if we got a 401 from the server and user hasn't manually dismissed
|
||||||
|
// Note: Removed auto-redirect to prevent redirect loops - user can manually refresh/login
|
||||||
|
// if (shouldRedirect) {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// window.location.href = '/login';
|
||||||
|
// }, 3000);
|
||||||
|
// }
|
||||||
|
} finally {
|
||||||
|
this.creatingAppointment = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccessPopup(message: string) {
|
||||||
|
this.logger.debug('[showSuccessPopup] Called with message:', message);
|
||||||
|
this.successMessage = message;
|
||||||
|
this.popupType = 'success';
|
||||||
|
this.showPopup = true;
|
||||||
|
this.logger.debug('[showSuccessPopup] showPopup set to:', this.showPopup);
|
||||||
|
this.logger.debug('[showSuccessPopup] successMessage set to:', this.successMessage);
|
||||||
|
this.logger.debug('[showSuccessPopup] popupType set to:', this.popupType);
|
||||||
|
|
||||||
|
// Force change detection
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
|
||||||
|
// Keep success message visible longer so user can see it
|
||||||
|
setTimeout(() => {
|
||||||
|
this.closePopup();
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
// Keep success message visible even after popup closes
|
||||||
|
// It will be cleared when user starts creating a new appointment
|
||||||
|
}, 5000); // Auto-close after 5 seconds (increased from 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
showErrorPopup(message: string) {
|
||||||
|
this.error = message;
|
||||||
|
this.popupType = 'error';
|
||||||
|
this.showPopup = true;
|
||||||
|
|
||||||
|
// For authentication errors, show longer and don't auto-close
|
||||||
|
const autoCloseDelay = message.includes('session') || message.includes('expired') || message.includes('log in')
|
||||||
|
? 8000 // 8 seconds for auth errors so user can read
|
||||||
|
: 5000; // 5 seconds for other errors
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.closePopup();
|
||||||
|
}, autoCloseDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPage() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup() {
|
||||||
|
this.showPopup = false;
|
||||||
|
// Don't clear successMessage here - keep it visible in inline message
|
||||||
|
// Only clear error when popup closes (user dismissed it)
|
||||||
|
if (this.popupType === 'error') {
|
||||||
|
// Keep error visible in inline message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterPatients() {
|
||||||
|
// Use all registered patients for search, but also include patients from parent (for backward compatibility)
|
||||||
|
const allPatients = [...this.allRegisteredPatients];
|
||||||
|
// Add patients from parent that aren't already in allRegisteredPatients
|
||||||
|
(this.patients || []).forEach(patient => {
|
||||||
|
if (!allPatients.some(p => p.id === patient.id)) {
|
||||||
|
allPatients.push(patient);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.patientSearchQuery || this.patientSearchQuery.trim() === '') {
|
||||||
|
this.filteredPatients = allPatients;
|
||||||
|
// Update selected patient name if patient is still in the list
|
||||||
|
if (this.newAppointment.patientId) {
|
||||||
|
const selectedPatient = allPatients.find(p => p.id === this.newAppointment.patientId);
|
||||||
|
if (selectedPatient) {
|
||||||
|
this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`;
|
||||||
|
} else {
|
||||||
|
// Clear selection if patient is no longer in the list
|
||||||
|
this.newAppointment.patientId = '';
|
||||||
|
this.selectedPatientName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = this.patientSearchQuery.toLowerCase().trim();
|
||||||
|
this.filteredPatients = allPatients.filter(patient => {
|
||||||
|
const firstName = (patient.firstName || '').toLowerCase();
|
||||||
|
const lastName = (patient.lastName || '').toLowerCase();
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
const displayName = (patient.displayName || fullName).toLowerCase();
|
||||||
|
const email = (patient.email || '').toLowerCase();
|
||||||
|
|
||||||
|
return firstName.includes(query) ||
|
||||||
|
lastName.includes(query) ||
|
||||||
|
fullName.includes(query) ||
|
||||||
|
displayName.includes(query) ||
|
||||||
|
email.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-fill if exactly one patient is found
|
||||||
|
if (this.filteredPatients.length === 1) {
|
||||||
|
const foundPatient = this.filteredPatients[0];
|
||||||
|
if (this.newAppointment.patientId !== foundPatient.id) {
|
||||||
|
this.newAppointment.patientId = foundPatient.id;
|
||||||
|
this.selectedPatientName = foundPatient.displayName || `${foundPatient.firstName} ${foundPatient.lastName}`;
|
||||||
|
// Don't show popup for auto-selection - just update the name
|
||||||
|
// The green banner below the dropdown will show the patient is selected
|
||||||
|
}
|
||||||
|
} else if (this.filteredPatients.length === 0) {
|
||||||
|
// Clear selection if no patients found
|
||||||
|
this.newAppointment.patientId = '';
|
||||||
|
this.selectedPatientName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPatientSearchChange() {
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPatientSearch() {
|
||||||
|
this.patientSearchQuery = '';
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
|
||||||
|
createFallbackPopup(message: string) {
|
||||||
|
// Create a fallback popup directly in the DOM if Angular isn't rendering it
|
||||||
|
const existingPopup = document.getElementById('appointment-success-popup');
|
||||||
|
if (existingPopup) {
|
||||||
|
existingPopup.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = document.createElement('div');
|
||||||
|
popup.id = 'appointment-success-popup';
|
||||||
|
popup.className = 'notification-popup popup-success';
|
||||||
|
popup.style.cssText = 'position: fixed !important; top: 20px !important; right: 20px !important; z-index: 99999 !important; min-width: 350px; max-width: 500px; background: white !important; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.2) !important; border: 2px solid #4caf50; animation: slideInRight 0.3s ease-out; display: block !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important;';
|
||||||
|
|
||||||
|
popup.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 1.5rem; position: relative;">
|
||||||
|
<div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: rgba(76, 175, 80, 0.1);">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 24px; height: 24px; color: #4caf50;">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h3 style="margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600; color: #333;">✓ Success!</h3>
|
||||||
|
<p style="margin: 0; font-size: 0.95rem; color: #666; line-height: 1.5;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="this.parentElement.parentElement.remove()" style="position: absolute; top: 0.75rem; right: 0.75rem; background: none; border: none; cursor: pointer; padding: 0.25rem; color: #999; opacity: 0.7;" aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 20px; height: 20px;">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="height: 4px; background: linear-gradient(90deg, #4caf50, #66bb6a); animation: progressBar 5s linear;"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(popup);
|
||||||
|
this.logger.debug('[CreateAppointment] Fallback popup created and appended to body');
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (popup.parentElement) {
|
||||||
|
popup.remove();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
showPatientFoundNotification(patient: any) {
|
||||||
|
const patientName = patient.displayName || `${patient.firstName} ${patient.lastName}`;
|
||||||
|
// Show a simple inline message for patient selection, not the appointment creation popup
|
||||||
|
// Don't use the same popup system - just update the selectedPatientName which shows in the green banner
|
||||||
|
// The successMessage and popup are only for appointment creation
|
||||||
|
this.logger.debug(`Patient found and selected: ${patientName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPatientSelect(patientId: string) {
|
||||||
|
if (patientId) {
|
||||||
|
const selectedPatient = this.filteredPatients.find(p => p.id === patientId);
|
||||||
|
if (selectedPatient) {
|
||||||
|
this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`;
|
||||||
|
// Don't show popup for patient selection - just update the name
|
||||||
|
// The green banner below the dropdown will show the patient is selected
|
||||||
|
// Clear search after selection to show all patients in dropdown
|
||||||
|
this.patientSearchQuery = '';
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedPatientName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
701
frontend/src/app/pages/doctor/components/ehr/ehr.component.html
Normal file
701
frontend/src/app/pages/doctor/components/ehr/ehr.component.html
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
<section class="ehr-section">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">EHR Management</h2>
|
||||||
|
<p class="section-description">Manage electronic health records for your patients</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="secondary-button" (click)="toggleRecordsView()">
|
||||||
|
{{ showAllRecords ? 'Show Patient Specific' : 'Show All Records' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div class="error-message" *ngIf="error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
<button type="button" (click)="error = null">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Selection -->
|
||||||
|
<div class="card" *ngIf="!showAllRecords">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0;">Select Patient</h3>
|
||||||
|
<p class="section-description" style="margin: 0.5rem 0 0 0;">Choose a patient to view their EHR records, vital signs, and lab results.</p>
|
||||||
|
</div>
|
||||||
|
<button *ngIf="localSelectedPatientId" class="secondary-button" (click)="selectPatient(null)" style="margin-left: 1rem;">
|
||||||
|
Clear Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Search -->
|
||||||
|
<div class="form-group" style="margin-bottom: 1rem;">
|
||||||
|
<label class="form-label">Search Patient</label>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="patientSearchQuery"
|
||||||
|
(input)="onPatientSearchChange()"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
style="padding-right: 2.5rem;">
|
||||||
|
<button
|
||||||
|
*ngIf="patientSearchQuery"
|
||||||
|
type="button"
|
||||||
|
(click)="clearPatientSearch()"
|
||||||
|
style="position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; padding: 0.25rem; color: #666;"
|
||||||
|
title="Clear search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1rem; height: 1rem;">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<svg
|
||||||
|
*ngIf="!patientSearchQuery"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="position: absolute; right: 0.75rem; top: 50%; transform: translateY(-50%); width: 1rem; height: 1rem; color: #999; pointer-events: none;">
|
||||||
|
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Dropdown -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Select Patient</label>
|
||||||
|
<select [(ngModel)]="localSelectedPatientId" (change)="selectPatient(localSelectedPatientId)" class="form-input">
|
||||||
|
<option [value]="null">-- Select a Patient --</option>
|
||||||
|
<option *ngFor="let patient of filteredPatients" [value]="patient.id">
|
||||||
|
{{ patient.firstName }} {{ patient.lastName }}
|
||||||
|
<span *ngIf="patient.email"> ({{ patient.email }})</span>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p *ngIf="patientSearchQuery && filteredPatients.length === 0" style="margin-top: 0.5rem; color: #666; font-size: 0.875rem;">
|
||||||
|
No patients found matching "{{ patientSearchQuery }}"
|
||||||
|
</p>
|
||||||
|
<p *ngIf="!patientSearchQuery && filteredPatients.length === 0" style="margin-top: 0.5rem; color: #666; font-size: 0.875rem;">
|
||||||
|
No patients available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Patient Info -->
|
||||||
|
<div class="card patient-info-card" *ngIf="localSelectedPatientId && !showAllRecords">
|
||||||
|
<div class="patient-info-header">
|
||||||
|
<div>
|
||||||
|
<h3>Current Patient</h3>
|
||||||
|
<p *ngFor="let patient of patients">
|
||||||
|
<strong *ngIf="patient.id === localSelectedPatientId">{{ patient.firstName }} {{ patient.lastName }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="view-profile-btn" (click)="viewPatientProfile(localSelectedPatientId!)" title="View Full Patient Profile">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2.45825 12C3.73203 7.94291 7.52281 5 12.0004 5C16.4781 5 20.2689 7.94291 21.5427 12C20.2689 16.0571 16.4781 19 12.0004 19C7.52281 19 3.73203 16.0571 2.45825 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
View Full Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div class="loading-container" *ngIf="loading">
|
||||||
|
<div class="spinner-wrapper">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="32" stroke-dashoffset="32">
|
||||||
|
<animate attributeName="stroke-dasharray" dur="2s" values="0 32;16 16;0 32;0 32" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-16;-32;-32" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs Navigation -->
|
||||||
|
<div class="ehr-tabs" *ngIf="!loading && (localSelectedPatientId || showAllRecords)">
|
||||||
|
<button class="tab-button" [class.active]="activeTab === 'records'" (click)="activeTab = 'records'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 2V8H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Medical Records
|
||||||
|
<span class="badge" *ngIf="getCurrentRecords().length > 0">{{ getCurrentRecords().length }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" [class.active]="activeTab === 'vitals'" (click)="activeTab = 'vitals'" *ngIf="localSelectedPatientId && !showAllRecords">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2V22M17 5H9.5C8.57174 5 7.6815 5.36875 7.02513 6.02513C6.36875 6.6815 6 7.57174 6 8.5C6 9.42826 6.36875 10.3185 7.02513 10.9749C7.6815 11.6312 8.57174 12 9.5 12H14.5C15.4283 12 16.3185 12.3687 16.9749 13.0251C17.6312 13.6815 18 14.5717 18 15.5C18 16.4283 17.6312 17.3185 16.9749 17.9749C16.3185 18.6312 15.4283 19 14.5 19H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Vital Signs
|
||||||
|
<span class="badge" *ngIf="vitalSigns.length > 0">{{ vitalSigns.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" [class.active]="activeTab === 'labResults'" (click)="activeTab = 'labResults'" *ngIf="localSelectedPatientId && !showAllRecords">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Lab Results
|
||||||
|
<span class="badge" *ngIf="labResults.length > 0">{{ labResults.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Medical Records Tab -->
|
||||||
|
<div class="tab-content" *ngIf="activeTab === 'records' && !loading">
|
||||||
|
<!-- Create/Edit Medical Record Form -->
|
||||||
|
<div class="card" *ngIf="showCreateMedicalRecord">
|
||||||
|
<h3>{{ isUpdatingMedicalRecord() ? 'Update' : 'Create' }} Medical Record</h3>
|
||||||
|
<form (ngSubmit)="createMedicalRecord()">
|
||||||
|
<div class="form-group" *ngIf="showAllRecords || !localSelectedPatientId">
|
||||||
|
<label class="form-label">Patient <span class="required-asterisk">*</span></label>
|
||||||
|
<select [(ngModel)]="newMedicalRecord.patientId" name="patientId" required class="form-input">
|
||||||
|
<option [value]="''">Select Patient</option>
|
||||||
|
<option *ngFor="let patient of patients" [value]="patient.id">
|
||||||
|
{{ patient.firstName }} {{ patient.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Record Type</label>
|
||||||
|
<select [(ngModel)]="newMedicalRecord.recordType" name="recordType" class="form-input">
|
||||||
|
<option value="NOTE">Note</option>
|
||||||
|
<option value="DIAGNOSIS">Diagnosis</option>
|
||||||
|
<option value="TREATMENT_PLAN">Treatment Plan</option>
|
||||||
|
<option value="PROCEDURE">Procedure</option>
|
||||||
|
<option value="IMAGING">Imaging</option>
|
||||||
|
<option value="OTHER">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Title <span class="required-asterisk">*</span></label>
|
||||||
|
<input type="text" [(ngModel)]="newMedicalRecord.title" name="title" required class="form-input" placeholder="Enter record title">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Content <span class="required-asterisk">*</span></label>
|
||||||
|
<textarea [(ngModel)]="newMedicalRecord.content" name="content" required class="form-input form-textarea" rows="6" placeholder="Enter record content"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Diagnosis Code</label>
|
||||||
|
<input type="text" [(ngModel)]="newMedicalRecord.diagnosisCode" name="diagnosisCode" class="form-input" placeholder="e.g., ICD-10 code">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" (click)="cancelMedicalRecordForm()">Cancel</button>
|
||||||
|
<button type="submit" [disabled]="loading">Save Record</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Medical Records List -->
|
||||||
|
<div class="card" *ngIf="!showCreateMedicalRecord">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>{{ showAllRecords ? 'All Medical Records' : 'Patient Medical Records' }} ({{ getCurrentRecords().length }})</h3>
|
||||||
|
<button class="primary-button" (click)="openCreateMedicalRecord()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Create Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="medical-records-list" *ngIf="getCurrentRecords().length > 0">
|
||||||
|
<div *ngFor="let record of getCurrentRecords()" class="medical-record-card">
|
||||||
|
<div class="record-header">
|
||||||
|
<div>
|
||||||
|
<h4>{{ record.title }}</h4>
|
||||||
|
<div class="record-meta">
|
||||||
|
<span class="record-type">{{ record.recordType }}</span>
|
||||||
|
<span class="record-date">{{ formatDateTime(record.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-actions">
|
||||||
|
<button class="icon-button" (click)="editMedicalRecord(record)" title="Edit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button delete-button" (click)="deleteMedicalRecord(record.id)" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-content">
|
||||||
|
<p>{{ record.content }}</p>
|
||||||
|
<div class="record-details" *ngIf="record.diagnosisCode || record.patientName || record.doctorName">
|
||||||
|
<div class="detail-row" *ngIf="record.diagnosisCode">
|
||||||
|
<span class="detail-label">Diagnosis Code:</span>
|
||||||
|
<span class="detail-value">{{ record.diagnosisCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="showAllRecords">
|
||||||
|
<span class="detail-label">Patient:</span>
|
||||||
|
<span class="detail-value">{{ record.patientName || record.patientId }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="record.doctorName">
|
||||||
|
<span class="detail-label">Doctor:</span>
|
||||||
|
<span class="detail-value">{{ record.doctorName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" *ngIf="getCurrentRecords().length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 2V8H20M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Medical Records</h3>
|
||||||
|
<p *ngIf="!localSelectedPatientId && !showAllRecords">Select a patient or view all records to see medical records.</p>
|
||||||
|
<p *ngIf="localSelectedPatientId || showAllRecords">No medical records found. Create your first record to get started.</p>
|
||||||
|
<button class="primary-button" (click)="openCreateMedicalRecord()" *ngIf="localSelectedPatientId || showAllRecords">
|
||||||
|
Create First Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vital Signs Tab -->
|
||||||
|
<div class="tab-content" *ngIf="activeTab === 'vitals' && localSelectedPatientId && !showAllRecords && !loading">
|
||||||
|
<!-- Latest Vital Signs Summary -->
|
||||||
|
<div class="card" *ngIf="latestVitalSigns">
|
||||||
|
<h3>Latest Vital Signs</h3>
|
||||||
|
<div class="vital-signs-summary">
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.temperature">
|
||||||
|
<span class="vital-label">Temperature</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.temperature }}°F</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.bloodPressureSystolic">
|
||||||
|
<span class="vital-label">Blood Pressure</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.bloodPressureSystolic }}/{{ latestVitalSigns.bloodPressureDiastolic }} mmHg</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.heartRate">
|
||||||
|
<span class="vital-label">Heart Rate</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.heartRate }} bpm</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.respiratoryRate">
|
||||||
|
<span class="vital-label">Respiratory Rate</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.respiratoryRate }} /min</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.oxygenSaturation">
|
||||||
|
<span class="vital-label">O2 Saturation</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.oxygenSaturation }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.weight">
|
||||||
|
<span class="vital-label">Weight</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.weight }} lbs</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.height">
|
||||||
|
<span class="vital-label">Height</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.height }} in</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-sign-item" *ngIf="latestVitalSigns.bmi">
|
||||||
|
<span class="vital-label">BMI</span>
|
||||||
|
<span class="vital-value">{{ latestVitalSigns.bmi | number:'1.1-1' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="vital-date">Recorded: {{ formatDateTime(latestVitalSigns.recordedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Vital Signs Form -->
|
||||||
|
<div class="card" *ngIf="showCreateVitalSigns">
|
||||||
|
<h3>Record Vital Signs</h3>
|
||||||
|
<form (ngSubmit)="createVitalSigns()">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Temperature (°F)</label>
|
||||||
|
<input type="number" step="0.1" [(ngModel)]="newVitalSigns.temperature" name="temperature" class="form-input" placeholder="98.6">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Blood Pressure Systolic (mmHg)</label>
|
||||||
|
<input type="number" [(ngModel)]="newVitalSigns.bloodPressureSystolic" name="bloodPressureSystolic" class="form-input" placeholder="120">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Blood Pressure Diastolic (mmHg)</label>
|
||||||
|
<input type="number" [(ngModel)]="newVitalSigns.bloodPressureDiastolic" name="bloodPressureDiastolic" class="form-input" placeholder="80">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Heart Rate (bpm)</label>
|
||||||
|
<input type="number" [(ngModel)]="newVitalSigns.heartRate" name="heartRate" class="form-input" placeholder="72">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Respiratory Rate (/min)</label>
|
||||||
|
<input type="number" [(ngModel)]="newVitalSigns.respiratoryRate" name="respiratoryRate" class="form-input" placeholder="16">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">O2 Saturation (%)</label>
|
||||||
|
<input type="number" step="0.1" [(ngModel)]="newVitalSigns.oxygenSaturation" name="oxygenSaturation" class="form-input" placeholder="98">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Weight (lbs)</label>
|
||||||
|
<input type="number" step="0.1" [(ngModel)]="newVitalSigns.weight" name="weight" class="form-input" placeholder="150">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Height (in)</label>
|
||||||
|
<input type="number" step="0.1" [(ngModel)]="newVitalSigns.height" name="height" class="form-input" placeholder="70">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea [(ngModel)]="newVitalSigns.notes" name="notes" class="form-input form-textarea" rows="3" placeholder="Additional notes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" (click)="showCreateVitalSigns = false">Cancel</button>
|
||||||
|
<button type="submit" [disabled]="loading">Save Vital Signs</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vital Signs History -->
|
||||||
|
<div class="card" *ngIf="!showCreateVitalSigns">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Vital Signs History ({{ vitalSigns.length }})</h3>
|
||||||
|
<button class="primary-button" (click)="openCreateVitalSigns()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Record Vital Signs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="vital-signs-list" *ngIf="vitalSigns.length > 0">
|
||||||
|
<div *ngFor="let vital of vitalSigns" class="vital-signs-card">
|
||||||
|
<div class="vital-header">
|
||||||
|
<span class="vital-date">{{ formatDateTime(vital.recordedAt) }}</span>
|
||||||
|
<button class="icon-button delete-button" (click)="deleteVitalSigns(vital.id)" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="vital-signs-grid">
|
||||||
|
<div class="vital-item" *ngIf="vital.temperature">
|
||||||
|
<span class="vital-label">Temp:</span>
|
||||||
|
<span class="vital-value">{{ vital.temperature }}°F</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.bloodPressureSystolic">
|
||||||
|
<span class="vital-label">BP:</span>
|
||||||
|
<span class="vital-value">{{ vital.bloodPressureSystolic }}/{{ vital.bloodPressureDiastolic }} mmHg</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.heartRate">
|
||||||
|
<span class="vital-label">HR:</span>
|
||||||
|
<span class="vital-value">{{ vital.heartRate }} bpm</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.respiratoryRate">
|
||||||
|
<span class="vital-label">RR:</span>
|
||||||
|
<span class="vital-value">{{ vital.respiratoryRate }} /min</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.oxygenSaturation">
|
||||||
|
<span class="vital-label">O2 Sat:</span>
|
||||||
|
<span class="vital-value">{{ vital.oxygenSaturation }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.weight">
|
||||||
|
<span class="vital-label">Weight:</span>
|
||||||
|
<span class="vital-value">{{ vital.weight }} lbs</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.height">
|
||||||
|
<span class="vital-label">Height:</span>
|
||||||
|
<span class="vital-value">{{ vital.height }} in</span>
|
||||||
|
</div>
|
||||||
|
<div class="vital-item" *ngIf="vital.bmi">
|
||||||
|
<span class="vital-label">BMI:</span>
|
||||||
|
<span class="vital-value">{{ vital.bmi | number:'1.1-1' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vital-notes" *ngIf="vital.notes">
|
||||||
|
<p><strong>Notes:</strong> {{ vital.notes }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" *ngIf="vitalSigns.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2V22M17 5H9.5C8.57174 5 7.6815 5.36875 7.02513 6.02513C6.36875 6.6815 6 7.57174 6 8.5C6 9.42826 6.36875 10.3185 7.02513 10.9749C7.6815 11.6312 8.57174 12 9.5 12H14.5C15.4283 12 16.3185 12.3687 16.9749 13.0251C17.6312 13.6815 18 14.5717 18 15.5C18 16.4283 17.6312 17.3185 16.9749 17.9749C16.3185 18.6312 15.4283 19 14.5 19H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Vital Signs Records</h3>
|
||||||
|
<p>No vital signs have been recorded for this patient yet.</p>
|
||||||
|
<button class="primary-button" (click)="openCreateVitalSigns()">Record First Vital Signs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lab Results Tab -->
|
||||||
|
<div class="tab-content" *ngIf="activeTab === 'labResults' && localSelectedPatientId && !showAllRecords && !loading">
|
||||||
|
<!-- Create/Edit Lab Result Form -->
|
||||||
|
<div class="card" *ngIf="showCreateLabResult">
|
||||||
|
<h3>{{ isUpdatingLabResult() ? 'Update' : 'Create' }} Lab Result</h3>
|
||||||
|
<form (ngSubmit)="createLabResult()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Test Name <span class="required-asterisk">*</span></label>
|
||||||
|
<input type="text" [(ngModel)]="newLabResult.testName" name="testName" required class="form-input" placeholder="e.g., Complete Blood Count">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Result Value <span class="required-asterisk">*</span></label>
|
||||||
|
<input type="text" [(ngModel)]="newLabResult.resultValue" name="resultValue" required class="form-input" placeholder="Enter result value">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Unit</label>
|
||||||
|
<input type="text" [(ngModel)]="newLabResult.unit" name="unit" class="form-input" placeholder="e.g., mg/dL">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Reference Range</label>
|
||||||
|
<input type="text" [(ngModel)]="newLabResult.referenceRange" name="referenceRange" class="form-input" placeholder="e.g., 70-100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select [(ngModel)]="newLabResult.status" name="status" class="form-input">
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="NORMAL">Normal</option>
|
||||||
|
<option value="ABNORMAL">Abnormal</option>
|
||||||
|
<option value="CRITICAL">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ordered Date</label>
|
||||||
|
<input type="date" [(ngModel)]="newLabResult.orderedDate" name="orderedDate" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Result Date</label>
|
||||||
|
<input type="date" [(ngModel)]="newLabResult.resultDate" name="resultDate" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea [(ngModel)]="newLabResult.notes" name="notes" class="form-input form-textarea" rows="3" placeholder="Additional notes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" (click)="cancelLabResultForm()">Cancel</button>
|
||||||
|
<button type="submit" [disabled]="loading">Save Lab Result</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lab Results List -->
|
||||||
|
<div class="card" *ngIf="!showCreateLabResult">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Lab Results ({{ labResults.length }})</h3>
|
||||||
|
<button class="primary-button" (click)="openCreateLabResult()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Add Lab Result
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="lab-results-list" *ngIf="labResults.length > 0">
|
||||||
|
<div *ngFor="let result of labResults" class="lab-result-card">
|
||||||
|
<div class="lab-result-header">
|
||||||
|
<div>
|
||||||
|
<h4>{{ result.testName }}</h4>
|
||||||
|
<div class="lab-result-meta">
|
||||||
|
<span class="lab-status" [class]="'status-' + result.status.toLowerCase()">{{ result.status }}</span>
|
||||||
|
<span class="lab-date">Ordered: {{ formatDate(result.orderedDate) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lab-result-actions">
|
||||||
|
<button class="icon-button" (click)="editLabResult(result)" title="Edit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button delete-button" (click)="deleteLabResult(result.id)" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lab-result-content">
|
||||||
|
<div class="lab-result-value">
|
||||||
|
<strong>Result:</strong> {{ result.resultValue }} <span *ngIf="result.unit">{{ result.unit }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lab-result-details">
|
||||||
|
<div class="detail-row" *ngIf="result.referenceRange">
|
||||||
|
<span class="detail-label">Reference Range:</span>
|
||||||
|
<span class="detail-value">{{ result.referenceRange }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="result.resultDate">
|
||||||
|
<span class="detail-label">Result Date:</span>
|
||||||
|
<span class="detail-value">{{ formatDate(result.resultDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="result.notes">
|
||||||
|
<span class="detail-label">Notes:</span>
|
||||||
|
<span class="detail-value">{{ result.notes }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" *ngIf="labResults.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Lab Results</h3>
|
||||||
|
<p>No lab results have been recorded for this patient yet.</p>
|
||||||
|
<button class="primary-button" (click)="openCreateLabResult()">Add First Lab Result</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Patient Profile Modal -->
|
||||||
|
<div class="modal-overlay" *ngIf="showPatientProfileModal" (click)="closePatientProfileModal()">
|
||||||
|
<div class="modal-content profile-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ selectedPatientProfile?.firstName }} {{ selectedPatientProfile?.lastName }} - Full Profile</h2>
|
||||||
|
<button class="modal-close" (click)="closePatientProfileModal()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" *ngIf="selectedPatientProfile">
|
||||||
|
<div class="profile-full-view">
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3>Basic Information</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Full Name:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.firstName }} {{ selectedPatientProfile.lastName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.email">
|
||||||
|
<span class="info-label">Email:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Phone:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.dateOfBirth">
|
||||||
|
<span class="info-label">Date of Birth:</span>
|
||||||
|
<span class="info-value">{{ formatDate(selectedPatientProfile.dateOfBirth) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.gender">
|
||||||
|
<span class="info-label">Gender:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.gender }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.bloodType">
|
||||||
|
<span class="info-label">Blood Type:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.bloodType }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.maritalStatus">
|
||||||
|
<span class="info-label">Marital Status:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.maritalStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.occupation">
|
||||||
|
<span class="info-label">Occupation:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.occupation }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.preferredLanguage">
|
||||||
|
<span class="info-label">Preferred Language:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.preferredLanguage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.streetAddress || selectedPatientProfile.city">
|
||||||
|
<h3>Address</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.streetAddress">
|
||||||
|
<span class="info-label">Street:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.streetAddress }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.city">
|
||||||
|
<span class="info-label">City:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.city }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.state">
|
||||||
|
<span class="info-label">State:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.state }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.zipCode">
|
||||||
|
<span class="info-label">Zip Code:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.zipCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.country">
|
||||||
|
<span class="info-label">Country:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.country }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emergency Contact -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.emergencyContactName">
|
||||||
|
<h3>Emergency Contact</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Name:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.emergencyContactName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.emergencyContactPhone">
|
||||||
|
<span class="info-label">Phone:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.emergencyContactPhone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Insurance -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.insuranceProvider">
|
||||||
|
<h3>Insurance Information</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Provider:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.insuranceProvider }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.insurancePolicyNumber">
|
||||||
|
<span class="info-label">Policy Number:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.insurancePolicyNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primary Care Physician -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.primaryCarePhysicianName">
|
||||||
|
<h3>Primary Care Physician</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Name:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.primaryCarePhysicianName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedPatientProfile.primaryCarePhysicianPhone">
|
||||||
|
<span class="info-label">Phone:</span>
|
||||||
|
<span class="info-value">{{ selectedPatientProfile.primaryCarePhysicianPhone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Allergies -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.allergies && selectedPatientProfile.allergies.length > 0">
|
||||||
|
<h3>Allergies</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let allergy of selectedPatientProfile.allergies">{{ allergy }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Medications -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.currentMedications && selectedPatientProfile.currentMedications.length > 0">
|
||||||
|
<h3>Current Medications</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let medication of selectedPatientProfile.currentMedications">{{ medication }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Medical History -->
|
||||||
|
<div class="profile-section" *ngIf="selectedPatientProfile.medicalHistorySummary">
|
||||||
|
<h3>Medical History Summary</h3>
|
||||||
|
<p class="biography-text">{{ selectedPatientProfile.medicalHistorySummary }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1490
frontend/src/app/pages/doctor/components/ehr/ehr.component.scss
Normal file
1490
frontend/src/app/pages/doctor/components/ehr/ehr.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
626
frontend/src/app/pages/doctor/components/ehr/ehr.component.ts
Normal file
626
frontend/src/app/pages/doctor/components/ehr/ehr.component.ts
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MedicalRecordService, MedicalRecord, MedicalRecordRequest, VitalSigns, VitalSignsRequest, LabResultRequest } from '../../../../services/medical-record.service';
|
||||||
|
import { UserService, PatientProfile } from '../../../../services/user.service';
|
||||||
|
import { ModalService } from '../../../../services/modal.service';
|
||||||
|
import { LoggerService } from '../../../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ehr',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './ehr.component.html',
|
||||||
|
styleUrl: './ehr.component.scss'
|
||||||
|
})
|
||||||
|
export class EhrComponent implements OnInit, OnChanges {
|
||||||
|
@Input() doctorId: string | null = null;
|
||||||
|
@Input() selectedPatientId: string | null = null;
|
||||||
|
@Input() patients: any[] = [];
|
||||||
|
@Output() patientSelected = new EventEmitter<string>();
|
||||||
|
@Output() dataChanged = new EventEmitter<void>();
|
||||||
|
|
||||||
|
// State
|
||||||
|
showAllRecords = false;
|
||||||
|
activeTab: 'records' | 'vitals' | 'labResults' = 'records';
|
||||||
|
loading = false;
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
// Local patient selection for dropdown (separate from Input)
|
||||||
|
localSelectedPatientId: string | null = null;
|
||||||
|
|
||||||
|
// Patient search
|
||||||
|
patientSearchQuery: string = '';
|
||||||
|
filteredPatients: any[] = [];
|
||||||
|
|
||||||
|
// Medical Records
|
||||||
|
medicalRecords: MedicalRecord[] = [];
|
||||||
|
allMedicalRecords: MedicalRecord[] = [];
|
||||||
|
showCreateMedicalRecord = false;
|
||||||
|
newMedicalRecord: MedicalRecordRequest = {
|
||||||
|
patientId: '',
|
||||||
|
doctorId: '',
|
||||||
|
recordType: 'NOTE',
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
diagnosisCode: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vital Signs
|
||||||
|
vitalSigns: VitalSigns[] = [];
|
||||||
|
latestVitalSigns: VitalSigns | null = null;
|
||||||
|
showCreateVitalSigns = false;
|
||||||
|
newVitalSigns: VitalSignsRequest = {
|
||||||
|
patientId: '',
|
||||||
|
temperature: undefined,
|
||||||
|
bloodPressureSystolic: undefined,
|
||||||
|
bloodPressureDiastolic: undefined,
|
||||||
|
heartRate: undefined,
|
||||||
|
respiratoryRate: undefined,
|
||||||
|
oxygenSaturation: undefined,
|
||||||
|
weight: undefined,
|
||||||
|
height: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lab Results
|
||||||
|
labResults: any[] = [];
|
||||||
|
showCreateLabResult = false;
|
||||||
|
showUpdateLabResult = false;
|
||||||
|
selectedLabResult: any = null;
|
||||||
|
newLabResult: LabResultRequest = {
|
||||||
|
patientId: undefined as any,
|
||||||
|
doctorId: '',
|
||||||
|
testName: '',
|
||||||
|
resultValue: '',
|
||||||
|
status: 'PENDING',
|
||||||
|
orderedDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patient Profile Modal
|
||||||
|
selectedPatientProfile: PatientProfile | null = null;
|
||||||
|
showPatientProfileModal = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private medicalRecordService: MedicalRecordService,
|
||||||
|
private userService: UserService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.doctorId) {
|
||||||
|
this.newMedicalRecord.doctorId = this.doctorId;
|
||||||
|
this.newLabResult.doctorId = this.doctorId;
|
||||||
|
this.loadAllDoctorRecords();
|
||||||
|
}
|
||||||
|
// Initialize local selection from Input if provided, but don't auto-load
|
||||||
|
// This allows parent to set initial selection, but user must explicitly choose
|
||||||
|
if (this.selectedPatientId) {
|
||||||
|
this.localSelectedPatientId = this.selectedPatientId;
|
||||||
|
}
|
||||||
|
// Initialize filtered patients
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
|
if (changes['doctorId'] && this.doctorId) {
|
||||||
|
this.newMedicalRecord.doctorId = this.doctorId;
|
||||||
|
this.newLabResult.doctorId = this.doctorId;
|
||||||
|
if (!this.allMedicalRecords.length) {
|
||||||
|
this.loadAllDoctorRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update local selection when Input changes, but don't auto-load data
|
||||||
|
// User must explicitly select a patient from the dropdown
|
||||||
|
if (changes['selectedPatientId']) {
|
||||||
|
if (this.selectedPatientId) {
|
||||||
|
this.localSelectedPatientId = this.selectedPatientId;
|
||||||
|
} else {
|
||||||
|
this.localSelectedPatientId = null;
|
||||||
|
this.showAllRecords = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update filtered patients when patients list changes
|
||||||
|
if (changes['patients']) {
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllDoctorRecords() {
|
||||||
|
if (!this.doctorId) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.allMedicalRecords = await this.medicalRecordService.getMedicalRecordsByDoctorId(this.doctorId);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading all doctor records:', e);
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to load medical records';
|
||||||
|
this.allMedicalRecords = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPatientData() {
|
||||||
|
if (!this.localSelectedPatientId) return;
|
||||||
|
await Promise.all([
|
||||||
|
this.loadPatientMedicalRecords(),
|
||||||
|
this.loadPatientVitalSigns(),
|
||||||
|
this.loadPatientLabResults()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPatientMedicalRecords() {
|
||||||
|
if (!this.localSelectedPatientId) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.medicalRecords = await this.medicalRecordService.getMedicalRecordsByPatientId(this.localSelectedPatientId);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading medical records:', e);
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to load medical records';
|
||||||
|
this.medicalRecords = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPatientVitalSigns() {
|
||||||
|
if (!this.localSelectedPatientId) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
[this.vitalSigns, this.latestVitalSigns] = await Promise.all([
|
||||||
|
this.medicalRecordService.getVitalSignsByPatientId(this.localSelectedPatientId),
|
||||||
|
this.medicalRecordService.getLatestVitalSignsByPatientId(this.localSelectedPatientId)
|
||||||
|
]);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading vital signs:', e);
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to load vital signs';
|
||||||
|
this.vitalSigns = [];
|
||||||
|
this.latestVitalSigns = null;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPatientLabResults() {
|
||||||
|
if (!this.localSelectedPatientId) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.labResults = await this.medicalRecordService.getLabResultsByPatientId(this.localSelectedPatientId);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading lab results:', e);
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to load lab results';
|
||||||
|
this.labResults = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPatient(patientId: string | null) {
|
||||||
|
if (!patientId) {
|
||||||
|
// Clear selection
|
||||||
|
this.localSelectedPatientId = null;
|
||||||
|
this.selectedPatientId = null;
|
||||||
|
this.showAllRecords = true;
|
||||||
|
this.patientSelected.emit('');
|
||||||
|
// Clear patient-specific data
|
||||||
|
this.medicalRecords = [];
|
||||||
|
this.vitalSigns = [];
|
||||||
|
this.latestVitalSigns = null;
|
||||||
|
this.labResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User explicitly selected a patient - load their data
|
||||||
|
this.localSelectedPatientId = patientId;
|
||||||
|
this.selectedPatientId = patientId;
|
||||||
|
this.showAllRecords = false;
|
||||||
|
this.patientSelected.emit(patientId);
|
||||||
|
this.loadPatientData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleRecordsView() {
|
||||||
|
this.showAllRecords = !this.showAllRecords;
|
||||||
|
if (this.showAllRecords) {
|
||||||
|
await this.loadAllDoctorRecords();
|
||||||
|
// Clear patient selection when showing all records
|
||||||
|
this.localSelectedPatientId = null;
|
||||||
|
} else if (this.localSelectedPatientId) {
|
||||||
|
// Only load if patient is explicitly selected
|
||||||
|
await this.loadPatientData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medical Records Methods
|
||||||
|
openCreateMedicalRecord() {
|
||||||
|
this.showCreateMedicalRecord = !this.showCreateMedicalRecord;
|
||||||
|
if (this.showCreateMedicalRecord) {
|
||||||
|
this.newMedicalRecord = {
|
||||||
|
patientId: this.localSelectedPatientId || '',
|
||||||
|
doctorId: this.doctorId || '',
|
||||||
|
recordType: 'NOTE',
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
diagnosisCode: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMedicalRecord() {
|
||||||
|
if (!this.newMedicalRecord.patientId || !this.newMedicalRecord.title || !this.newMedicalRecord.content) {
|
||||||
|
this.error = 'Please fill in all required fields';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.newMedicalRecord.doctorId) {
|
||||||
|
this.newMedicalRecord.doctorId = this.doctorId || '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const recordId = (this.newMedicalRecord as any).recordId;
|
||||||
|
if (recordId) {
|
||||||
|
await this.medicalRecordService.updateMedicalRecord(recordId, this.newMedicalRecord);
|
||||||
|
} else {
|
||||||
|
await this.medicalRecordService.createMedicalRecord(this.newMedicalRecord);
|
||||||
|
}
|
||||||
|
this.showCreateMedicalRecord = false;
|
||||||
|
this.newMedicalRecord = {
|
||||||
|
patientId: '',
|
||||||
|
doctorId: this.doctorId || '',
|
||||||
|
recordType: 'NOTE',
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
diagnosisCode: ''
|
||||||
|
};
|
||||||
|
delete (this.newMedicalRecord as any).recordId;
|
||||||
|
await Promise.all([
|
||||||
|
this.localSelectedPatientId ? this.loadPatientMedicalRecords() : Promise.resolve(),
|
||||||
|
this.loadAllDoctorRecords()
|
||||||
|
]);
|
||||||
|
this.dataChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error creating/updating medical record:', e);
|
||||||
|
const recordId = (this.newMedicalRecord as any).recordId;
|
||||||
|
const errorMessage = e?.response?.data?.message ||
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.message ||
|
||||||
|
(recordId ? 'Failed to update medical record' : 'Failed to create medical record');
|
||||||
|
this.error = errorMessage;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 5000);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editMedicalRecord(record: MedicalRecord) {
|
||||||
|
this.newMedicalRecord = {
|
||||||
|
patientId: record.patientId,
|
||||||
|
doctorId: record.doctorId,
|
||||||
|
appointmentId: record.appointmentId,
|
||||||
|
recordType: record.recordType,
|
||||||
|
title: record.title,
|
||||||
|
content: record.content,
|
||||||
|
diagnosisCode: record.diagnosisCode
|
||||||
|
};
|
||||||
|
this.showCreateMedicalRecord = true;
|
||||||
|
(this.newMedicalRecord as any).recordId = record.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMedicalRecord(recordId: string) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this medical record? This action cannot be undone.',
|
||||||
|
'Delete Medical Record',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await this.medicalRecordService.deleteMedicalRecord(recordId);
|
||||||
|
if (this.showAllRecords) {
|
||||||
|
await this.loadAllDoctorRecords();
|
||||||
|
} else if (this.localSelectedPatientId) {
|
||||||
|
await this.loadPatientMedicalRecords();
|
||||||
|
}
|
||||||
|
this.dataChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to delete medical record';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vital Signs Methods
|
||||||
|
openCreateVitalSigns() {
|
||||||
|
this.showCreateVitalSigns = !this.showCreateVitalSigns;
|
||||||
|
if (this.showCreateVitalSigns) {
|
||||||
|
this.newVitalSigns = {
|
||||||
|
patientId: this.localSelectedPatientId || '',
|
||||||
|
temperature: undefined,
|
||||||
|
bloodPressureSystolic: undefined,
|
||||||
|
bloodPressureDiastolic: undefined,
|
||||||
|
heartRate: undefined,
|
||||||
|
respiratoryRate: undefined,
|
||||||
|
oxygenSaturation: undefined,
|
||||||
|
weight: undefined,
|
||||||
|
height: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVitalSigns() {
|
||||||
|
if (!this.newVitalSigns.patientId) {
|
||||||
|
this.error = 'Please select a patient';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await this.medicalRecordService.createVitalSigns(this.newVitalSigns);
|
||||||
|
this.showCreateVitalSigns = false;
|
||||||
|
this.newVitalSigns = {
|
||||||
|
patientId: '',
|
||||||
|
temperature: undefined,
|
||||||
|
bloodPressureSystolic: undefined,
|
||||||
|
bloodPressureDiastolic: undefined,
|
||||||
|
heartRate: undefined,
|
||||||
|
respiratoryRate: undefined,
|
||||||
|
oxygenSaturation: undefined,
|
||||||
|
weight: undefined,
|
||||||
|
height: undefined,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
if (this.localSelectedPatientId) {
|
||||||
|
await this.loadPatientVitalSigns();
|
||||||
|
}
|
||||||
|
this.dataChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to create vital signs';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVitalSigns(vitalSignsId: string) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this vital signs record?',
|
||||||
|
'Delete Vital Signs',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await this.medicalRecordService.deleteVitalSigns(vitalSignsId);
|
||||||
|
if (this.localSelectedPatientId) {
|
||||||
|
await this.loadPatientVitalSigns();
|
||||||
|
}
|
||||||
|
this.dataChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to delete vital signs';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lab Results Methods
|
||||||
|
openCreateLabResult() {
|
||||||
|
this.showCreateLabResult = !this.showCreateLabResult;
|
||||||
|
this.showUpdateLabResult = false;
|
||||||
|
if (this.showCreateLabResult) {
|
||||||
|
this.newLabResult = {
|
||||||
|
patientId: undefined as any,
|
||||||
|
doctorId: this.doctorId || '',
|
||||||
|
testName: '',
|
||||||
|
resultValue: '',
|
||||||
|
status: 'PENDING',
|
||||||
|
orderedDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
if (this.localSelectedPatientId) {
|
||||||
|
this.newLabResult.patientId = this.localSelectedPatientId as any;
|
||||||
|
}
|
||||||
|
delete (this.newLabResult as any).labResultId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLabResult() {
|
||||||
|
// Auto-fill patientId from context if missing
|
||||||
|
if (!this.newLabResult.patientId) {
|
||||||
|
if (this.localSelectedPatientId) {
|
||||||
|
this.newLabResult.patientId = this.localSelectedPatientId as any;
|
||||||
|
} else if (Array.isArray(this.patients) && this.patients.length === 1) {
|
||||||
|
this.newLabResult.patientId = this.patients[0].id as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If patientId is not in known patient IDs, try to resolve from userId
|
||||||
|
if (this.newLabResult.patientId && Array.isArray(this.patients) && !this.patients.some(p => p.id === this.newLabResult.patientId)) {
|
||||||
|
try {
|
||||||
|
const resolved = await this.userService.getPatientIdByUserId(this.newLabResult.patientId as any);
|
||||||
|
if (resolved) {
|
||||||
|
this.newLabResult.patientId = resolved as any;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!this.newLabResult.patientId) missing.push('patient');
|
||||||
|
if (!this.newLabResult.testName) missing.push('test name');
|
||||||
|
if (!this.newLabResult.resultValue) missing.push('result value');
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
this.error = `Please fill in all required fields: ${missing.join(', ')}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const labResultId = (this.newLabResult as any).labResultId;
|
||||||
|
if (labResultId) {
|
||||||
|
await this.medicalRecordService.updateLabResult(labResultId, this.newLabResult);
|
||||||
|
} else {
|
||||||
|
await this.medicalRecordService.createLabResult(this.newLabResult);
|
||||||
|
}
|
||||||
|
this.showCreateLabResult = false;
|
||||||
|
this.showUpdateLabResult = false;
|
||||||
|
this.newLabResult = {
|
||||||
|
patientId: undefined as any,
|
||||||
|
doctorId: this.doctorId || '',
|
||||||
|
testName: '',
|
||||||
|
resultValue: '',
|
||||||
|
status: 'PENDING',
|
||||||
|
orderedDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
delete (this.newLabResult as any).labResultId;
|
||||||
|
if (this.localSelectedPatientId) {
|
||||||
|
await this.loadPatientLabResults();
|
||||||
|
}
|
||||||
|
this.dataChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('[CreateLabResult] Error:', e);
|
||||||
|
let errorMsg = 'Failed to create lab result';
|
||||||
|
if (e?.response?.data) {
|
||||||
|
if (typeof e.response.data === 'string') {
|
||||||
|
errorMsg = e.response.data;
|
||||||
|
} else if (e.response.data.error) {
|
||||||
|
errorMsg = e.response.data.error;
|
||||||
|
} else if (e.response.data.message) {
|
||||||
|
errorMsg = e.response.data.message;
|
||||||
|
}
|
||||||
|
} else if (e?.message) {
|
||||||
|
errorMsg = e.message;
|
||||||
|
}
|
||||||
|
this.error = errorMsg;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editLabResult(labResult: any) {
|
||||||
|
this.selectedLabResult = labResult;
|
||||||
|
this.newLabResult = {
|
||||||
|
patientId: labResult.patientId,
|
||||||
|
doctorId: labResult.doctorId,
|
||||||
|
testName: labResult.testName,
|
||||||
|
resultValue: labResult.resultValue,
|
||||||
|
referenceRange: labResult.referenceRange,
|
||||||
|
unit: labResult.unit,
|
||||||
|
status: labResult.status,
|
||||||
|
orderedDate: labResult.orderedDate,
|
||||||
|
resultDate: labResult.resultDate,
|
||||||
|
notes: labResult.notes
|
||||||
|
};
|
||||||
|
this.showCreateLabResult = true;
|
||||||
|
this.showUpdateLabResult = true;
|
||||||
|
(this.newLabResult as any).labResultId = labResult.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLabResult(labResultId: string) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this lab result?',
|
||||||
|
'Delete Lab Result',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await this.medicalRecordService.deleteLabResult(labResultId);
|
||||||
|
if (this.localSelectedPatientId) {
|
||||||
|
await this.loadPatientLabResults();
|
||||||
|
}
|
||||||
|
this.dataChanged.emit();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to delete lab result';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateString: string): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(dateString: string): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentRecords() {
|
||||||
|
return this.showAllRecords ? this.allMedicalRecords : this.medicalRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingMedicalRecord(): boolean {
|
||||||
|
return !!(this.newMedicalRecord as any).recordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingLabResult(): boolean {
|
||||||
|
return !!(this.newLabResult as any).labResultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMedicalRecordForm() {
|
||||||
|
this.showCreateMedicalRecord = false;
|
||||||
|
delete (this.newMedicalRecord as any).recordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelLabResultForm() {
|
||||||
|
this.showCreateLabResult = false;
|
||||||
|
this.showUpdateLabResult = false;
|
||||||
|
delete (this.newLabResult as any).labResultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewPatientProfile(patientId: string) {
|
||||||
|
try {
|
||||||
|
this.selectedPatientProfile = await this.userService.getPatientProfileById(patientId);
|
||||||
|
this.showPatientProfileModal = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.message || 'Failed to load patient profile';
|
||||||
|
this.logger.error('Failed to load patient profile:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closePatientProfileModal() {
|
||||||
|
this.showPatientProfileModal = false;
|
||||||
|
this.selectedPatientProfile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterPatients() {
|
||||||
|
if (!this.patientSearchQuery || this.patientSearchQuery.trim() === '') {
|
||||||
|
this.filteredPatients = this.patients || [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = this.patientSearchQuery.toLowerCase().trim();
|
||||||
|
this.filteredPatients = (this.patients || []).filter(patient => {
|
||||||
|
const firstName = (patient.firstName || '').toLowerCase();
|
||||||
|
const lastName = (patient.lastName || '').toLowerCase();
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
const email = (patient.email || '').toLowerCase();
|
||||||
|
|
||||||
|
return firstName.includes(query) ||
|
||||||
|
lastName.includes(query) ||
|
||||||
|
fullName.includes(query) ||
|
||||||
|
email.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPatientSearchChange() {
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPatientSearch() {
|
||||||
|
this.patientSearchQuery = '';
|
||||||
|
this.filterPatients();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<section class="patients-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Search Patients</h2>
|
||||||
|
<p class="section-description">Search all patients in the system. Create appointments or start chats with them.</p>
|
||||||
|
</div>
|
||||||
|
<button class="refresh-button" (click)="loadPatients()" [disabled]="loading || loadingPatients" title="Refresh Patients">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 4V10H7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M23 20V14H17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14L18.36 18.36A9 9 0 0 1 3.51 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-container" style="display: block !important; visibility: visible !important; opacity: 1 !important;">
|
||||||
|
<div class="search-box" style="display: flex !important; visibility: visible !important;">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search patients by name, email, or phone..."
|
||||||
|
[ngModel]="searchTerm"
|
||||||
|
(ngModelChange)="searchTerm = $event; onSearchChange()"
|
||||||
|
(input)="onSearchChange()"
|
||||||
|
(keyup)="onSearchChange()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="clear-search-btn"
|
||||||
|
*ngIf="searchTerm"
|
||||||
|
(click)="clearSearch()"
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-results-info" *ngIf="searchTerm && patients.length > 0">
|
||||||
|
Showing {{ filteredPatients.length }} of {{ patients.length }} patient{{ patients.length !== 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="patients-grid" *ngIf="filteredPatients.length > 0">
|
||||||
|
<div class="patient-card" *ngFor="let patient of filteredPatients">
|
||||||
|
<div class="patient-header">
|
||||||
|
<div class="patient-avatar">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="patient-info">
|
||||||
|
<h3 class="patient-name">{{ patient.firstName }} {{ patient.lastName }}</h3>
|
||||||
|
<p class="patient-email" *ngIf="patient.email">{{ patient.email }}</p>
|
||||||
|
<p class="patient-id" *ngIf="patient.id">ID: {{ patient.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="patient-details">
|
||||||
|
<div class="detail-row" *ngIf="patient.phoneNumber">
|
||||||
|
<span class="detail-label">Phone:</span>
|
||||||
|
<span class="detail-value">{{ patient.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="patient.bloodType">
|
||||||
|
<span class="detail-label">Blood Type:</span>
|
||||||
|
<span class="detail-value">{{ patient.bloodType }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="patient-actions">
|
||||||
|
<button class="btn-action btn-chat" (click)="startChat(patient)" [disabled]="loading || loadingPatients" title="Start chat">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-appointment" (click)="createAppointment(patient)" [disabled]="loading || loadingPatients" title="Create appointment">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Appointment
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-remove" (click)="removePatientFromHistory(patient)" [disabled]="loading || loadingPatients" title="Remove from history">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" *ngIf="!loadingPatients && patients.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Patients Found</h3>
|
||||||
|
<p>No patients are registered in the system yet. Use the search bar to find patients.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-state" *ngIf="loadingPatients">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading patients...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state search-empty" *ngIf="patients.length > 0 && filteredPatients.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Patients Found</h3>
|
||||||
|
<p>No patients match your search criteria. Try adjusting your search terms.</p>
|
||||||
|
<button class="clear-search-link" (click)="clearSearch()">Clear search</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Enterprise Patients Component Styles
|
||||||
|
// Modern, Professional, and Responsive Styling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Root & Variables
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
:host,
|
||||||
|
app-patients {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Section Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patients-section {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Header
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
margin-bottom: var(--space-2xl, 3rem);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
margin-bottom: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Section Title
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-3xl, 1.875rem);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-sm, 0.5rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: var(--space-md, 1rem) 0 0 0;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Refresh Button
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transition: transform var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary-dark, #1e40af) 0%, var(--color-primary, #2563eb) 100%);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Search Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: var(--space-xl, 2rem);
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border: 2px solid var(--color-gray-300, #d1d5db);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)), 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
background: transparent;
|
||||||
|
min-width: 200px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: var(--space-xs, 0.25rem);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-gray-100, #f3f4f6);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-xs, 0.375rem) var(--space-sm, 0.75rem);
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-info {
|
||||||
|
margin-top: var(--space-sm, 0.5rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
padding-left: var(--space-xs, 0.25rem);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search-link {
|
||||||
|
margin-top: var(--space-md, 1rem);
|
||||||
|
padding: var(--space-xs, 0.5rem) var(--space-md, 1rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
background: var(--color-primary-50, #eff6ff);
|
||||||
|
border: 1px solid var(--color-primary-200, #bfdbfe);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary-100, #dbeafe);
|
||||||
|
border-color: var(--color-primary-300, #93c5fd);
|
||||||
|
color: var(--color-primary-700, #1d4ed8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patients Grid
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patients-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
gap: var(--space-lg, 1.5rem);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Card
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-card {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
padding: var(--space-lg, 1.5rem);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06));
|
||||||
|
border: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05));
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-md, 1rem);
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Header
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md, 1rem);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
padding-bottom: var(--space-lg, 1.5rem);
|
||||||
|
border-bottom: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Avatar
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card:hover & {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
min-width: 56px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-name {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-xs, 0.25rem) 0;
|
||||||
|
line-height: var(--line-height-tight, 1.25);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-email {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0 0 var(--space-xs, 0.25rem) 0;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '✉';
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-id {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
color: var(--text-tertiary, #6b7280);
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
font-family: 'Monaco', 'Courier New', monospace;
|
||||||
|
background: var(--color-gray-100, #f3f4f6);
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: 'ID: ';
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Details
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
padding: var(--space-sm, 0.5rem) 0;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--color-gray-100, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Empty State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl, 4rem) var(--space-xl, 2rem);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border-radius: var(--radius-xl, 1rem);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
border: 2px dashed var(--color-gray-300, #d1d5db);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
color: var(--color-gray-400, #9ca3af);
|
||||||
|
margin-bottom: var(--space-lg, 1.5rem);
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-2xl, 1.5rem);
|
||||||
|
font-weight: var(--font-weight-semibold, 600);
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
margin: 0 0 var(--space-sm, 0.5rem) 0;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: var(--line-height-normal, 1.5);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
border-color: var(--color-gray-300, #d1d5db);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
color: var(--color-gray-400, #9ca3af);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patients-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl, 4rem);
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: var(--space-sm, 0.5rem);
|
||||||
|
border: 3px solid var(--color-gray-300, #d1d5db);
|
||||||
|
border-top-color: var(--color-primary, #2563eb);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patient Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.patient-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm, 0.5rem);
|
||||||
|
margin-top: var(--space-md, 1rem);
|
||||||
|
padding-top: var(--space-md, 1rem);
|
||||||
|
border-top: 1px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs, 0.25rem);
|
||||||
|
padding: var(--space-xs, 0.5rem) var(--space-sm, 0.75rem);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: var(--font-weight-medium, 500);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
border: 1px solid;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: var(--space-xs, 0.375rem) var(--space-xs, 0.5rem);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chat {
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
background: var(--color-primary-50, #eff6ff);
|
||||||
|
border-color: var(--color-primary-200, #bfdbfe);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-100, #dbeafe);
|
||||||
|
border-color: var(--color-primary-300, #93c5fd);
|
||||||
|
color: var(--color-primary-700, #1d4ed8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-appointment {
|
||||||
|
color: var(--color-green-600, #16a34a);
|
||||||
|
background: var(--color-green-50, #f0fdf4);
|
||||||
|
border-color: var(--color-green-200, #bbf7d0);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-green-100, #dcfce7);
|
||||||
|
border-color: var(--color-green-300, #86efac);
|
||||||
|
color: var(--color-green-700, #15803d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
color: var(--color-red-600, #dc2626);
|
||||||
|
background: var(--color-red-50, #fef2f2);
|
||||||
|
border-color: var(--color-red-200, #fecaca);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-red-100, #fee2e2);
|
||||||
|
border-color: var(--color-red-300, #fca5a5);
|
||||||
|
color: var(--color-red-700, #b91c1c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl, 4rem) var(--space-xl, 2rem);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--color-gray-200, #e5e7eb);
|
||||||
|
border-top-color: var(--color-primary, #2563eb);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responsive Enhancements
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.patient-card {
|
||||||
|
border-radius: var(--radius-lg, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-header {
|
||||||
|
padding-bottom: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Print Styles
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.patients-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-card {
|
||||||
|
break-inside: avoid;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--color-gray-300, #d1d5db);
|
||||||
|
margin-bottom: var(--space-md, 1rem);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.patients-grid {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { Component, Input, OnInit, OnChanges, SimpleChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { UserService } from '../../../../services/user.service';
|
||||||
|
import { AppointmentService } from '../../../../services/appointment.service';
|
||||||
|
import { ModalService } from '../../../../services/modal.service';
|
||||||
|
import { ChatService } from '../../../../services/chat.service';
|
||||||
|
import { LoggerService } from '../../../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patients',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './patients.component.html',
|
||||||
|
styleUrl: './patients.component.scss'
|
||||||
|
})
|
||||||
|
export class PatientsComponent implements OnInit, OnChanges {
|
||||||
|
@Input() patients: any[] = []; // Fallback from appointments
|
||||||
|
@Output() patientRemoved = new EventEmitter<void>();
|
||||||
|
@Output() patientSelected = new EventEmitter<any>();
|
||||||
|
@Output() createAppointmentRequested = new EventEmitter<any>();
|
||||||
|
@Output() startChatRequested = new EventEmitter<any>();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
loadingPatients = false;
|
||||||
|
searchTerm: string = '';
|
||||||
|
filteredPatients: any[] = [];
|
||||||
|
allPatients: any[] = []; // All patients from system
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private userService: UserService,
|
||||||
|
private appointmentService: AppointmentService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private chatService: ChatService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
this.logger.debug('[PatientsComponent] ngOnInit - patients:', this.patients.length);
|
||||||
|
// Load all patients from system
|
||||||
|
await this.loadAllPatients();
|
||||||
|
// Initialize filtered patients
|
||||||
|
this.applySearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllPatients() {
|
||||||
|
try {
|
||||||
|
this.loadingPatients = true;
|
||||||
|
const patients = await this.userService.getAllPatients();
|
||||||
|
this.logger.debug('[PatientsComponent] Loaded all patients from system:', patients.length);
|
||||||
|
|
||||||
|
// Map PatientProfile to the format we need
|
||||||
|
// PatientProfile has firstName, lastName, email, phoneNumber directly (not nested in user)
|
||||||
|
// Now also includes userId for chat functionality
|
||||||
|
this.allPatients = patients.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
userId: p.userId, // Include userId for chat functionality
|
||||||
|
firstName: p.firstName || '',
|
||||||
|
lastName: p.lastName || '',
|
||||||
|
email: p.email || '',
|
||||||
|
phoneNumber: p.phoneNumber || '',
|
||||||
|
displayName: `${p.firstName || ''} ${p.lastName || ''}`.trim(),
|
||||||
|
bloodType: p.bloodType,
|
||||||
|
allergies: p.allergies || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge with patients from appointments (if any) to avoid duplicates
|
||||||
|
const existingIds = new Set(this.allPatients.map(p => p.id));
|
||||||
|
const newPatients = this.patients.filter(p => p.id && !existingIds.has(p.id));
|
||||||
|
this.allPatients = [...this.allPatients, ...newPatients];
|
||||||
|
|
||||||
|
// Update patients array to include all
|
||||||
|
this.patients = this.allPatients;
|
||||||
|
this.applySearch();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('[PatientsComponent] Error loading all patients:', error);
|
||||||
|
// Fallback to patients from input
|
||||||
|
this.patients = this.patients || [];
|
||||||
|
this.applySearch();
|
||||||
|
} finally {
|
||||||
|
this.loadingPatients = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
|
if (changes['patients'] && !changes['patients'].firstChange) {
|
||||||
|
this.logger.debug('[PatientsComponent] ngOnChanges - patients changed:', {
|
||||||
|
previousValue: changes['patients'].previousValue?.length,
|
||||||
|
currentValue: changes['patients'].currentValue?.length
|
||||||
|
});
|
||||||
|
// Merge with all patients if needed
|
||||||
|
if (this.allPatients.length > 0) {
|
||||||
|
const existingIds = new Set(this.allPatients.map(p => p.id));
|
||||||
|
const newPatients = this.patients.filter(p => p.id && !existingIds.has(p.id));
|
||||||
|
this.allPatients = [...this.allPatients, ...newPatients];
|
||||||
|
this.patients = this.allPatients;
|
||||||
|
}
|
||||||
|
this.applySearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySearch() {
|
||||||
|
this.logger.debug('[PatientsComponent] applySearch called', {
|
||||||
|
searchTerm: this.searchTerm,
|
||||||
|
patientsCount: this.patients.length,
|
||||||
|
filteredCount: this.filteredPatients.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no search term, show all patients
|
||||||
|
if (!this.searchTerm || this.searchTerm.trim() === '') {
|
||||||
|
this.filteredPatients = [...this.patients];
|
||||||
|
this.logger.debug('[PatientsComponent] No search term, showing all patients:', this.filteredPatients.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter patients based on search term
|
||||||
|
const search = this.searchTerm.toLowerCase().trim();
|
||||||
|
this.logger.debug('[PatientsComponent] Filtering with search term:', search);
|
||||||
|
|
||||||
|
this.filteredPatients = this.patients.filter(patient => {
|
||||||
|
// Search by name (first name + last name)
|
||||||
|
const fullName = `${patient.firstName || ''} ${patient.lastName || ''}`.toLowerCase();
|
||||||
|
const nameMatch = fullName.includes(search);
|
||||||
|
|
||||||
|
// Search by email
|
||||||
|
const emailMatch = patient.email && patient.email.toLowerCase().includes(search);
|
||||||
|
|
||||||
|
// Search by phone number
|
||||||
|
const phoneMatch = patient.phoneNumber && patient.phoneNumber.toLowerCase().includes(search);
|
||||||
|
|
||||||
|
const matches = nameMatch || emailMatch || phoneMatch;
|
||||||
|
if (matches) {
|
||||||
|
this.logger.debug('[PatientsComponent] Patient matches:', patient.firstName, patient.lastName);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug('[PatientsComponent] Filtered results:', this.filteredPatients.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange() {
|
||||||
|
this.logger.debug('[PatientsComponent] onSearchChange called, searchTerm:', this.searchTerm);
|
||||||
|
this.applySearch();
|
||||||
|
this.cdr.detectChanges(); // Force change detection
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch() {
|
||||||
|
this.searchTerm = '';
|
||||||
|
this.applySearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPatients() {
|
||||||
|
// Reload all patients from system
|
||||||
|
await this.loadAllPatients();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAppointment(patient: any) {
|
||||||
|
this.createAppointmentRequested.emit(patient);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startChat(patient: any) {
|
||||||
|
try {
|
||||||
|
// Get the user ID for the patient
|
||||||
|
// PatientProfile now includes userId directly
|
||||||
|
let userId: string | null = patient.userId || null;
|
||||||
|
|
||||||
|
// If userId is not available, try to find it by searching (fallback)
|
||||||
|
if (!userId) {
|
||||||
|
this.logger.warn('[PatientsComponent] userId not found in patient object, searching...', patient);
|
||||||
|
|
||||||
|
// Try searching by full name
|
||||||
|
if (patient.firstName && patient.lastName) {
|
||||||
|
try {
|
||||||
|
const fullName = `${patient.firstName} ${patient.lastName}`;
|
||||||
|
const chatUsers = await this.chatService.searchPatients(fullName);
|
||||||
|
const matchingUser = chatUsers.find(u =>
|
||||||
|
u.firstName?.toLowerCase() === patient.firstName?.toLowerCase() &&
|
||||||
|
u.lastName?.toLowerCase() === patient.lastName?.toLowerCase()
|
||||||
|
);
|
||||||
|
if (matchingUser) {
|
||||||
|
userId = matchingUser.userId;
|
||||||
|
this.logger.debug('[PatientsComponent] Found user ID by name search:', userId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn('[PatientsComponent] Could not search by name:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
this.logger.error('[PatientsComponent] Could not find user ID for patient:', {
|
||||||
|
id: patient.id,
|
||||||
|
userId: patient.userId,
|
||||||
|
firstName: patient.firstName,
|
||||||
|
lastName: patient.lastName,
|
||||||
|
email: patient.email
|
||||||
|
});
|
||||||
|
await this.modalService.alert(
|
||||||
|
`Could not find patient user information for ${patient.firstName} ${patient.lastName}. The patient may not be available for chat.`,
|
||||||
|
'error',
|
||||||
|
'Error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start chat with patient
|
||||||
|
this.logger.debug('[PatientsComponent] Starting chat with userId:', userId);
|
||||||
|
this.startChatRequested.emit({
|
||||||
|
userId: userId,
|
||||||
|
patientId: patient.id,
|
||||||
|
patientName: `${patient.firstName} ${patient.lastName}`
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('[PatientsComponent] Error starting chat:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.message || 'Failed to start chat with patient',
|
||||||
|
'error',
|
||||||
|
'Error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewPatient(patient: any) {
|
||||||
|
this.patientSelected.emit(patient);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePatientFromHistory(patient: any) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
`Are you sure you want to remove ${patient.firstName} ${patient.lastName} from your history? This will hide all appointments with this patient from your view. This action cannot be undone.`,
|
||||||
|
'Remove Patient from History',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await this.appointmentService.removePatientFromHistory(patient.id);
|
||||||
|
await this.modalService.alert(
|
||||||
|
'Patient removed from history successfully',
|
||||||
|
'success',
|
||||||
|
'Success'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from local list immediately (optimistic update)
|
||||||
|
this.patients = this.patients.filter(p => p.id !== patient.id);
|
||||||
|
this.filteredPatients = this.filteredPatients.filter(p => p.id !== patient.id);
|
||||||
|
|
||||||
|
// Emit event to parent to refresh data (will update the @Input binding)
|
||||||
|
this.patientRemoved.emit();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error removing patient from history:', error);
|
||||||
|
await this.modalService.alert(
|
||||||
|
error?.message || 'Failed to remove patient from history',
|
||||||
|
'error',
|
||||||
|
'Error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<section class="prescriptions-section">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Prescription Management</h2>
|
||||||
|
<p class="section-description">Manage prescriptions for your patients</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="secondary-button" (click)="showPrescriptionAnalytics = !showPrescriptionAnalytics">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 3V21H21M7 18L12 8L16 13L21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Analytics
|
||||||
|
</button>
|
||||||
|
<button class="secondary-button" (click)="exportPrescriptions()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15M17 8L12 3M12 3L7 8M12 3V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button class="primary-button" (click)="openCreatePrescription()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Create Prescription
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics Dashboard -->
|
||||||
|
<div class="prescription-analytics" *ngIf="showPrescriptionAnalytics">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon total">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ getPrescriptionStatistics().total }}</h3>
|
||||||
|
<p>Total Prescriptions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon active">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ getPrescriptionStatistics().active }}</h3>
|
||||||
|
<p>Active</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon completed">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.7088 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ getPrescriptionStatistics().completed }}</h3>
|
||||||
|
<p>Completed</p>
|
||||||
|
<small>{{ getPrescriptionStatistics().completionRate }}% completion rate</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon recent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ getPrescriptionStatistics().recent }}</h3>
|
||||||
|
<p>Last 30 Days</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon eprescription">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 22H20C20.5304 22 21.0391 21.7893 21.4142 21.4142C21.7893 21.0391 22 20.5304 22 20V4C22 3.46957 21.7893 2.96086 21.4142 2.58579C21.0391 2.21071 20.5304 2 20 2H4C3.46957 2 2.96086 2.21071 2.58579 2.58579C2.21071 2.96086 2 3.46957 2 4V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 8H17M7 12H17M7 16H13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>{{ getPrescriptionStatistics().ePrescriptionsSent }}</h3>
|
||||||
|
<p>E-Prescriptions Sent</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div class="error-message" *ngIf="error" role="alert" aria-live="assertive">
|
||||||
|
<div class="error-content">
|
||||||
|
<svg class="error-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 8V12M12 16H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div class="error-text">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" (click)="error = null" aria-label="Close error message" class="error-close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div class="loading-overlay" *ngIf="loading" aria-live="polite" aria-busy="true">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<svg class="spinner" viewBox="0 0 50 50">
|
||||||
|
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<p class="loading-text">Loading prescriptions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters and Controls -->
|
||||||
|
<div class="prescription-controls">
|
||||||
|
<div class="controls-left">
|
||||||
|
<div class="search-box">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="prescriptionSearchTerm"
|
||||||
|
(ngModelChange)="onPrescriptionFilterChange()"
|
||||||
|
placeholder="Search prescriptions..."
|
||||||
|
aria-label="Search prescriptions"
|
||||||
|
class="search-input">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="clear-search"
|
||||||
|
*ngIf="prescriptionSearchTerm"
|
||||||
|
(click)="prescriptionSearchTerm = ''; onPrescriptionFilterChange()"
|
||||||
|
aria-label="Clear search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select [(ngModel)]="prescriptionStatusFilter" (ngModelChange)="onPrescriptionFilterChange()" class="filter-select">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="COMPLETED">Completed</option>
|
||||||
|
<option value="CANCELLED">Cancelled</option>
|
||||||
|
<option value="DISCONTINUED">Discontinued</option>
|
||||||
|
</select>
|
||||||
|
<select [(ngModel)]="prescriptionDateFilter" (ngModelChange)="onPrescriptionFilterChange()" class="filter-select">
|
||||||
|
<option value="all">All Dates</option>
|
||||||
|
<option value="today">Today</option>
|
||||||
|
<option value="week">Last 7 Days</option>
|
||||||
|
<option value="month">Last 30 Days</option>
|
||||||
|
<option value="year">Last Year</option>
|
||||||
|
<option value="custom">Custom Range</option>
|
||||||
|
</select>
|
||||||
|
<div class="date-range" *ngIf="prescriptionDateFilter === 'custom'">
|
||||||
|
<input type="date" [(ngModel)]="prescriptionDateFrom" (ngModelChange)="onPrescriptionFilterChange()">
|
||||||
|
<span>to</span>
|
||||||
|
<input type="date" [(ngModel)]="prescriptionDateTo" (ngModelChange)="onPrescriptionFilterChange()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls-right">
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="toggle-btn" [class.active]="prescriptionViewMode === 'card'" (click)="prescriptionViewMode = 'card'" title="Card View">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="toggle-btn" [class.active]="prescriptionViewMode === 'table'" (click)="prescriptionViewMode = 'table'" title="Table View">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 3H5C4.46957 3 3.96086 3.21071 3.58579 3.58579C3.21071 3.96086 3 4.46957 3 5V9M9 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M9 3V21M3 9V19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21H19C19.5304 21 20.0391 20.7893 20.4142 20.4142C20.7893 20.0391 21 19.5304 21 19V9M3 9H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" (click)="toggleRecordsView()">
|
||||||
|
{{ showAllRecords ? 'Show Patient Specific' : 'Show All Prescriptions' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Actions Bar -->
|
||||||
|
<div class="bulk-actions-bar" *ngIf="selectedPrescriptions.length > 0">
|
||||||
|
<span class="selection-count">{{ selectedPrescriptions.length }} prescription(s) selected</span>
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<button class="secondary-button" (click)="bulkUpdatePrescriptionStatus('ACTIVE')">Mark Active</button>
|
||||||
|
<button class="secondary-button" (click)="bulkUpdatePrescriptionStatus('COMPLETED')">Mark Completed</button>
|
||||||
|
<button class="secondary-button" (click)="bulkUpdatePrescriptionStatus('CANCELLED')">Cancel</button>
|
||||||
|
<button class="secondary-button" (click)="bulkUpdatePrescriptionStatus('DISCONTINUED')">Discontinue</button>
|
||||||
|
<button class="secondary-button" (click)="selectedPrescriptions = []">Clear Selection</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patient Selection -->
|
||||||
|
<div class="card" *ngIf="!showAllRecords && !selectedPatientId">
|
||||||
|
<h3>View Patient Prescriptions</h3>
|
||||||
|
<select [(ngModel)]="selectedPatientId" (change)="selectPatient(selectedPatientId!)">
|
||||||
|
<option value="">Select Patient</option>
|
||||||
|
<option *ngFor="let patient of patients" [value]="patient.id">
|
||||||
|
{{ patient.firstName }} {{ patient.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Prescription Form -->
|
||||||
|
<div class="card create-form-card" *ngIf="showCreatePrescription" [attr.aria-expanded]="showCreatePrescription">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h3>Create New Prescription</h3>
|
||||||
|
<p class="card-subtitle">Fill in the medication details below</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="close-button" (click)="showCreatePrescription = false" aria-label="Close form">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="debug-toggle" *ngIf="showPrescriptionDebug">
|
||||||
|
<button class="secondary-button" type="button" (click)="showPrescriptionDebug = !showPrescriptionDebug">
|
||||||
|
{{ showPrescriptionDebug ? 'Hide' : 'Show' }} Debug
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre *ngIf="showPrescriptionDebug" class="debug-pre">{{ prescriptionDebug | json }}</pre>
|
||||||
|
<form (ngSubmit)="createPrescription()" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="patient-select">Patient <span class="required-asterisk" aria-label="required">*</span></label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<svg class="input-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21M16 7C16 9.20914 14.2091 11 12 11C9.79086 11 8 9.20914 8 7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<select [(ngModel)]="newPrescription.patientId" name="patientId" id="patient-select" required (ngModelChange)="onPatientSelectionChange($event)" class="form-input">
|
||||||
|
<option [value]="''">Select Patient</option>
|
||||||
|
<option *ngFor="let patient of patients" [value]="patient.id">
|
||||||
|
{{ patient.firstName }} {{ patient.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="medication-name">Medication Name <span class="required-asterisk" aria-label="required">*</span></label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<svg class="input-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 8H15M9 12H15M9 16H13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.medicationName" name="medicationName" id="medication-name" required class="form-input" placeholder="e.g., Amoxicillin 500mg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Dosage</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.dosage" name="dosage" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Frequency</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.frequency" name="frequency" required class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Quantity</label>
|
||||||
|
<input type="number" [(ngModel)]="newPrescription.quantity" name="quantity" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Refills</label>
|
||||||
|
<input type="number" [(ngModel)]="newPrescription.refills" name="refills" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Instructions</label>
|
||||||
|
<textarea [(ngModel)]="newPrescription.instructions" name="instructions" class="form-input form-textarea"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Start Date</label>
|
||||||
|
<input type="date" [(ngModel)]="newPrescription.startDate" name="startDate" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" (click)="showCreatePrescription = false" class="cancel-button">Cancel</button>
|
||||||
|
<button type="submit" [disabled]="loading" class="submit-button">
|
||||||
|
<span *ngIf="!loading">Create Prescription</span>
|
||||||
|
<span *ngIf="loading" class="loading-content">
|
||||||
|
<svg class="spinner-small" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="32" stroke-dashoffset="32">
|
||||||
|
<animate attributeName="stroke-dasharray" dur="2s" values="0 32;16 16;0 32;0 32" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-16;-32;-32" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
Creating...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prescriptions Table View -->
|
||||||
|
<div class="card prescription-table-container" *ngIf="prescriptionViewMode === 'table' && (filteredPrescriptions.length > 0 || (showAllRecords ? allPrescriptions.length : prescriptions.length) > 0)">
|
||||||
|
<div class="table-header">
|
||||||
|
<h3>Prescriptions ({{ filteredPrescriptions.length > 0 ? filteredPrescriptions.length : (showAllRecords ? allPrescriptions.length : prescriptions.length) }})</h3>
|
||||||
|
</div>
|
||||||
|
<table class="prescription-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="checkbox-column">
|
||||||
|
<input type="checkbox" [checked]="selectedPrescriptions.length === (filteredPrescriptions.length > 0 ? filteredPrescriptions : (showAllRecords ? allPrescriptions : prescriptions)).length && (filteredPrescriptions.length > 0 ? filteredPrescriptions : (showAllRecords ? allPrescriptions : prescriptions)).length > 0" (change)="toggleSelectAllPrescriptions()">
|
||||||
|
</th>
|
||||||
|
<th (click)="togglePrescriptionSort('prescriptionNumber')" class="sortable">
|
||||||
|
Prescription #
|
||||||
|
<span *ngIf="prescriptionSortBy === 'prescriptionNumber'" class="sort-indicator">
|
||||||
|
{{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th (click)="togglePrescriptionSort('medicationName')" class="sortable">
|
||||||
|
Medication
|
||||||
|
<span *ngIf="prescriptionSortBy === 'medicationName'" class="sort-indicator">
|
||||||
|
{{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th (click)="togglePrescriptionSort('patientName')" class="sortable">
|
||||||
|
Patient
|
||||||
|
<span *ngIf="prescriptionSortBy === 'patientName'" class="sort-indicator">
|
||||||
|
{{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th>Dosage</th>
|
||||||
|
<th>Frequency</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Refills</th>
|
||||||
|
<th (click)="togglePrescriptionSort('status')" class="sortable">
|
||||||
|
Status
|
||||||
|
<span *ngIf="prescriptionSortBy === 'status'" class="sort-indicator">
|
||||||
|
{{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th (click)="togglePrescriptionSort('startDate')" class="sortable">
|
||||||
|
Start Date
|
||||||
|
<span *ngIf="prescriptionSortBy === 'startDate'" class="sort-indicator">
|
||||||
|
{{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th>E-Prescription</th>
|
||||||
|
<th class="actions-column">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let prescription of (filteredPrescriptions.length > 0 ? filteredPrescriptions : (showAllRecords ? allPrescriptions : prescriptions))" [class.selected]="selectedPrescriptions.includes(prescription.id)">
|
||||||
|
<td class="checkbox-column">
|
||||||
|
<input type="checkbox" [checked]="selectedPrescriptions.includes(prescription.id)" (change)="togglePrescriptionSelection(prescription.id)">
|
||||||
|
</td>
|
||||||
|
<td class="prescription-number-cell">
|
||||||
|
<strong>#{{ prescription.prescriptionNumber }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{{ prescription.medicationName }}</td>
|
||||||
|
<td>{{ prescription.patientName || prescription.patientId }}</td>
|
||||||
|
<td>{{ prescription.dosage }}</td>
|
||||||
|
<td>{{ prescription.frequency }}</td>
|
||||||
|
<td>{{ prescription.quantity }}</td>
|
||||||
|
<td>{{ prescription.refills || 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="prescription-status" [class]="'status-' + prescription.status.toLowerCase()">
|
||||||
|
{{ prescription.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(prescription.startDate) }}</td>
|
||||||
|
<td>
|
||||||
|
<span *ngIf="prescription.ePrescriptionSent" class="e-prescription-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Sent
|
||||||
|
</span>
|
||||||
|
<span *ngIf="!prescription.ePrescriptionSent" class="not-sent">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions-column">
|
||||||
|
<div class="table-actions">
|
||||||
|
<button class="icon-button" (click)="printPrescription(prescription)" title="Print">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 9V2H18V9M6 18H4C3.46957 18 2.96086 17.7893 2.58579 17.4142C2.21071 17.0391 2 16.5304 2 16V11C2 10.4696 2.21071 9.96086 2.58579 9.58579C2.96086 9.21071 3.46957 9 4 9H20C20.5304 9 21.0391 9.21071 21.4142 9.58579C21.7893 9.96086 22 10.4696 22 11V16C22 16.5304 21.7893 17.0391 21.4142 17.4142C21.0391 17.7893 20.5304 18 20 18H18M6 14H18V22H6V14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button" (click)="selectPatient(prescription.patientId); editPrescription(prescription)" title="Edit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="icon-button" title="Status">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 15V17M12 9V13M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button *ngIf="prescription.status !== 'ACTIVE'" (click)="updatePrescriptionStatus(prescription.id, 'ACTIVE')">Mark Active</button>
|
||||||
|
<button *ngIf="prescription.status !== 'COMPLETED'" (click)="updatePrescriptionStatus(prescription.id, 'COMPLETED')">Mark Completed</button>
|
||||||
|
<button *ngIf="prescription.status !== 'CANCELLED'" (click)="updatePrescriptionStatus(prescription.id, 'CANCELLED')">Cancel</button>
|
||||||
|
<button *ngIf="prescription.status !== 'DISCONTINUED'" (click)="updatePrescriptionStatus(prescription.id, 'DISCONTINUED')">Discontinue</button>
|
||||||
|
<button *ngIf="!prescription.ePrescriptionSent" (click)="markEPrescriptionSent(prescription.id)">Mark E-Prescription Sent</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button delete-button" (click)="deletePrescription(prescription.id)" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prescriptions Card View -->
|
||||||
|
<div class="card" *ngIf="prescriptionViewMode === 'card' && (filteredPrescriptions.length > 0 || (showAllRecords ? allPrescriptions.length : prescriptions.length) > 0)">
|
||||||
|
<div class="prescription-list-header">
|
||||||
|
<h3>{{ showAllRecords ? 'All My Prescriptions' : 'Patient Prescriptions' }} ({{ filteredPrescriptions.length > 0 ? filteredPrescriptions.length : (showAllRecords ? allPrescriptions.length : prescriptions.length) }})</h3>
|
||||||
|
</div>
|
||||||
|
<div class="prescription-grid">
|
||||||
|
<div *ngFor="let prescription of (filteredPrescriptions.length > 0 ? filteredPrescriptions : (showAllRecords ? allPrescriptions : prescriptions))" class="prescription-card" [class.selected]="selectedPrescriptions.includes(prescription.id)">
|
||||||
|
<div class="prescription-card-checkbox">
|
||||||
|
<input type="checkbox" [checked]="selectedPrescriptions.includes(prescription.id)" (change)="togglePrescriptionSelection(prescription.id)">
|
||||||
|
</div>
|
||||||
|
<div class="prescription-header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ prescription.medicationName }}</h3>
|
||||||
|
<p class="prescription-number">#{{ prescription.prescriptionNumber }}</p>
|
||||||
|
<p *ngIf="showAllRecords"><strong>Patient:</strong> {{ prescription.patientName || prescription.patientId }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="prescription-header-right">
|
||||||
|
<span class="prescription-status" [class]="'status-' + prescription.status.toLowerCase()">
|
||||||
|
{{ prescription.status }}
|
||||||
|
</span>
|
||||||
|
<div class="prescription-actions">
|
||||||
|
<button class="icon-button" (click)="printPrescription(prescription)" title="Print">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 9V2H18V9M6 18H4C3.46957 18 2.96086 17.7893 2.58579 17.4142C2.21071 17.0391 2 16.5304 2 16V11C2 10.4696 2.21071 9.96086 2.58579 9.58579C2.96086 9.21071 3.46957 9 4 9H20C20.5304 9 21.0391 9.21071 21.4142 9.58579C21.7893 9.96086 22 10.4696 22 11V16C22 16.5304 21.7893 17.0391 21.4142 17.4142C21.0391 17.7893 20.5304 18 20 18H18M6 14H18V22H6V14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button" (click)="selectPatient(prescription.patientId); editPrescription(prescription)" title="Edit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="icon-button" title="Update Status">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 15V17M12 9V13M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button *ngIf="prescription.status !== 'ACTIVE'" (click)="updatePrescriptionStatus(prescription.id, 'ACTIVE')">Mark Active</button>
|
||||||
|
<button *ngIf="prescription.status !== 'COMPLETED'" (click)="updatePrescriptionStatus(prescription.id, 'COMPLETED')">Mark Completed</button>
|
||||||
|
<button *ngIf="prescription.status !== 'CANCELLED'" (click)="updatePrescriptionStatus(prescription.id, 'CANCELLED')">Cancel</button>
|
||||||
|
<button *ngIf="prescription.status !== 'DISCONTINUED'" (click)="updatePrescriptionStatus(prescription.id, 'DISCONTINUED')">Discontinue</button>
|
||||||
|
<button *ngIf="!prescription.ePrescriptionSent" (click)="markEPrescriptionSent(prescription.id)">Mark E-Prescription Sent</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button delete-button" (click)="deletePrescription(prescription.id)" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prescription-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Dosage:</span>
|
||||||
|
<span class="detail-value">{{ prescription.dosage }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Frequency:</span>
|
||||||
|
<span class="detail-value">{{ prescription.frequency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Quantity:</span>
|
||||||
|
<span class="detail-value">{{ prescription.quantity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="prescription.refills && prescription.refills > 0">
|
||||||
|
<span class="detail-label">Refills:</span>
|
||||||
|
<span class="detail-value">{{ prescription.refills }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Start Date:</span>
|
||||||
|
<span class="detail-value">{{ formatDate(prescription.startDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="prescription.endDate">
|
||||||
|
<span class="detail-label">End Date:</span>
|
||||||
|
<span class="detail-value">{{ formatDate(prescription.endDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="prescription.instructions">
|
||||||
|
<span class="detail-label">Instructions:</span>
|
||||||
|
<span class="detail-value instructions-text">{{ prescription.instructions }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="prescription.pharmacyName">
|
||||||
|
<span class="detail-label">Pharmacy:</span>
|
||||||
|
<span class="detail-value">{{ prescription.pharmacyName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="prescription.ePrescriptionSent" class="e-prescription-sent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
E-Prescription Sent {{ prescription.ePrescriptionSentAt ? '(' + formatDate(prescription.ePrescriptionSentAt) + ')' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="card empty-state-card" *ngIf="filteredPrescriptions.length === 0 && (showAllRecords ? allPrescriptions.length : prescriptions.length) === 0 && !showCreatePrescription && !showUpdatePrescription && !loading">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon-wrapper">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="empty-content">
|
||||||
|
<h3>No Prescriptions Found</h3>
|
||||||
|
<p *ngIf="!selectedPatientId">Select a patient or create a new prescription to get started.</p>
|
||||||
|
<p *ngIf="selectedPatientId && prescriptionSearchTerm">No prescriptions match your search criteria. Try adjusting your filters.</p>
|
||||||
|
<p *ngIf="selectedPatientId && !prescriptionSearchTerm">No prescriptions found for this patient. Create their first prescription to get started.</p>
|
||||||
|
</div>
|
||||||
|
<div class="empty-actions" *ngIf="selectedPatientId">
|
||||||
|
<button class="primary-button" (click)="openCreatePrescription()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Create First Prescription
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Prescription Form -->
|
||||||
|
<div class="card update-form-card" *ngIf="showUpdatePrescription && selectedPrescription" [attr.aria-expanded]="showUpdatePrescription">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h3>Update Prescription</h3>
|
||||||
|
<p class="card-subtitle">Modify prescription details</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="close-button" (click)="showUpdatePrescription = false; selectedPrescription = null" aria-label="Close form">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form (ngSubmit)="updatePrescription()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Patient</label>
|
||||||
|
<input type="text" [value]="selectedPrescription.patientName || selectedPrescription.patientId" disabled class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Medication Name</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.medicationName" name="medicationName" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Dosage</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.dosage" name="dosage" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Frequency</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.frequency" name="frequency" required class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Quantity</label>
|
||||||
|
<input type="number" [(ngModel)]="newPrescription.quantity" name="quantity" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Refills</label>
|
||||||
|
<input type="number" [(ngModel)]="newPrescription.refills" name="refills" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Instructions</label>
|
||||||
|
<textarea [(ngModel)]="newPrescription.instructions" name="instructions" class="form-input form-textarea"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Start Date</label>
|
||||||
|
<input type="date" [(ngModel)]="newPrescription.startDate" name="startDate" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">End Date</label>
|
||||||
|
<input type="date" [(ngModel)]="newPrescription.endDate" name="endDate" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Pharmacy Name</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.pharmacyName" name="pharmacyName" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Pharmacy Address</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.pharmacyAddress" name="pharmacyAddress" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Pharmacy Phone</label>
|
||||||
|
<input type="text" [(ngModel)]="newPrescription.pharmacyPhone" name="pharmacyPhone" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" (click)="showUpdatePrescription = false; selectedPrescription = null" class="cancel-button">Cancel</button>
|
||||||
|
<button type="submit" [disabled]="loading" class="submit-button">
|
||||||
|
<span *ngIf="!loading">Update Prescription</span>
|
||||||
|
<span *ngIf="loading" class="loading-content">
|
||||||
|
<svg class="spinner-small" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="32" stroke-dashoffset="32">
|
||||||
|
<animate attributeName="stroke-dasharray" dur="2s" values="0 32;16 16;0 32;0 32" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-16;-32;-32" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
Updating...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { PrescriptionService, Prescription, PrescriptionRequest } from '../../../../services/prescription.service';
|
||||||
|
import { ModalService } from '../../../../services/modal.service';
|
||||||
|
import { LoggerService } from '../../../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-prescriptions',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './prescriptions.component.html',
|
||||||
|
styleUrl: './prescriptions.component.scss'
|
||||||
|
})
|
||||||
|
export class PrescriptionsComponent implements OnInit, OnChanges {
|
||||||
|
@Input() doctorId: string | null = null;
|
||||||
|
@Input() selectedPatientId: string | null = null;
|
||||||
|
@Input() prescriptions: Prescription[] = [];
|
||||||
|
@Input() patients: any[] = [];
|
||||||
|
@Output() prescriptionChanged = new EventEmitter<void>();
|
||||||
|
@Output() patientSelected = new EventEmitter<string>();
|
||||||
|
@Output() safetyCheckRequested = new EventEmitter<void>();
|
||||||
|
|
||||||
|
// Prescription Management State
|
||||||
|
allPrescriptions: Prescription[] = [];
|
||||||
|
filteredPrescriptions: Prescription[] = [];
|
||||||
|
showAllRecords = false;
|
||||||
|
selectedPrescription: Prescription | null = null;
|
||||||
|
showUpdatePrescription = false;
|
||||||
|
showCreatePrescription = false;
|
||||||
|
showPrescriptionAnalytics = false;
|
||||||
|
showPrescriptionDebug = false;
|
||||||
|
|
||||||
|
// Filters and Controls
|
||||||
|
prescriptionSearchTerm: string = '';
|
||||||
|
prescriptionStatusFilter: string = 'all';
|
||||||
|
prescriptionDateFilter: string = 'all';
|
||||||
|
prescriptionDateFrom: string = '';
|
||||||
|
prescriptionDateTo: string = '';
|
||||||
|
prescriptionSortBy: string = 'createdAt';
|
||||||
|
prescriptionSortOrder: 'asc' | 'desc' = 'desc';
|
||||||
|
prescriptionViewMode: 'card' | 'table' = 'card';
|
||||||
|
selectedPrescriptions: string[] = [];
|
||||||
|
|
||||||
|
// Form Data
|
||||||
|
newPrescription: PrescriptionRequest = {
|
||||||
|
patientId: '',
|
||||||
|
doctorId: '',
|
||||||
|
medicationName: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
quantity: 30,
|
||||||
|
refills: 0,
|
||||||
|
startDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
|
||||||
|
error: string | null = null;
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private prescriptionService: PrescriptionService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.doctorId) {
|
||||||
|
this.newPrescription.doctorId = this.doctorId;
|
||||||
|
this.loadAllPrescriptions();
|
||||||
|
}
|
||||||
|
if (this.selectedPatientId) {
|
||||||
|
this.loadPatientPrescriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
|
if (changes['doctorId'] && this.doctorId) {
|
||||||
|
this.newPrescription.doctorId = this.doctorId;
|
||||||
|
if (!this.allPrescriptions.length) {
|
||||||
|
this.loadAllPrescriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['selectedPatientId']) {
|
||||||
|
if (this.selectedPatientId) {
|
||||||
|
this.loadPatientPrescriptions();
|
||||||
|
} else {
|
||||||
|
this.showAllRecords = true;
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['prescriptions']) {
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllPrescriptions() {
|
||||||
|
if (!this.doctorId) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.allPrescriptions = await this.prescriptionService.getPrescriptionsByDoctorId(this.doctorId);
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading all prescriptions:', e);
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to load prescriptions';
|
||||||
|
this.allPrescriptions = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPatientPrescriptions() {
|
||||||
|
if (!this.selectedPatientId) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.prescriptions = await this.prescriptionService.getPrescriptionsByPatientId(this.selectedPatientId);
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error loading patient prescriptions:', e);
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to load prescriptions';
|
||||||
|
this.prescriptions = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshPrescriptions() {
|
||||||
|
if (this.showAllRecords) {
|
||||||
|
await this.loadAllPrescriptions();
|
||||||
|
} else if (this.selectedPatientId) {
|
||||||
|
await this.loadPatientPrescriptions();
|
||||||
|
}
|
||||||
|
this.prescriptionChanged.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async openCreatePrescription() {
|
||||||
|
this.showCreatePrescription = !this.showCreatePrescription;
|
||||||
|
if (this.showCreatePrescription) {
|
||||||
|
this.newPrescription = {
|
||||||
|
patientId: this.selectedPatientId || '',
|
||||||
|
doctorId: this.doctorId || '',
|
||||||
|
medicationName: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
quantity: 30,
|
||||||
|
refills: 0,
|
||||||
|
startDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPrescription() {
|
||||||
|
// Validation
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!this.newPrescription.patientId) missing.push('patient');
|
||||||
|
if (!this.newPrescription.doctorId) missing.push('doctor');
|
||||||
|
if (!this.newPrescription.medicationName) missing.push('medication');
|
||||||
|
if (!this.newPrescription.dosage) missing.push('dosage');
|
||||||
|
if (!this.newPrescription.frequency) missing.push('frequency');
|
||||||
|
if ((this.newPrescription.quantity as any) == null || (this.newPrescription.quantity as any) <= 0) missing.push('quantity');
|
||||||
|
if (!(this.newPrescription.startDate as any)) missing.push('start date');
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
this.error = `Please fill in all required fields (${missing.join(', ')})`;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.error?.includes('required fields')) {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
await this.prescriptionService.createPrescription(this.newPrescription);
|
||||||
|
this.showCreatePrescription = false;
|
||||||
|
this.newPrescription = {
|
||||||
|
patientId: '',
|
||||||
|
doctorId: this.doctorId || '',
|
||||||
|
medicationName: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
quantity: 30,
|
||||||
|
refills: 0,
|
||||||
|
startDate: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
await this.refreshPrescriptions();
|
||||||
|
|
||||||
|
// Request parent component to refresh safety alerts
|
||||||
|
this.safetyCheckRequested.emit();
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
await this.modalService.alert(
|
||||||
|
'Prescription created successfully! Safety checks are being performed...',
|
||||||
|
'success',
|
||||||
|
'Success'
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error creating prescription:', e);
|
||||||
|
const errorMessage = e?.response?.data?.message ||
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.message ||
|
||||||
|
'Failed to create prescription';
|
||||||
|
this.error = errorMessage;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 5000);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editPrescription(prescription: Prescription) {
|
||||||
|
this.selectedPrescription = prescription;
|
||||||
|
this.newPrescription = {
|
||||||
|
patientId: prescription.patientId,
|
||||||
|
doctorId: prescription.doctorId,
|
||||||
|
medicationName: prescription.medicationName,
|
||||||
|
dosage: prescription.dosage,
|
||||||
|
frequency: prescription.frequency,
|
||||||
|
quantity: prescription.quantity,
|
||||||
|
refills: prescription.refills || 0,
|
||||||
|
instructions: prescription.instructions,
|
||||||
|
startDate: prescription.startDate,
|
||||||
|
endDate: prescription.endDate,
|
||||||
|
pharmacyName: prescription.pharmacyName,
|
||||||
|
pharmacyAddress: prescription.pharmacyAddress,
|
||||||
|
pharmacyPhone: prescription.pharmacyPhone
|
||||||
|
};
|
||||||
|
this.showUpdatePrescription = true;
|
||||||
|
(this.newPrescription as any).prescriptionId = prescription.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePrescription() {
|
||||||
|
const prescriptionId = (this.newPrescription as any).prescriptionId;
|
||||||
|
if (!prescriptionId) {
|
||||||
|
this.error = 'Prescription ID is missing. Please try again.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!this.newPrescription.patientId) missing.push('patient');
|
||||||
|
if (!this.newPrescription.doctorId) missing.push('doctor');
|
||||||
|
if (!this.newPrescription.medicationName?.trim()) missing.push('medication name');
|
||||||
|
if (!this.newPrescription.dosage?.trim()) missing.push('dosage');
|
||||||
|
if (!this.newPrescription.frequency?.trim()) missing.push('frequency');
|
||||||
|
if (this.newPrescription.quantity == null || this.newPrescription.quantity <= 0) missing.push('quantity');
|
||||||
|
if (!this.newPrescription.startDate) missing.push('start date');
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
this.error = `Please fill in all required fields (${missing.join(', ')})`;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.error?.includes('required fields')) {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
// Create a clean request object without the prescriptionId property
|
||||||
|
const updateRequest: PrescriptionRequest = {
|
||||||
|
patientId: this.newPrescription.patientId,
|
||||||
|
doctorId: this.newPrescription.doctorId,
|
||||||
|
medicationName: this.newPrescription.medicationName.trim(),
|
||||||
|
dosage: this.newPrescription.dosage.trim(),
|
||||||
|
frequency: this.newPrescription.frequency.trim(),
|
||||||
|
quantity: this.newPrescription.quantity,
|
||||||
|
refills: this.newPrescription.refills || 0,
|
||||||
|
instructions: this.newPrescription.instructions?.trim() || undefined,
|
||||||
|
startDate: this.newPrescription.startDate,
|
||||||
|
endDate: this.newPrescription.endDate || undefined,
|
||||||
|
pharmacyName: this.newPrescription.pharmacyName?.trim() || undefined,
|
||||||
|
pharmacyAddress: this.newPrescription.pharmacyAddress?.trim() || undefined,
|
||||||
|
pharmacyPhone: this.newPrescription.pharmacyPhone?.trim() || undefined,
|
||||||
|
appointmentId: this.newPrescription.appointmentId
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prescriptionService.updatePrescription(prescriptionId, updateRequest);
|
||||||
|
this.showUpdatePrescription = false;
|
||||||
|
this.selectedPrescription = null;
|
||||||
|
delete (this.newPrescription as any).prescriptionId;
|
||||||
|
await this.refreshPrescriptions();
|
||||||
|
|
||||||
|
// Request parent component to refresh safety alerts
|
||||||
|
this.safetyCheckRequested.emit();
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
await this.modalService.alert(
|
||||||
|
'Prescription updated successfully! Safety checks are being performed...',
|
||||||
|
'success',
|
||||||
|
'Success'
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error updating prescription:', e);
|
||||||
|
const errorMessage = e?.response?.data?.message ||
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.message ||
|
||||||
|
'Failed to update prescription. Please check your input and try again.';
|
||||||
|
this.error = errorMessage;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 5000);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePrescription(prescriptionId: string) {
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
'Are you sure you want to delete this prescription? This action cannot be undone.',
|
||||||
|
'Delete Prescription',
|
||||||
|
'Delete',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
await this.prescriptionService.deletePrescription(prescriptionId);
|
||||||
|
this.selectedPrescriptions = this.selectedPrescriptions.filter(id => id !== prescriptionId);
|
||||||
|
await this.refreshPrescriptions();
|
||||||
|
// Show success feedback
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to delete prescription';
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 5000);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePrescriptionStatus(prescriptionId: string, status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED') {
|
||||||
|
if (!prescriptionId) {
|
||||||
|
this.error = 'Prescription ID is missing. Please try again.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
await this.prescriptionService.updatePrescriptionStatus(prescriptionId, status);
|
||||||
|
await this.refreshPrescriptions();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error updating prescription status:', e);
|
||||||
|
const errorMessage = e?.response?.data?.message ||
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.message ||
|
||||||
|
`Failed to update prescription status to ${status}. Please try again.`;
|
||||||
|
this.error = errorMessage;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 5000);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markEPrescriptionSent(prescriptionId: string) {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await this.prescriptionService.markEPrescriptionSent(prescriptionId);
|
||||||
|
await this.refreshPrescriptions();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to mark e-prescription as sent';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdatePrescriptionStatus(status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED') {
|
||||||
|
if (this.selectedPrescriptions.length === 0) {
|
||||||
|
this.error = 'Please select at least one prescription';
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.error?.includes('Please select')) {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.modalService.confirm(
|
||||||
|
`Are you sure you want to update ${this.selectedPrescriptions.length} prescription(s) to ${status}?`,
|
||||||
|
'Update Prescription Status',
|
||||||
|
'Update',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
// Update prescriptions one by one to handle partial failures
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
this.selectedPrescriptions.map(id => {
|
||||||
|
if (!id || id.trim() === '') {
|
||||||
|
throw new Error('Invalid prescription ID');
|
||||||
|
}
|
||||||
|
return this.prescriptionService.updatePrescriptionStatus(id, status);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for failures
|
||||||
|
const failures = results.filter(result => result.status === 'rejected');
|
||||||
|
const successes = results.filter(result => result.status === 'fulfilled');
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
// Log failures for debugging
|
||||||
|
failures.forEach((failure, index) => {
|
||||||
|
if (failure.status === 'rejected') {
|
||||||
|
this.logger.error(`Failed to update prescription ${this.selectedPrescriptions[index]}:`, failure.reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const failureReasons = failures.map((failure, index) => {
|
||||||
|
if (failure.status === 'rejected') {
|
||||||
|
const error = failure.reason;
|
||||||
|
const errorMsg = error?.response?.data?.message ||
|
||||||
|
error?.response?.data?.error ||
|
||||||
|
error?.message ||
|
||||||
|
'Unknown error';
|
||||||
|
return errorMsg;
|
||||||
|
}
|
||||||
|
return 'Unknown error';
|
||||||
|
}).join('; ');
|
||||||
|
|
||||||
|
const totalFailures = failures.length;
|
||||||
|
const totalSuccess = successes.length;
|
||||||
|
|
||||||
|
if (totalSuccess > 0) {
|
||||||
|
this.error = `Updated ${totalSuccess} prescription(s) successfully. Failed to update ${totalFailures} prescription(s). ${failureReasons}`;
|
||||||
|
} else {
|
||||||
|
this.error = `Failed to update all ${totalFailures} prescription(s). ${failureReasons}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All succeeded
|
||||||
|
this.selectedPrescriptions = [];
|
||||||
|
// Don't show error message if all succeeded
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh to show updated statuses
|
||||||
|
await this.refreshPrescriptions();
|
||||||
|
|
||||||
|
// Auto-hide error after 8 seconds (longer since it contains more info)
|
||||||
|
if (failures.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error bulk updating prescriptions:', e);
|
||||||
|
const errorMessage = e?.response?.data?.message ||
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.message ||
|
||||||
|
'Failed to update prescriptions. Please try again.';
|
||||||
|
this.error = errorMessage;
|
||||||
|
// Auto-hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.error = null;
|
||||||
|
}, 5000);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPrescriptionFilters() {
|
||||||
|
const prescriptionsToFilter = this.showAllRecords ? this.allPrescriptions : this.prescriptions;
|
||||||
|
let filtered = prescriptionsToFilter && prescriptionsToFilter.length > 0 ? [...prescriptionsToFilter] : [];
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (this.prescriptionSearchTerm) {
|
||||||
|
const searchLower = this.prescriptionSearchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter(p =>
|
||||||
|
p.medicationName?.toLowerCase().includes(searchLower) ||
|
||||||
|
p.prescriptionNumber?.toLowerCase().includes(searchLower) ||
|
||||||
|
p.patientName?.toLowerCase().includes(searchLower) ||
|
||||||
|
p.doctorName?.toLowerCase().includes(searchLower) ||
|
||||||
|
p.pharmacyName?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (this.prescriptionStatusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(p => p.status === this.prescriptionStatusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date filter
|
||||||
|
if (this.prescriptionDateFilter !== 'all') {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
filtered = filtered.filter(p => {
|
||||||
|
const startDate = new Date(p.startDate);
|
||||||
|
|
||||||
|
switch (this.prescriptionDateFilter) {
|
||||||
|
case 'today':
|
||||||
|
return startDate >= today;
|
||||||
|
case 'week':
|
||||||
|
const weekAgo = new Date(today);
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
return startDate >= weekAgo;
|
||||||
|
case 'month':
|
||||||
|
const monthAgo = new Date(today);
|
||||||
|
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||||
|
return startDate >= monthAgo;
|
||||||
|
case 'year':
|
||||||
|
const yearAgo = new Date(today);
|
||||||
|
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
||||||
|
return startDate >= yearAgo;
|
||||||
|
case 'custom':
|
||||||
|
if (this.prescriptionDateFrom && this.prescriptionDateTo) {
|
||||||
|
const fromDate = new Date(this.prescriptionDateFrom);
|
||||||
|
const toDate = new Date(this.prescriptionDateTo);
|
||||||
|
toDate.setHours(23, 59, 59, 999);
|
||||||
|
return startDate >= fromDate && startDate <= toDate;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue: any, bValue: any;
|
||||||
|
|
||||||
|
switch (this.prescriptionSortBy) {
|
||||||
|
case 'medicationName':
|
||||||
|
aValue = a.medicationName || '';
|
||||||
|
bValue = b.medicationName || '';
|
||||||
|
break;
|
||||||
|
case 'patientName':
|
||||||
|
aValue = a.patientName || a.patientId || '';
|
||||||
|
bValue = b.patientName || b.patientId || '';
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
aValue = a.status || '';
|
||||||
|
bValue = b.status || '';
|
||||||
|
break;
|
||||||
|
case 'startDate':
|
||||||
|
aValue = new Date(a.startDate).getTime();
|
||||||
|
bValue = new Date(b.startDate).getTime();
|
||||||
|
break;
|
||||||
|
case 'prescriptionNumber':
|
||||||
|
aValue = a.prescriptionNumber || '';
|
||||||
|
bValue = b.prescriptionNumber || '';
|
||||||
|
break;
|
||||||
|
case 'createdAt':
|
||||||
|
default:
|
||||||
|
aValue = new Date(a.createdAt).getTime();
|
||||||
|
bValue = new Date(b.createdAt).getTime();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return this.prescriptionSortOrder === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return this.prescriptionSortOrder === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filteredPrescriptions = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPrescriptionFilterChange() {
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePrescriptionSort(column: string) {
|
||||||
|
if (this.prescriptionSortBy === column) {
|
||||||
|
this.prescriptionSortOrder = this.prescriptionSortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
this.prescriptionSortBy = column;
|
||||||
|
this.prescriptionSortOrder = 'desc';
|
||||||
|
}
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePrescriptionSelection(prescriptionId: string) {
|
||||||
|
const index = this.selectedPrescriptions.indexOf(prescriptionId);
|
||||||
|
if (index > -1) {
|
||||||
|
this.selectedPrescriptions.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
this.selectedPrescriptions.push(prescriptionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAllPrescriptions() {
|
||||||
|
const prescriptionsToSelect = this.filteredPrescriptions.length > 0
|
||||||
|
? this.filteredPrescriptions
|
||||||
|
: (this.showAllRecords ? this.allPrescriptions : this.prescriptions);
|
||||||
|
|
||||||
|
if (this.selectedPrescriptions.length === prescriptionsToSelect.length) {
|
||||||
|
this.selectedPrescriptions = [];
|
||||||
|
} else {
|
||||||
|
this.selectedPrescriptions = prescriptionsToSelect.map(p => p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleRecordsView() {
|
||||||
|
this.showAllRecords = !this.showAllRecords;
|
||||||
|
this.selectedPrescriptions = [];
|
||||||
|
if (this.showAllRecords) {
|
||||||
|
await this.loadAllPrescriptions();
|
||||||
|
} else {
|
||||||
|
if (this.selectedPatientId) {
|
||||||
|
await this.loadPatientPrescriptions();
|
||||||
|
} else {
|
||||||
|
this.applyPrescriptionFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPatient(patientId: string) {
|
||||||
|
this.patientSelected.emit(patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPatientSelectionChange(patientId: string) {
|
||||||
|
this.newPrescription.patientId = patientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrescriptionStatistics() {
|
||||||
|
const prescriptions = this.showAllRecords ? this.allPrescriptions : this.prescriptions;
|
||||||
|
const sourcePrescriptions = prescriptions && prescriptions.length > 0 ? prescriptions : this.filteredPrescriptions;
|
||||||
|
const total = sourcePrescriptions.length;
|
||||||
|
const active = sourcePrescriptions.filter(p => p.status === 'ACTIVE').length;
|
||||||
|
const completed = sourcePrescriptions.filter(p => p.status === 'COMPLETED').length;
|
||||||
|
const ePrescriptionsSent = sourcePrescriptions.filter(p => p.ePrescriptionSent).length;
|
||||||
|
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const recent = sourcePrescriptions.filter(p => {
|
||||||
|
const createdDate = new Date(p.createdAt);
|
||||||
|
return createdDate >= thirtyDaysAgo;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
active,
|
||||||
|
completed,
|
||||||
|
ePrescriptionsSent,
|
||||||
|
recent,
|
||||||
|
completionRate: total > 0 ? ((completed / total) * 100).toFixed(1) : '0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
exportPrescriptions() {
|
||||||
|
const prescriptions = this.filteredPrescriptions.length > 0 ? this.filteredPrescriptions :
|
||||||
|
(this.showAllRecords ? this.allPrescriptions : this.prescriptions);
|
||||||
|
|
||||||
|
const headers = ['Prescription #', 'Medication', 'Patient', 'Dosage', 'Frequency', 'Quantity', 'Refills', 'Status', 'Start Date', 'End Date', 'E-Prescription Sent'];
|
||||||
|
const rows = prescriptions.map(p => [
|
||||||
|
p.prescriptionNumber || '',
|
||||||
|
p.medicationName || '',
|
||||||
|
p.patientName || p.patientId || '',
|
||||||
|
p.dosage || '',
|
||||||
|
p.frequency || '',
|
||||||
|
p.quantity?.toString() || '',
|
||||||
|
p.refills?.toString() || '',
|
||||||
|
p.status || '',
|
||||||
|
p.startDate || '',
|
||||||
|
p.endDate || '',
|
||||||
|
p.ePrescriptionSent ? 'Yes' : 'No'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `prescriptions_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
printPrescription(prescription: Prescription) {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Prescription - ${prescription.prescriptionNumber}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }
|
||||||
|
.header { border-bottom: 2px solid #333; padding-bottom: 20px; margin-bottom: 20px; }
|
||||||
|
.prescription-info { margin: 20px 0; }
|
||||||
|
.section { margin: 15px 0; }
|
||||||
|
.label { font-weight: bold; margin-right: 10px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||||
|
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||||
|
.footer { margin-top: 40px; padding-top: 20px; border-top: 2px solid #333; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Prescription</h1>
|
||||||
|
<p><strong>Prescription #:</strong> ${prescription.prescriptionNumber}</p>
|
||||||
|
<p><strong>Date:</strong> ${this.formatDate(prescription.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="prescription-info">
|
||||||
|
<div class="section">
|
||||||
|
<p><span class="label">Patient:</span> ${prescription.patientName || prescription.patientId}</p>
|
||||||
|
<p><span class="label">Doctor:</span> ${prescription.doctorName || prescription.doctorId}</p>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Medication Information</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Medication</th><td>${prescription.medicationName}</td></tr>
|
||||||
|
<tr><th>Dosage</th><td>${prescription.dosage}</td></tr>
|
||||||
|
<tr><th>Frequency</th><td>${prescription.frequency}</td></tr>
|
||||||
|
<tr><th>Quantity</th><td>${prescription.quantity}</td></tr>
|
||||||
|
<tr><th>Refills</th><td>${prescription.refills || 0}</td></tr>
|
||||||
|
<tr><th>Start Date</th><td>${this.formatDate(prescription.startDate)}</td></tr>
|
||||||
|
${prescription.endDate ? `<tr><th>End Date</th><td>${this.formatDate(prescription.endDate)}</td></tr>` : ''}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
${prescription.instructions ? `<div class="section"><p><span class="label">Instructions:</span> ${prescription.instructions}</p></div>` : ''}
|
||||||
|
${prescription.pharmacyName ? `
|
||||||
|
<div class="section">
|
||||||
|
<h2>Pharmacy Information</h2>
|
||||||
|
<p><span class="label">Pharmacy:</span> ${prescription.pharmacyName}</p>
|
||||||
|
${prescription.pharmacyAddress ? `<p><span class="label">Address:</span> ${prescription.pharmacyAddress}</p>` : ''}
|
||||||
|
${prescription.pharmacyPhone ? `<p><span class="label">Phone:</span> ${prescription.pharmacyPhone}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Status:</strong> ${prescription.status}</p>
|
||||||
|
${prescription.ePrescriptionSent ? `<p><strong>E-Prescription Sent:</strong> Yes (${prescription.ePrescriptionSentAt ? this.formatDate(prescription.ePrescriptionSentAt) : 'N/A'})</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateString: string): string {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get prescriptionDebug() {
|
||||||
|
return {
|
||||||
|
doctorId: this.doctorId,
|
||||||
|
selectedPatientId: this.selectedPatientId,
|
||||||
|
showAllRecords: this.showAllRecords,
|
||||||
|
prescriptions: this.prescriptions?.length || 0,
|
||||||
|
allPrescriptions: this.allPrescriptions?.length || 0,
|
||||||
|
filteredPrescriptions: this.filteredPrescriptions?.length || 0,
|
||||||
|
newPrescription: this.newPrescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
<section class="profile-section">
|
||||||
|
<div class="profile-header-section">
|
||||||
|
<div class="profile-header-content">
|
||||||
|
<h2 class="section-title">My Profile</h2>
|
||||||
|
<p class="section-description">View and manage your professional information</p>
|
||||||
|
</div>
|
||||||
|
<button class="edit-profile-btn" (click)="onEditProfile()" *ngIf="!showEditProfile">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-card" *ngIf="!showEditProfile && currentUser">
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar-wrapper">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<img *ngIf="currentUser.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(currentUser.avatarUrl)"
|
||||||
|
[alt]="currentUser.firstName + ' ' + currentUser.lastName"
|
||||||
|
class="avatar-image-large">
|
||||||
|
<svg *ngIf="!currentUser.avatarUrl" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button class="avatar-upload-btn" (click)="fileInput.click()" title="Upload photo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 3V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input #fileInput type="file" accept="image/*" class="hidden-input" (change)="onFileSelected($event)">
|
||||||
|
</div>
|
||||||
|
<div class="profile-name">
|
||||||
|
<h3>Dr. {{ currentUser.firstName }} {{ currentUser.lastName }}</h3>
|
||||||
|
<p>{{ currentUser.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Phone Number</span>
|
||||||
|
<span class="detail-value">{{ currentUser.phoneNumber || doctorProfile?.phoneNumber || 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="doctorProfile">
|
||||||
|
<span class="detail-label">Specialization</span>
|
||||||
|
<span class="detail-value">{{ doctorProfile.specialization || 'Not specified' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="doctorProfile">
|
||||||
|
<span class="detail-label">Years of Experience</span>
|
||||||
|
<span class="detail-value">{{ doctorProfile.yearsOfExperience || 'Not specified' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="doctorProfile">
|
||||||
|
<span class="detail-label">Default Appointment Duration</span>
|
||||||
|
<span class="detail-value">{{ doctorProfile.defaultDurationMinutes || 30 }} minutes</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Account Status</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span class="status-badge" [ngClass]="currentUser.isActive ? 'status-confirmed' : 'status-cancelled'">
|
||||||
|
{{ currentUser.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Profile Form -->
|
||||||
|
<div class="edit-profile-form" *ngIf="showEditProfile">
|
||||||
|
<div class="form-header">
|
||||||
|
<h3 class="form-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Edit Profile
|
||||||
|
</h3>
|
||||||
|
<p class="form-subtitle">Update your professional and contact information</p>
|
||||||
|
</div>
|
||||||
|
<form class="profile-form" (ngSubmit)="onUpdateProfile()" #profileForm="ngForm">
|
||||||
|
<!-- User Profile Fields -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-subtitle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Basic Information
|
||||||
|
</h4>
|
||||||
|
<p class="section-description">Your personal contact details</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="firstName">
|
||||||
|
First Name
|
||||||
|
<span class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
[(ngModel)]="editUserData.firstName"
|
||||||
|
name="firstName"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ currentUser?.firstName || 'Enter first name' }}"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
pattern="[A-Za-z\s]{2,50}"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-50 characters, letters only</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="lastName">
|
||||||
|
Last Name
|
||||||
|
<span class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
[(ngModel)]="editUserData.lastName"
|
||||||
|
name="lastName"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ currentUser?.lastName || 'Enter last name' }}"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
pattern="[A-Za-z\s]{2,50}"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-50 characters, letters only</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="phoneNumber">
|
||||||
|
Phone Number
|
||||||
|
<span class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phoneNumber"
|
||||||
|
[(ngModel)]="editUserData.phoneNumber"
|
||||||
|
name="phoneNumber"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ currentUser?.phoneNumber || '+1234567890' }}"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="15"
|
||||||
|
pattern="[+]?[0-9]{10,15}"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">10-15 digits, can include country code with +</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Doctor-Specific Fields -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-subtitle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 14V22M8 18H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Professional Information
|
||||||
|
</h4>
|
||||||
|
<p class="section-description">Your medical credentials and expertise</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="medicalLicenseNumber">
|
||||||
|
Medical License Number
|
||||||
|
<span class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="medicalLicenseNumber"
|
||||||
|
[(ngModel)]="editDoctorData.medicalLicenseNumber"
|
||||||
|
name="medicalLicenseNumber"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.medicalLicenseNumber || 'Enter medical license number' }}"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-100 characters, required for verification</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="specialization">Specialization</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="specialization"
|
||||||
|
[(ngModel)]="editDoctorData.specialization"
|
||||||
|
name="specialization"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.specialization || 'e.g., Cardiology, Pediatrics' }}"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-100 characters</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="yearsOfExperience">Years of Experience</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="yearsOfExperience"
|
||||||
|
[(ngModel)]="editDoctorData.yearsOfExperience"
|
||||||
|
name="yearsOfExperience"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.yearsOfExperience || '0' }}"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">0-50 years</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<label class="form-label" for="biography">Biography</label>
|
||||||
|
<textarea
|
||||||
|
id="biography"
|
||||||
|
[(ngModel)]="editDoctorData.biography"
|
||||||
|
name="biography"
|
||||||
|
class="form-input form-textarea"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Share your professional background, education, and areas of expertise..."
|
||||||
|
maxlength="1000"
|
||||||
|
></textarea>
|
||||||
|
<div class="textarea-footer">
|
||||||
|
<span class="form-hint">Brief professional biography (max 1000 characters)</span>
|
||||||
|
<span class="char-counter" *ngIf="editDoctorData.biography">
|
||||||
|
{{ (editDoctorData.biography || '').length }}/1000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="consultationFee">
|
||||||
|
Consultation Fee ($)
|
||||||
|
<span class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<span class="input-icon">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="consultationFee"
|
||||||
|
[(ngModel)]="editDoctorData.consultationFee"
|
||||||
|
name="consultationFee"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.consultationFee || '0.00' }}"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">Must be greater than 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="defaultDurationMinutes">Default Appointment Duration (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="defaultDurationMinutes"
|
||||||
|
[(ngModel)]="editDoctorData.defaultDurationMinutes"
|
||||||
|
name="defaultDurationMinutes"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.defaultDurationMinutes || '30' }}"
|
||||||
|
min="15"
|
||||||
|
max="120"
|
||||||
|
step="15"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">Default duration for patient appointments (15-120 minutes, in 15-minute increments). Default: 30 minutes. You can change it anytime (e.g., from 60 to 30).</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enterprise Fields Section -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-subtitle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 7.61305 3.94821 5.32387 5.63604 3.63604C7.32387 1.94821 9.61305 1 12 1C14.3869 1 16.6761 1.94821 18.364 3.63604C20.0518 5.32387 21 7.61305 21 10Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
Enterprise Information
|
||||||
|
</h4>
|
||||||
|
<p class="section-description">Additional professional details</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Address</h5>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="streetAddress">Street Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="streetAddress"
|
||||||
|
[(ngModel)]="editDoctorData.streetAddress"
|
||||||
|
name="streetAddress"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.streetAddress || 'Enter street address' }}"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="city">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="city"
|
||||||
|
[(ngModel)]="editDoctorData.city"
|
||||||
|
name="city"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.city || 'Enter city' }}"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="state">State/Province</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="state"
|
||||||
|
[(ngModel)]="editDoctorData.state"
|
||||||
|
name="state"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.state || 'Enter state' }}"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="zipCode">Zip/Postal Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="zipCode"
|
||||||
|
[(ngModel)]="editDoctorData.zipCode"
|
||||||
|
name="zipCode"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.zipCode || 'Enter zip code' }}"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="country">Country</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="country"
|
||||||
|
[(ngModel)]="editDoctorData.country"
|
||||||
|
name="country"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.country || 'Enter country' }}"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Education -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Education</h5>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="educationDegree">Degree</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="educationDegree"
|
||||||
|
[(ngModel)]="editDoctorData.educationDegree"
|
||||||
|
name="educationDegree"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.educationDegree || 'e.g., MD, PhD' }}"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="educationUniversity">University</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="educationUniversity"
|
||||||
|
[(ngModel)]="editDoctorData.educationUniversity"
|
||||||
|
name="educationUniversity"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.educationUniversity || 'Enter university name' }}"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="educationGraduationYear">Graduation Year</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="educationGraduationYear"
|
||||||
|
[(ngModel)]="editDoctorData.educationGraduationYear"
|
||||||
|
name="educationGraduationYear"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="{{ doctorProfile?.educationGraduationYear || 'YYYY' }}"
|
||||||
|
min="1900"
|
||||||
|
max="2100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Certifications -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Certifications</h5>
|
||||||
|
<div class="array-input-container">
|
||||||
|
<div class="array-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newCertification"
|
||||||
|
name="newCertification"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter certification name"
|
||||||
|
(keyup.enter)="addCertification()"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="addCertification()" title="Add certification">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tags-list" *ngIf="editDoctorData.certifications && editDoctorData.certifications.length > 0">
|
||||||
|
<span class="tag" *ngFor="let cert of editDoctorData.certifications; let i = index">
|
||||||
|
{{ cert }}
|
||||||
|
<button type="button" class="tag-remove" (click)="removeCertification(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Languages Spoken -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Languages Spoken</h5>
|
||||||
|
<div class="array-input-container">
|
||||||
|
<div class="array-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newLanguage"
|
||||||
|
name="newLanguage"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter language"
|
||||||
|
(keyup.enter)="addLanguage()"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="addLanguage()" title="Add language">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tags-list" *ngIf="editDoctorData.languagesSpoken && editDoctorData.languagesSpoken.length > 0">
|
||||||
|
<span class="tag" *ngFor="let lang of editDoctorData.languagesSpoken; let i = index">
|
||||||
|
{{ lang }}
|
||||||
|
<button type="button" class="tag-remove" (click)="removeLanguage(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hospital Affiliations -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Hospital Affiliations</h5>
|
||||||
|
<div class="array-input-container">
|
||||||
|
<div class="array-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newHospitalAffiliation"
|
||||||
|
name="newHospitalAffiliation"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter hospital name"
|
||||||
|
(keyup.enter)="addHospitalAffiliation()"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="addHospitalAffiliation()" title="Add hospital">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tags-list" *ngIf="editDoctorData.hospitalAffiliations && editDoctorData.hospitalAffiliations.length > 0">
|
||||||
|
<span class="tag" *ngFor="let hospital of editDoctorData.hospitalAffiliations; let i = index">
|
||||||
|
{{ hospital }}
|
||||||
|
<button type="button" class="tag-remove" (click)="removeHospitalAffiliation(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Insurance Accepted -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Insurance Accepted</h5>
|
||||||
|
<div class="array-input-container">
|
||||||
|
<div class="array-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newInsuranceAccepted"
|
||||||
|
name="newInsuranceAccepted"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter insurance provider name"
|
||||||
|
(keyup.enter)="addInsuranceAccepted()"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="addInsuranceAccepted()" title="Add insurance">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tags-list" *ngIf="editDoctorData.insuranceAccepted && editDoctorData.insuranceAccepted.length > 0">
|
||||||
|
<span class="tag" *ngFor="let insurance of editDoctorData.insuranceAccepted; let i = index">
|
||||||
|
{{ insurance }}
|
||||||
|
<button type="button" class="tag-remove" (click)="removeInsuranceAccepted(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Professional Memberships -->
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Professional Memberships</h5>
|
||||||
|
<div class="array-input-container">
|
||||||
|
<div class="array-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newProfessionalMembership"
|
||||||
|
name="newProfessionalMembership"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter organization name"
|
||||||
|
(keyup.enter)="addProfessionalMembership()"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="addProfessionalMembership()" title="Add membership">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tags-list" *ngIf="editDoctorData.professionalMemberships && editDoctorData.professionalMemberships.length > 0">
|
||||||
|
<span class="tag" *ngFor="let membership of editDoctorData.professionalMemberships; let i = index">
|
||||||
|
{{ membership }}
|
||||||
|
<button type="button" class="tag-remove" (click)="removeProfessionalMembership(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-btn" (click)="cancelEdit()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="submit-button" [disabled]="profileForm.invalid">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
// Enterprise-Grade Profile Component Styling
|
||||||
|
// Color Palette
|
||||||
|
$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
$primary-blue: #0066cc;
|
||||||
|
$primary-blue-light: #e6f2ff;
|
||||||
|
$primary-blue-dark: #0052a3;
|
||||||
|
$accent-teal: #0099a1;
|
||||||
|
$accent-purple: #8b5cf6;
|
||||||
|
$text-dark: #1a1a1a;
|
||||||
|
$text-medium: #4a4a4a;
|
||||||
|
$text-light: #6b6b6b;
|
||||||
|
$text-lighter: #9ca3af;
|
||||||
|
$border-color: #e8eaed;
|
||||||
|
$border-focus: #0066cc;
|
||||||
|
$background-light: #f8f9fa;
|
||||||
|
$background-page: #f5f7fa;
|
||||||
|
$background-elevated: #ffffff;
|
||||||
|
$white: #ffffff;
|
||||||
|
$success-green: #10b981;
|
||||||
|
$success-green-light: #d1fae5;
|
||||||
|
$success-green-dark: #059669;
|
||||||
|
$error-red: #ef4444;
|
||||||
|
$error-red-light: #fee2e2;
|
||||||
|
$error-red-dark: #dc2626;
|
||||||
|
$warning-orange: #f59e0b;
|
||||||
|
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
$border-radius-sm: 8px;
|
||||||
|
$border-radius-md: 12px;
|
||||||
|
$border-radius-lg: 16px;
|
||||||
|
$border-radius-xl: 20px;
|
||||||
|
$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-profile-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
color: $white;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(5deg) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 32px;
|
||||||
|
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-gradient;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: $shadow-xl, 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-gradient;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
z-index: -1;
|
||||||
|
filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: $shadow-2xl, 0 0 0 4px rgba(102, 126, 234, 0.15);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image-large {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
color: $white;
|
||||||
|
stroke-width: 2;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-gradient;
|
||||||
|
color: $white;
|
||||||
|
border: 3px solid $white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
transition: all $transition-base;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1) rotate(15deg);
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(1.05) rotate(10deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-verified {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: linear-gradient(135deg, $success-green-light 0%, rgba(209, 250, 229, 0.5) 100%);
|
||||||
|
color: $success-green;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 8px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all $transition-base;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-medium;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-dark;
|
||||||
|
text-align: right;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&.status-confirmed {
|
||||||
|
background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%);
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-cancelled {
|
||||||
|
background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%);
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit Profile Form
|
||||||
|
.edit-profile-form {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 40px;
|
||||||
|
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: $primary-blue;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&.form-group-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-indicator {
|
||||||
|
color: $error-red;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $white;
|
||||||
|
transition: all $transition-base;
|
||||||
|
font-family: inherit;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-lighter;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1), $shadow-md;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:focus) {
|
||||||
|
border-color: rgba(0, 0, 0, 0.12);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $background-light;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
color: $text-medium;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-lighter;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-medium;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: $background-light;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: $white;
|
||||||
|
color: $text-medium;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $background-light;
|
||||||
|
border-color: $text-light;
|
||||||
|
color: $text-dark;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
color: $white;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1) translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive Design
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.profile-section {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card,
|
||||||
|
.edit-profile-form {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.profile-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn,
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card,
|
||||||
|
.edit-profile-form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced focus states for accessibility
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid $primary-blue;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth transitions
|
||||||
|
* {
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
.profile-section,
|
||||||
|
.profile-card,
|
||||||
|
.edit-profile-form {
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enterprise Fields Styles
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 1.5rem 0 1rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid rgba(102, 126, 234, 0.2);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
.array-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: $primary-blue;
|
||||||
|
color: $white;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all $transition-base;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 44px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-blue-dark;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: $primary-blue-light;
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.tag-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 102, 204, 0.1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { UserService, UserInfo, DoctorProfile, DoctorUpdateRequest, UserUpdateRequest } from '../../../../services/user.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './profile.component.html',
|
||||||
|
styleUrl: './profile.component.scss'
|
||||||
|
})
|
||||||
|
export class ProfileComponent implements OnChanges {
|
||||||
|
@Input() currentUser: UserInfo | null = null;
|
||||||
|
@Input() doctorProfile: DoctorProfile | null = null;
|
||||||
|
@Input() showEditProfile = false;
|
||||||
|
@Output() editProfileClick = new EventEmitter<void>();
|
||||||
|
@Output() updateProfile = new EventEmitter<{userData: UserUpdateRequest, doctorData: DoctorUpdateRequest}>();
|
||||||
|
@Output() fileSelected = new EventEmitter<Event>();
|
||||||
|
|
||||||
|
constructor(public userService: UserService) {}
|
||||||
|
|
||||||
|
editUserData: UserUpdateRequest = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
phoneNumber: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
editDoctorData: DoctorUpdateRequest = {
|
||||||
|
medicalLicenseNumber: '',
|
||||||
|
specialization: '',
|
||||||
|
yearsOfExperience: undefined,
|
||||||
|
biography: '',
|
||||||
|
consultationFee: undefined,
|
||||||
|
defaultDurationMinutes: undefined,
|
||||||
|
// Enterprise fields
|
||||||
|
streetAddress: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zipCode: '',
|
||||||
|
country: '',
|
||||||
|
educationDegree: '',
|
||||||
|
educationUniversity: '',
|
||||||
|
educationGraduationYear: undefined,
|
||||||
|
certifications: [],
|
||||||
|
languagesSpoken: [],
|
||||||
|
hospitalAffiliations: [],
|
||||||
|
insuranceAccepted: [],
|
||||||
|
professionalMemberships: []
|
||||||
|
};
|
||||||
|
|
||||||
|
newCertification: string = '';
|
||||||
|
newLanguage: string = '';
|
||||||
|
newHospitalAffiliation: string = '';
|
||||||
|
newInsuranceAccepted: string = '';
|
||||||
|
newProfessionalMembership: string = '';
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
|
// When showEditProfile changes to true, initialize form data
|
||||||
|
if (changes['showEditProfile'] && this.showEditProfile) {
|
||||||
|
this.initializeFormData();
|
||||||
|
}
|
||||||
|
// When currentUser or doctorProfile changes and we're in edit mode, update form data
|
||||||
|
if (this.showEditProfile && (changes['currentUser'] || changes['doctorProfile'])) {
|
||||||
|
this.initializeFormData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeFormData() {
|
||||||
|
// Initialize form data with current values
|
||||||
|
if (this.currentUser) {
|
||||||
|
this.editUserData = {
|
||||||
|
firstName: this.currentUser.firstName || '',
|
||||||
|
lastName: this.currentUser.lastName || '',
|
||||||
|
phoneNumber: this.currentUser.phoneNumber || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.doctorProfile) {
|
||||||
|
this.editDoctorData = {
|
||||||
|
medicalLicenseNumber: this.doctorProfile.medicalLicenseNumber || '',
|
||||||
|
specialization: this.doctorProfile.specialization || '',
|
||||||
|
yearsOfExperience: this.doctorProfile.yearsOfExperience,
|
||||||
|
biography: this.doctorProfile.biography || '',
|
||||||
|
consultationFee: this.doctorProfile.consultationFee,
|
||||||
|
defaultDurationMinutes: this.doctorProfile.defaultDurationMinutes,
|
||||||
|
// Enterprise fields
|
||||||
|
streetAddress: this.doctorProfile.streetAddress || '',
|
||||||
|
city: this.doctorProfile.city || '',
|
||||||
|
state: this.doctorProfile.state || '',
|
||||||
|
zipCode: this.doctorProfile.zipCode || '',
|
||||||
|
country: this.doctorProfile.country || '',
|
||||||
|
educationDegree: this.doctorProfile.educationDegree || '',
|
||||||
|
educationUniversity: this.doctorProfile.educationUniversity || '',
|
||||||
|
educationGraduationYear: this.doctorProfile.educationGraduationYear,
|
||||||
|
certifications: [...(this.doctorProfile.certifications || [])],
|
||||||
|
languagesSpoken: [...(this.doctorProfile.languagesSpoken || [])],
|
||||||
|
hospitalAffiliations: [...(this.doctorProfile.hospitalAffiliations || [])],
|
||||||
|
insuranceAccepted: [...(this.doctorProfile.insuranceAccepted || [])],
|
||||||
|
professionalMemberships: [...(this.doctorProfile.professionalMemberships || [])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditProfile() {
|
||||||
|
// Initialize form data with current values when editing starts
|
||||||
|
this.initializeFormData();
|
||||||
|
this.editProfileClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
// Reset form data to original values
|
||||||
|
this.initializeFormData();
|
||||||
|
this.editProfileClick.emit(); // Emit to close edit mode
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateProfile() {
|
||||||
|
this.updateProfile.emit({
|
||||||
|
userData: this.editUserData,
|
||||||
|
doctorData: this.editDoctorData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
this.fileSelected.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array management methods
|
||||||
|
addCertification() {
|
||||||
|
if (this.newCertification && this.newCertification.trim()) {
|
||||||
|
if (!this.editDoctorData.certifications) {
|
||||||
|
this.editDoctorData.certifications = [];
|
||||||
|
}
|
||||||
|
this.editDoctorData.certifications.push(this.newCertification.trim());
|
||||||
|
this.newCertification = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCertification(index: number) {
|
||||||
|
if (this.editDoctorData.certifications) {
|
||||||
|
this.editDoctorData.certifications.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addLanguage() {
|
||||||
|
if (this.newLanguage && this.newLanguage.trim()) {
|
||||||
|
if (!this.editDoctorData.languagesSpoken) {
|
||||||
|
this.editDoctorData.languagesSpoken = [];
|
||||||
|
}
|
||||||
|
this.editDoctorData.languagesSpoken.push(this.newLanguage.trim());
|
||||||
|
this.newLanguage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLanguage(index: number) {
|
||||||
|
if (this.editDoctorData.languagesSpoken) {
|
||||||
|
this.editDoctorData.languagesSpoken.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addHospitalAffiliation() {
|
||||||
|
if (this.newHospitalAffiliation && this.newHospitalAffiliation.trim()) {
|
||||||
|
if (!this.editDoctorData.hospitalAffiliations) {
|
||||||
|
this.editDoctorData.hospitalAffiliations = [];
|
||||||
|
}
|
||||||
|
this.editDoctorData.hospitalAffiliations.push(this.newHospitalAffiliation.trim());
|
||||||
|
this.newHospitalAffiliation = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeHospitalAffiliation(index: number) {
|
||||||
|
if (this.editDoctorData.hospitalAffiliations) {
|
||||||
|
this.editDoctorData.hospitalAffiliations.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addInsuranceAccepted() {
|
||||||
|
if (this.newInsuranceAccepted && this.newInsuranceAccepted.trim()) {
|
||||||
|
if (!this.editDoctorData.insuranceAccepted) {
|
||||||
|
this.editDoctorData.insuranceAccepted = [];
|
||||||
|
}
|
||||||
|
this.editDoctorData.insuranceAccepted.push(this.newInsuranceAccepted.trim());
|
||||||
|
this.newInsuranceAccepted = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInsuranceAccepted(index: number) {
|
||||||
|
if (this.editDoctorData.insuranceAccepted) {
|
||||||
|
this.editDoctorData.insuranceAccepted.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addProfessionalMembership() {
|
||||||
|
if (this.newProfessionalMembership && this.newProfessionalMembership.trim()) {
|
||||||
|
if (!this.editDoctorData.professionalMemberships) {
|
||||||
|
this.editDoctorData.professionalMemberships = [];
|
||||||
|
}
|
||||||
|
this.editDoctorData.professionalMemberships.push(this.newProfessionalMembership.trim());
|
||||||
|
this.newProfessionalMembership = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProfessionalMembership(index: number) {
|
||||||
|
if (this.editDoctorData.professionalMemberships) {
|
||||||
|
this.editDoctorData.professionalMemberships.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<section class="security-section">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Security Settings</h2>
|
||||||
|
<p class="section-description">Manage your account security and two-factor authentication</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div class="error-message" *ngIf="error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
<button type="button" (click)="error = null">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA Status Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="security-status">
|
||||||
|
<div class="status-header">
|
||||||
|
<div>
|
||||||
|
<h3>Two-Factor Authentication (2FA)</h3>
|
||||||
|
<p class="security-description">
|
||||||
|
Two-factor authentication adds an extra layer of security to your account.
|
||||||
|
When enabled, you'll need your password and a verification code from your authenticator app to sign in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-badge" [class]="twoFAEnabled ? 'status-enabled' : 'status-disabled'">
|
||||||
|
<svg *ngIf="twoFAEnabled" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="!twoFAEnabled" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ twoFAEnabled ? 'Enabled' : 'Disabled' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-details" *ngIf="twoFAStatus">
|
||||||
|
<div class="detail-item" *ngIf="twoFAStatus.hasBackupCodes">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>You have {{ twoFAStatus.backupCodesCount || 0 }} backup codes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="security-actions">
|
||||||
|
<button *ngIf="!twoFAEnabled" class="primary-button" (click)="onSetup2FA()" [disabled]="loading">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Enable 2FA
|
||||||
|
</button>
|
||||||
|
<button *ngIf="twoFAEnabled" class="danger-button" (click)="onDisable2FA()" [disabled]="loading">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA Setup Modal -->
|
||||||
|
<div class="modal-overlay" *ngIf="show2FAModal" (click)="close2FAModal()">
|
||||||
|
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Set Up Two-Factor Authentication</h2>
|
||||||
|
<button class="modal-close" (click)="close2FAModal()" title="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Step 1: QR Code and Secret Key -->
|
||||||
|
<div class="setup-step" *ngIf="!showBackupCodes">
|
||||||
|
<h3>Step 1: Scan QR Code</h3>
|
||||||
|
<p class="step-instructions">
|
||||||
|
Open your authenticator app (like Google Authenticator, Authy, or Microsoft Authenticator) and scan this QR code.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-code-container">
|
||||||
|
<img *ngIf="qrCodeUrl" [src]="qrCodeUrl" alt="2FA QR Code" class="qr-code-image">
|
||||||
|
<div class="loading-placeholder" *ngIf="!qrCodeUrl && loading">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="32" stroke-dashoffset="32">
|
||||||
|
<animate attributeName="stroke-dasharray" dur="2s" values="0 32;16 16;0 32;0 32" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-16;-32;-32" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="secret-key-section">
|
||||||
|
<h4>Can't scan? Enter this code manually:</h4>
|
||||||
|
<div class="secret-key-display">
|
||||||
|
<code class="secret-key-text">{{ twoFASecretKey }}</code>
|
||||||
|
<button class="icon-button" (click)="copySecretKey()" [class.copied]="copiedSecretKey" title="Copy secret key">
|
||||||
|
<svg *ngIf="!copiedSecretKey" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="copiedSecretKey" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="copied-message" *ngIf="copiedSecretKey">Copied to clipboard!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verification-section">
|
||||||
|
<h3>Step 2: Verify Setup</h3>
|
||||||
|
<p class="step-instructions">
|
||||||
|
Enter the 6-digit code from your authenticator app to verify and enable 2FA.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Verification Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="twoFACodeInput"
|
||||||
|
(ngModelChange)="onCodeInputChange()"
|
||||||
|
maxlength="6"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
inputmode="numeric"
|
||||||
|
class="form-input code-input"
|
||||||
|
placeholder="000000"
|
||||||
|
autocomplete="one-time-code">
|
||||||
|
<p class="form-hint">Enter the 6-digit code from your authenticator app</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Backup Codes -->
|
||||||
|
<div class="setup-step backup-codes-step" *ngIf="showBackupCodes">
|
||||||
|
<h3>Step 3: Save Your Backup Codes</h3>
|
||||||
|
<p class="step-instructions important-notice">
|
||||||
|
<strong>Important:</strong> Save these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="backup-codes-container">
|
||||||
|
<div class="backup-codes-list">
|
||||||
|
<div *ngFor="let code of twoFABackupCodes; let i = index" class="backup-code-item">
|
||||||
|
<span class="code-number">{{ i + 1 }}</span>
|
||||||
|
<code class="backup-code-text">{{ code }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backup-codes-actions">
|
||||||
|
<button class="secondary-button" (click)="copyBackupCodes()" [class.copied]="copiedBackupCodes">
|
||||||
|
<svg *ngIf="!copiedBackupCodes" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="copiedBackupCodes" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ copiedBackupCodes ? 'Copied!' : 'Copy Codes' }}
|
||||||
|
</button>
|
||||||
|
<button class="secondary-button" (click)="downloadBackupCodes()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15M17 8L12 3M12 3L7 8M12 3V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Download Codes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="secondary-button" (click)="close2FAModal()" *ngIf="!showBackupCodes">Cancel</button>
|
||||||
|
<button class="secondary-button" (click)="showBackupCodes = false" *ngIf="showBackupCodes">Back</button>
|
||||||
|
<button class="primary-button" (click)="verifyAndEnable2FA()" [disabled]="loading || !twoFACodeInput || twoFACodeInput.length !== 6" *ngIf="!showBackupCodes">
|
||||||
|
<span *ngIf="loading">Verifying...</span>
|
||||||
|
<span *ngIf="!loading">Verify and Enable</span>
|
||||||
|
</button>
|
||||||
|
<button class="primary-button" (click)="close2FAModal()" *ngIf="showBackupCodes">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Security Information -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Security Tips</h3>
|
||||||
|
<ul class="security-tips">
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Use a strong, unique password for your account
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Enable two-factor authentication for enhanced security
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Keep your backup codes in a safe, secure location
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Never share your 2FA codes or backup codes with anyone
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Log out from shared or public computers
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
// Enterprise-Grade Security Component Styling
|
||||||
|
// Color Palette
|
||||||
|
$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
$primary-blue: #0066cc;
|
||||||
|
$primary-blue-light: #e6f2ff;
|
||||||
|
$primary-blue-dark: #0052a3;
|
||||||
|
$accent-teal: #0099a1;
|
||||||
|
$accent-purple: #8b5cf6;
|
||||||
|
$text-dark: #1a1a1a;
|
||||||
|
$text-medium: #4a4a4a;
|
||||||
|
$text-light: #6b6b6b;
|
||||||
|
$text-lighter: #9ca3af;
|
||||||
|
$border-color: #e8eaed;
|
||||||
|
$border-focus: #0066cc;
|
||||||
|
$background-light: #f8f9fa;
|
||||||
|
$background-page: #f5f7fa;
|
||||||
|
$background-elevated: #ffffff;
|
||||||
|
$white: #ffffff;
|
||||||
|
$success-green: #10b981;
|
||||||
|
$success-green-light: #d1fae5;
|
||||||
|
$success-green-dark: #059669;
|
||||||
|
$error-red: #ef4444;
|
||||||
|
$error-red-light: #fee2e2;
|
||||||
|
$error-red-dark: #dc2626;
|
||||||
|
$warning-orange: #f59e0b;
|
||||||
|
$warning-orange-light: #fef3c7;
|
||||||
|
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
$border-radius-sm: 8px;
|
||||||
|
$border-radius-md: 12px;
|
||||||
|
$border-radius-lg: 16px;
|
||||||
|
$border-radius-xl: 20px;
|
||||||
|
$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.security-section {
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(135deg, $error-red-light 0%, rgba(254, 226, 226, 0.5) 100%);
|
||||||
|
color: $error-red-dark;
|
||||||
|
border: 1.5px solid $error-red;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: $error-red-dark;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all $transition-base;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $error-red;
|
||||||
|
color: $white;
|
||||||
|
transform: rotate(90deg) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-status {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-description {
|
||||||
|
font-size: 15px;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-enabled {
|
||||||
|
background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%);
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-disabled {
|
||||||
|
background: linear-gradient(135deg, $text-lighter 0%, #6b7280 100%);
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 4px 12px rgba(156, 163, 175, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: $success-green;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.danger-button,
|
||||||
|
.secondary-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-base;
|
||||||
|
border: none;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: $primary-gradient;
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%);
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: $white;
|
||||||
|
color: $text-medium;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $background-light;
|
||||||
|
border-color: $text-light;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.copied {
|
||||||
|
background: linear-gradient(135deg, $success-green-light 0%, rgba(209, 250, 229, 0.5) 100%);
|
||||||
|
border-color: $success-green;
|
||||||
|
color: $success-green-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Styles
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $shadow-2xl;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: modalSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 24px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
background: $primary-gradient;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: $text-light;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all $transition-base;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $background-light;
|
||||||
|
color: $text-dark;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-instructions {
|
||||||
|
font-size: 15px;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
|
||||||
|
&.important-notice {
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(135deg, $warning-orange-light 0%, rgba(254, 243, 199, 0.5) 100%);
|
||||||
|
border: 1.5px solid $warning-orange;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
color: darken($warning-orange, 20%);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: $white;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 250px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: $primary-blue;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-key-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-key-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: $background-light;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
.secret-key-text {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: $white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
background: $white;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
color: $text-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all $transition-base;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
transition: transform $transition-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-blue-light;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
color: $primary-blue;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.copied {
|
||||||
|
background: $success-green-light;
|
||||||
|
border-color: $success-green;
|
||||||
|
color: $success-green-dark;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $success-green-light;
|
||||||
|
border-color: $success-green;
|
||||||
|
color: $success-green-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copied-message {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $success-green-dark;
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $white;
|
||||||
|
transition: all $transition-base;
|
||||||
|
font-family: inherit;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-lighter;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1), $shadow-md;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:focus) {
|
||||||
|
border-color: rgba(0, 0, 0, 0.12);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.code-input {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-lighter;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-step {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-container {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-code-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: $white;
|
||||||
|
border: 1.5px solid $border-color;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $background-light;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-number {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-medium;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-code-text {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
background: $background-light;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security Tips
|
||||||
|
.security-tips {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all $transition-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: $success-green;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span,
|
||||||
|
&::before {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-dark;
|
||||||
|
line-height: 1.6;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text content is directly in li, so we style it
|
||||||
|
&:not(:has(span)) {
|
||||||
|
&::before {
|
||||||
|
content: attr(data-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive Design
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.security-section {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.security-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.danger-button,
|
||||||
|
.secondary-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-codes-actions .secondary-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced focus states for accessibility
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid $primary-blue;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth transitions
|
||||||
|
* {
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
.security-section,
|
||||||
|
.card,
|
||||||
|
.modal-content {
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TwoFactorAuthService, TwoFactorStatus, TwoFactorSetupResponse } from '../../../../services/two-factor-auth.service';
|
||||||
|
import { ModalService } from '../../../../services/modal.service';
|
||||||
|
import { LoggerService } from '../../../../services/logger.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-security',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './security.component.html',
|
||||||
|
styleUrl: './security.component.scss'
|
||||||
|
})
|
||||||
|
export class SecurityComponent implements OnInit {
|
||||||
|
@Input() twoFAEnabled = false;
|
||||||
|
@Input() twoFAStatus: TwoFactorStatus | null = null;
|
||||||
|
@Output() setup2FA = new EventEmitter<void>();
|
||||||
|
@Output() disable2FA = new EventEmitter<string>();
|
||||||
|
|
||||||
|
// 2FA Modal State
|
||||||
|
show2FAModal = false;
|
||||||
|
qrCodeUrl: string = '';
|
||||||
|
twoFASecretKey: string = '';
|
||||||
|
twoFABackupCodes: string[] = [];
|
||||||
|
twoFACodeInput: string = '';
|
||||||
|
error: string | null = null;
|
||||||
|
loading = false;
|
||||||
|
showBackupCodes = false;
|
||||||
|
copiedSecretKey = false;
|
||||||
|
copiedBackupCodes = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private twoFactorAuthService: TwoFactorAuthService,
|
||||||
|
private modalService: ModalService,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Component is initialized with inputs from parent
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSetup2FA() {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
const setup: TwoFactorSetupResponse = await this.twoFactorAuthService.setup2FA();
|
||||||
|
this.qrCodeUrl = setup.qrCodeUrl;
|
||||||
|
this.twoFASecretKey = setup.secretKey;
|
||||||
|
this.twoFABackupCodes = setup.backupCodes || [];
|
||||||
|
this.twoFACodeInput = '';
|
||||||
|
this.show2FAModal = true;
|
||||||
|
this.showBackupCodes = false;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to setup 2FA';
|
||||||
|
this.logger.error('Error setting up 2FA:', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAndEnable2FA() {
|
||||||
|
if (!this.twoFACodeInput || this.twoFACodeInput.length !== 6) {
|
||||||
|
this.error = 'Please enter a valid 6-digit code';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
await this.twoFactorAuthService.enable2FA(this.twoFACodeInput);
|
||||||
|
await this.refresh2FAStatus();
|
||||||
|
this.show2FAModal = false;
|
||||||
|
this.showBackupCodes = true;
|
||||||
|
// Show success message
|
||||||
|
setTimeout(async () => {
|
||||||
|
await this.modalService.alert(
|
||||||
|
'2FA enabled successfully! Please save your backup codes.',
|
||||||
|
'success',
|
||||||
|
'2FA Enabled'
|
||||||
|
);
|
||||||
|
}, 100);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to enable 2FA';
|
||||||
|
this.logger.error('Error enabling 2FA:', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close2FAModal() {
|
||||||
|
this.show2FAModal = false;
|
||||||
|
this.qrCodeUrl = '';
|
||||||
|
this.twoFASecretKey = '';
|
||||||
|
this.twoFABackupCodes = [];
|
||||||
|
this.twoFACodeInput = '';
|
||||||
|
this.error = null;
|
||||||
|
this.showBackupCodes = false;
|
||||||
|
this.copiedSecretKey = false;
|
||||||
|
this.copiedBackupCodes = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDisable2FA() {
|
||||||
|
const code = await this.modalService.prompt(
|
||||||
|
'Enter your 2FA code or backup code to disable two-factor authentication:',
|
||||||
|
'Disable 2FA',
|
||||||
|
'2FA Code',
|
||||||
|
'Enter 2FA code or backup code',
|
||||||
|
'text',
|
||||||
|
'Disable',
|
||||||
|
'Cancel'
|
||||||
|
);
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
await this.twoFactorAuthService.disable2FA(code);
|
||||||
|
await this.refresh2FAStatus();
|
||||||
|
this.disable2FA.emit(code);
|
||||||
|
await this.modalService.alert(
|
||||||
|
'2FA disabled successfully',
|
||||||
|
'success',
|
||||||
|
'2FA Disabled'
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.error || 'Failed to disable 2FA';
|
||||||
|
await this.modalService.alert(
|
||||||
|
this.error || 'Failed to disable 2FA',
|
||||||
|
'error',
|
||||||
|
'2FA Error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh2FAStatus() {
|
||||||
|
try {
|
||||||
|
const status = await this.twoFactorAuthService.get2FAStatus();
|
||||||
|
this.twoFAEnabled = status.enabled;
|
||||||
|
this.twoFAStatus = status;
|
||||||
|
this.setup2FA.emit(); // Notify parent to refresh
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error('Error refreshing 2FA status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copySecretKey() {
|
||||||
|
if (this.twoFASecretKey) {
|
||||||
|
navigator.clipboard.writeText(this.twoFASecretKey).then(() => {
|
||||||
|
this.copiedSecretKey = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.copiedSecretKey = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyBackupCodes() {
|
||||||
|
if (this.twoFABackupCodes.length > 0) {
|
||||||
|
navigator.clipboard.writeText(this.twoFABackupCodes.join('\n')).then(() => {
|
||||||
|
this.copiedBackupCodes = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.copiedBackupCodes = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBackupCodes() {
|
||||||
|
const content = this.twoFABackupCodes.join('\n');
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = '2fa-backup-codes.txt';
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCodeInputChange() {
|
||||||
|
// Auto-verify if 6 digits entered
|
||||||
|
if (this.twoFACodeInput.length === 6 && /^\d{6}$/.test(this.twoFACodeInput)) {
|
||||||
|
// Optional: auto-verify on 6 digits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
987
frontend/src/app/pages/doctor/doctor.component.html
Normal file
987
frontend/src/app/pages/doctor/doctor.component.html
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
<div class="dashboard-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo-section">
|
||||||
|
<svg class="medical-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V16M8 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1 class="dashboard-title">Doctor Dashboard</h1>
|
||||||
|
<p class="dashboard-subtitle">Welcome, {{ currentUser?.firstName }} {{ currentUser?.lastName }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<!-- Notifications -->
|
||||||
|
<div class="notification-container">
|
||||||
|
<button class="notification-button" (click)="toggleNotifications()" [class.has-notifications]="notificationCount > 0" title="Notifications">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8C6 9.65735 5.32843 11.2336 4.17157 12.4142C3.01472 13.5949 2 14.7712 2 16V17H22V16C22 14.7712 21.0147 13.5949 19.8579 12.4142C18.7011 11.2336 18 9.65735 18 8Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 21H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 3C10 2.44772 10.4477 2 11 2H13C13.5523 2 14 2.44772 14 3V4H10V3Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="notification-badge" *ngIf="notificationCount > 0">{{ notificationCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Notification Dropdown -->
|
||||||
|
<div class="notification-dropdown" *ngIf="showNotifications">
|
||||||
|
<div class="notification-header">
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="mark-all-read" *ngIf="notificationCount > 0" (click)="markAllNotificationsAsRead()">Mark all read</button>
|
||||||
|
<button class="delete-all" *ngIf="notifications.length > 0" (click)="deleteAllNotifications()" title="Delete all notifications">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notification-list">
|
||||||
|
<div *ngIf="notifications.length === 0" class="no-notifications">
|
||||||
|
<p>No notifications</p>
|
||||||
|
</div>
|
||||||
|
<div *ngFor="let notification of notifications"
|
||||||
|
class="notification-item"
|
||||||
|
[class.unread]="!notification.read"
|
||||||
|
(click)="handleNotificationClick(notification)">
|
||||||
|
<div class="notification-icon">
|
||||||
|
<!-- Message Icon -->
|
||||||
|
<svg *ngIf="notification.type === 'message'" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<!-- Missed Call Icon -->
|
||||||
|
<svg *ngIf="notification.type === 'missed-call'" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 16.92V19.92C22 20.6204 21.719 21.2922 21.219 21.7922C20.719 22.2922 20.0474 22.5734 19.347 22.5134C15.8482 22.2324 12.4895 21.0003 9.6 18.86C7.21358 17.0896 5.35036 14.7065 4.2 12C3.04964 9.29352 2.66265 6.44787 3.07 3.64C3.13826 2.94623 3.43566 2.29903 3.91368 1.80642C4.39171 1.31381 5.01885 1.00758 5.7 1H8.7C9.29674 0.994966 9.87908 1.16796 10.3813 1.49754C10.8835 1.82712 11.2839 2.29965 11.53 2.86L13.08 6.58C13.2833 7.06943 13.3459 7.60609 13.2612 8.12757C13.1765 8.64906 12.9484 9.13502 12.6 9.53L11 11.13C12.7613 13.3728 15.1272 15.2387 17.37 17L18.97 15.4C19.365 15.0516 19.851 14.8235 20.3724 14.7388C20.8939 14.6541 21.4306 14.7167 21.92 14.92L25.64 16.47C26.1928 16.7132 26.6677 17.1107 26.9989 17.6098C27.3301 18.1088 27.5035 18.6879 27.5 19.28L27.5 22.28C27.4913 22.9658 27.2056 23.6191 26.7058 24.1051C26.206 24.5912 25.5326 24.8734 24.83 24.89C22.8968 24.9587 20.9658 24.7498 19.1 24.27" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 2L22 22M22 2L2 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="notification-title">{{ notification.title }}</div>
|
||||||
|
<div class="notification-message">{{ notification.message }}</div>
|
||||||
|
<div class="notification-time">{{ getNotificationTime(notification.timestamp) }}</div>
|
||||||
|
</div>
|
||||||
|
<button class="notification-delete"
|
||||||
|
(click)="deleteNotification(notification.id, $event)"
|
||||||
|
title="Delete notification">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Icon - Facebook Style -->
|
||||||
|
<div class="chat-menu-container">
|
||||||
|
<button class="chat-menu-button" (click)="toggleChatMenu()" [class.active]="showChatMenu" [class.has-unread]="chatUnreadCount > 0" title="Messages">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="chat-menu-badge" *ngIf="chatUnreadCount > 0">{{ chatUnreadCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Blocked Users Panel (from chat menu) -->
|
||||||
|
<div class="blocked-users-panel-dropdown" *ngIf="showBlockedUsers && showChatMenu" (click)="$event.stopPropagation()">
|
||||||
|
<div class="blocked-users-header-dropdown">
|
||||||
|
<h3>Blocked Users</h3>
|
||||||
|
<button class="close-blocked-users" (click)="toggleBlockedUsers()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blocked-users-content-dropdown">
|
||||||
|
<div class="blocked-loading" *ngIf="isLoadingBlockedUsers">
|
||||||
|
<p>Loading blocked users...</p>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-users-list-dropdown" *ngIf="!isLoadingBlockedUsers">
|
||||||
|
<div
|
||||||
|
*ngFor="let user of blockedUsers"
|
||||||
|
class="blocked-user-item-dropdown">
|
||||||
|
<div class="blocked-user-avatar-dropdown">
|
||||||
|
<img *ngIf="user.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(user.avatarUrl)"
|
||||||
|
[alt]="user.firstName + ' ' + user.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)">
|
||||||
|
<div class="avatar-circle-dropdown"
|
||||||
|
[style.display]="user.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-user-info-dropdown">
|
||||||
|
<div class="blocked-user-name-dropdown">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="blocked-user-details-dropdown">
|
||||||
|
<span *ngIf="user.specialization">{{ user.specialization }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="unblock-button-dropdown" (click)="handleUnblockClick(user, $event)" title="Unblock user">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" pointer-events="none">
|
||||||
|
<path d="M18 6L6 18"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Unblock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="no-blocked-users-dropdown" *ngIf="blockedUsers.length === 0 && !isLoadingBlockedUsers">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<p>No blocked users</p>
|
||||||
|
<span>You haven't blocked anyone yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Menu Dropdown (Conversation List) -->
|
||||||
|
<div class="chat-menu-dropdown" *ngIf="showChatMenu && !showBlockedUsers" (click)="$event.stopPropagation()">
|
||||||
|
<div class="chat-menu-header">
|
||||||
|
<h3>Messages</h3>
|
||||||
|
<div class="chat-menu-header-actions">
|
||||||
|
<button class="chat-menu-blocked-users" (click)="toggleBlockedUsers()" title="Blocked Users">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
<circle cx="18" cy="8" r="2.5"></circle>
|
||||||
|
<line x1="16.5" y1="6.5" x2="19.5" y2="9.5"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="chat-menu-close" (click)="toggleChatMenu()" title="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-menu-search">
|
||||||
|
<div class="search-input-container">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search for people..."
|
||||||
|
[(ngModel)]="chatSearchQuery"
|
||||||
|
(input)="onChatSearch($event)"
|
||||||
|
(focus)="showChatSearchResults = true">
|
||||||
|
<button
|
||||||
|
*ngIf="chatSearchQuery"
|
||||||
|
class="search-clear"
|
||||||
|
(click)="clearChatSearch()"
|
||||||
|
title="Clear search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-list-dropdown">
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div *ngIf="chatSearchQuery && chatSearchResults.length > 0" class="search-results-section">
|
||||||
|
<div class="search-results-header">Search Results ({{ chatSearchResults.length }})</div>
|
||||||
|
<div *ngFor="let user of chatSearchResults"
|
||||||
|
class="conversation-item-dropdown"
|
||||||
|
(click)="openChatWithUser(user.userId)">
|
||||||
|
<div class="conversation-avatar-dropdown">
|
||||||
|
<img *ngIf="user.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(user.avatarUrl)"
|
||||||
|
[alt]="user.firstName + ' ' + user.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)">
|
||||||
|
<div class="avatar-circle-dropdown"
|
||||||
|
[style.display]="user.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div class="online-indicator-dropdown"
|
||||||
|
[class.online]="user.isOnline && user.status !== 'BUSY' && user.status !== 'OFFLINE'"
|
||||||
|
[class.offline]="!user.isOnline || user.status === 'OFFLINE'"
|
||||||
|
[class.busy]="user.status === 'BUSY'"
|
||||||
|
[title]="user.status || 'OFFLINE'"></div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-info-dropdown">
|
||||||
|
<div class="conversation-name-dropdown">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="conversation-preview-dropdown" *ngIf="user.specialization">{{ user.specialization }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Conversations (filtered by search) -->
|
||||||
|
<div class="conversations-section">
|
||||||
|
<div class="conversations-header" *ngIf="chatSearchQuery && chatComponent?.conversations && (chatComponent?.conversations?.length ?? 0) > 0">Conversations ({{ filteredConversations.length }})</div>
|
||||||
|
<div *ngIf="(!chatSearchQuery && (!chatComponent?.conversations || chatComponent?.conversations?.length === 0)) || (chatSearchQuery && (!filteredConversations || filteredConversations.length === 0))" class="no-conversations">
|
||||||
|
<p>{{ chatSearchQuery ? 'No conversations found' : 'No conversations yet' }}</p>
|
||||||
|
<p class="subtext">{{ chatSearchQuery ? 'Try a different search term' : 'Start a conversation to begin messaging' }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Show message when searching and no results -->
|
||||||
|
<div *ngIf="chatSearchQuery && chatSearchResults.length === 0 && filteredConversations.length === 0 && chatSearchQuery.length >= 2" class="no-conversations">
|
||||||
|
<p>No users found</p>
|
||||||
|
<p class="subtext">Try searching with a different name</p>
|
||||||
|
</div>
|
||||||
|
<div *ngFor="let conversation of (chatSearchQuery ? filteredConversations : (chatComponent?.conversations || []))"
|
||||||
|
class="conversation-item-dropdown"
|
||||||
|
[class.active]="chatComponent?.selectedConversation?.otherUserId === conversation.otherUserId"
|
||||||
|
(click)="openChatConversation(conversation.otherUserId)">
|
||||||
|
<div class="conversation-avatar-dropdown">
|
||||||
|
<img *ngIf="conversation.otherUserAvatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(conversation.otherUserAvatarUrl)"
|
||||||
|
[alt]="conversation.otherUserName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)">
|
||||||
|
<div class="avatar-circle-dropdown"
|
||||||
|
[style.display]="conversation.otherUserAvatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ conversation.otherUserName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div class="online-indicator-dropdown"
|
||||||
|
[class.online]="conversation.isOnline && conversation.otherUserStatus !== 'BUSY' && conversation.otherUserStatus !== 'OFFLINE'"
|
||||||
|
[class.offline]="!conversation.isOnline || conversation.otherUserStatus === 'OFFLINE'"
|
||||||
|
[class.busy]="conversation.otherUserStatus === 'BUSY'"
|
||||||
|
[title]="conversation.otherUserStatus || 'OFFLINE'"></div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-info-dropdown">
|
||||||
|
<div class="conversation-name-dropdown">{{ conversation.otherUserName }}</div>
|
||||||
|
<div class="conversation-preview-dropdown">{{ conversation.lastMessage?.content || 'No messages yet' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-meta-dropdown">
|
||||||
|
<div class="conversation-time-dropdown" *ngIf="conversation.lastMessage?.createdAt">
|
||||||
|
{{ getConversationTime(conversation.lastMessage!.createdAt) }}
|
||||||
|
</div>
|
||||||
|
<div class="conversation-unread-dropdown" *ngIf="conversation.unreadCount > 0">
|
||||||
|
{{ conversation.unreadCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocked Users Button -->
|
||||||
|
<div class="blocked-users-container">
|
||||||
|
<button class="blocked-users-button-header" (click)="toggleBlockedUsers($event)" [class.active]="showBlockedUsers" title="Blocked Users">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
<circle cx="18" cy="8" r="2.5"></circle>
|
||||||
|
<line x1="16.5" y1="6.5" x2="19.5" y2="9.5"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Blocked Users Panel (from header) -->
|
||||||
|
<div class="blocked-users-panel-dropdown" *ngIf="showBlockedUsers && !showChatMenu" (click)="$event.stopPropagation()">
|
||||||
|
<div class="blocked-users-header-dropdown">
|
||||||
|
<h3>Blocked Users</h3>
|
||||||
|
<button class="close-blocked-users" (click)="toggleBlockedUsers()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blocked-users-content-dropdown">
|
||||||
|
<div class="blocked-loading" *ngIf="isLoadingBlockedUsers">
|
||||||
|
<p>Loading blocked users...</p>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-users-list-dropdown" *ngIf="!isLoadingBlockedUsers">
|
||||||
|
<div
|
||||||
|
*ngFor="let user of blockedUsers"
|
||||||
|
class="blocked-user-item-dropdown">
|
||||||
|
<div class="blocked-user-avatar-dropdown">
|
||||||
|
<img *ngIf="user.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(user.avatarUrl)"
|
||||||
|
[alt]="user.firstName + ' ' + user.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)">
|
||||||
|
<div class="avatar-circle-dropdown"
|
||||||
|
[style.display]="user.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-user-info-dropdown">
|
||||||
|
<div class="blocked-user-name-dropdown">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="blocked-user-details-dropdown">
|
||||||
|
<span *ngIf="user.specialization">{{ user.specialization }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="unblock-button-dropdown" (click)="handleUnblockClick(user, $event)" title="Unblock user">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" pointer-events="none">
|
||||||
|
<path d="M18 6L6 18"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Unblock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="no-blocked-users-dropdown" *ngIf="blockedUsers.length === 0 && !isLoadingBlockedUsers">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<p>No blocked users</p>
|
||||||
|
<span>You haven't blocked anyone yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="refresh-button" (click)="refresh()" [disabled]="loading" title="Refresh">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 4V10H7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M23 20V14H17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14L18.36 18.36A9 9 0 0 1 3.51 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Profile Dropdown -->
|
||||||
|
<div class="profile-menu-container">
|
||||||
|
<button class="profile-menu-button" (click)="toggleProfileMenu()" [class.active]="showProfileMenu" title="Profile">
|
||||||
|
<div class="profile-avatar-small">
|
||||||
|
<img *ngIf="currentUser?.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(currentUser?.avatarUrl || '')"
|
||||||
|
[alt]="(currentUser?.firstName || '') + ' ' + (currentUser?.lastName || '')"
|
||||||
|
class="avatar-image-small"
|
||||||
|
(error)="onImageError($event)">
|
||||||
|
<div class="avatar-circle-small"
|
||||||
|
[style.display]="currentUser?.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ currentUser?.firstName?.charAt(0) }}{{ currentUser?.lastName?.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Profile Dropdown Menu -->
|
||||||
|
<div class="profile-dropdown" *ngIf="showProfileMenu" (click)="$event.stopPropagation()">
|
||||||
|
<div class="profile-dropdown-header">
|
||||||
|
<div class="profile-avatar-medium">
|
||||||
|
<img *ngIf="currentUser?.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(currentUser?.avatarUrl || '')"
|
||||||
|
[alt]="(currentUser?.firstName || '') + ' ' + (currentUser?.lastName || '')"
|
||||||
|
class="avatar-image-medium"
|
||||||
|
(error)="onImageError($event)">
|
||||||
|
<div class="avatar-circle-medium"
|
||||||
|
[style.display]="currentUser?.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ currentUser?.firstName?.charAt(0) }}{{ currentUser?.lastName?.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-dropdown-info">
|
||||||
|
<div class="profile-dropdown-name">Dr. {{ currentUser?.firstName }} {{ currentUser?.lastName }}</div>
|
||||||
|
<div class="profile-dropdown-email">{{ currentUser?.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-dropdown-divider"></div>
|
||||||
|
<div class="profile-dropdown-menu">
|
||||||
|
<button class="profile-menu-item" (click)="setTab('profile'); showProfileMenu = false">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>View Profile</span>
|
||||||
|
</button>
|
||||||
|
<button class="profile-menu-item" (click)="setTab('security'); showProfileMenu = false">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Security Settings</span>
|
||||||
|
</button>
|
||||||
|
<div class="profile-dropdown-divider"></div>
|
||||||
|
<button class="profile-menu-item logout-item" (click)="logout(); showProfileMenu = false">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 17L21 12L16 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 12H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="dashboard-main">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div class="loading-container" *ngIf="loading">
|
||||||
|
<div class="spinner-wrapper">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" stroke-opacity="0.25"/>
|
||||||
|
<path d="M12 2C16.4183 2 20 5.58172 20 10" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="loading-text">Loading dashboard...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div class="error-container" *ngIf="error && !loading">
|
||||||
|
<div class="error-card">
|
||||||
|
<svg class="error-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<div class="error-content">
|
||||||
|
<h3>Error</h3>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button class="retry-button" (click)="refresh()">Try Again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar and Content Layout -->
|
||||||
|
<div class="dashboard-layout" *ngIf="!loading && !error">
|
||||||
|
<!-- Left Sidebar - Tabs Navigation -->
|
||||||
|
<aside class="sidebar-navigation">
|
||||||
|
<nav class="tabs-container">
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'overview'"
|
||||||
|
(click)="setTab('overview')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 22V12H15V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'appointments'"
|
||||||
|
(click)="setTab('appointments')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Appointments
|
||||||
|
<span class="tab-badge">{{ getTotalAppointments() }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'create'"
|
||||||
|
(click)="setTab('create')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Create Appointment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'availability'"
|
||||||
|
(click)="setTab('availability')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Availability
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'patients'"
|
||||||
|
(click)="setTab('patients')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 11C11.2091 11 13 9.20914 13 7C13 4.79086 11.2091 3 9 3C6.79086 3 5 4.79086 5 7C5 9.20914 6.79086 11 9 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Patients
|
||||||
|
<span class="tab-badge">{{ patients.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'ehr'"
|
||||||
|
(click)="setTab('ehr')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 7H16M14 11H16M14 15H16M10 7H12M10 11H12M10 15H12M6 7H8M6 11H8M6 15H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
EHR Management
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'prescriptions'"
|
||||||
|
(click)="setTab('prescriptions')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Prescriptions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'safety'"
|
||||||
|
(click)="setTab('safety')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Safety Alerts
|
||||||
|
<span class="tab-badge critical" *ngIf="unacknowledgedAlertsCount > 0">{{ unacknowledgedAlertsCount }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'security'"
|
||||||
|
(click)="setTab('security')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Right Content Area -->
|
||||||
|
<div class="content-area">
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'overview'">
|
||||||
|
<section class="stats-section">
|
||||||
|
<h2 class="section-title">Dashboard Overview</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card stat-card-primary">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p class="stat-label">Total Appointments</p>
|
||||||
|
<p class="stat-value">{{ getTotalAppointments() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-card-success">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M9 14L11 16L15 12" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p class="stat-label">Upcoming</p>
|
||||||
|
<p class="stat-value">{{ getUpcomingCount() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-card-info">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p class="stat-label">Completed</p>
|
||||||
|
<p class="stat-value">{{ getCompletedCount() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-card-warning">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 11C11.2091 11 13 9.20914 13 7C13 4.79086 11.2091 3 9 3C6.79086 3 5 4.79086 5 7C5 9.20914 6.79086 11 9 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p class="stat-label">Patients</p>
|
||||||
|
<p class="stat-value">{{ patients.length }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<section class="quick-actions-section">
|
||||||
|
<h2 class="section-title">Quick Actions</h2>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<button class="action-card" (click)="setTab('create')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>Create Appointment</h3>
|
||||||
|
<p>Schedule a new appointment for a patient</p>
|
||||||
|
</button>
|
||||||
|
<button class="action-card" (click)="setTab('availability')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<h3>Manage Availability</h3>
|
||||||
|
<p>Set your working hours and availability</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Appointments Tab -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'appointments'">
|
||||||
|
<app-appointments
|
||||||
|
[appointments]="appointments"
|
||||||
|
[error]="error"
|
||||||
|
(refreshRequested)="refresh()">
|
||||||
|
</app-appointments>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Appointment Tab -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'create'">
|
||||||
|
<app-create-appointment
|
||||||
|
[patients]="patients"
|
||||||
|
[doctorId]="doctorId"
|
||||||
|
[selectedPatientId]="selectedPatientId"
|
||||||
|
(appointmentCreated)="refresh()">
|
||||||
|
</app-create-appointment>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Availability Tab -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'availability'">
|
||||||
|
<app-availability
|
||||||
|
[doctorId]="doctorId"
|
||||||
|
(availabilityChanged)="loadAvailability()">
|
||||||
|
</app-availability>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patients Tab -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'patients'">
|
||||||
|
<app-patients
|
||||||
|
[patients]="patients"
|
||||||
|
(patientRemoved)="handlePatientRemoved()"
|
||||||
|
(patientSelected)="selectPatient($event?.id)"
|
||||||
|
(createAppointmentRequested)="handleCreateAppointmentRequest($event)"
|
||||||
|
(startChatRequested)="handleStartChatRequest($event)">
|
||||||
|
</app-patients>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Tab -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'profile'">
|
||||||
|
<app-profile
|
||||||
|
[currentUser]="currentUser"
|
||||||
|
[doctorProfile]="doctorProfile"
|
||||||
|
[showEditProfile]="showEditProfile"
|
||||||
|
(editProfileClick)="editProfile()"
|
||||||
|
(updateProfile)="updateDoctorProfile($event)"
|
||||||
|
(fileSelected)="onFileSelected($event)">
|
||||||
|
</app-profile>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EHR Management Tab Panel -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'ehr'">
|
||||||
|
<app-ehr
|
||||||
|
[doctorId]="doctorId"
|
||||||
|
[selectedPatientId]="selectedPatientId"
|
||||||
|
[patients]="patients"
|
||||||
|
(patientSelected)="selectPatient($event)"
|
||||||
|
(dataChanged)="loadAllDoctorRecords()">
|
||||||
|
</app-ehr>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prescriptions Tab Panel -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'prescriptions'">
|
||||||
|
<app-prescriptions
|
||||||
|
(safetyCheckRequested)="handleSafetyCheckRequest()"
|
||||||
|
[doctorId]="doctorId"
|
||||||
|
[selectedPatientId]="selectedPatientId"
|
||||||
|
[prescriptions]="prescriptions"
|
||||||
|
[patients]="patients"
|
||||||
|
(prescriptionChanged)="refreshPrescriptions()"
|
||||||
|
(patientSelected)="selectPatient($event)">
|
||||||
|
</app-prescriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Safety Alerts Tab Panel -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'safety'">
|
||||||
|
<div class="safety-alerts-container">
|
||||||
|
<div class="safety-header">
|
||||||
|
<h2>Patient Safety Alerts</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-create" (click)="toggleCreateAlertForm()" [class.active]="showCreateAlertForm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="18" height="18">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ showCreateAlertForm ? 'Cancel' : 'Create Alert' }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh-btn" (click)="loadSafetyAlerts()" title="Refresh alerts">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20" height="20">
|
||||||
|
<path d="M1 4V10H7M23 20V14H17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14L18.36 18.36A9 9 0 0 1 3.51 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Alert Form -->
|
||||||
|
<div class="create-alert-section" *ngIf="showCreateAlertForm">
|
||||||
|
<h3>Create New Clinical Alert</h3>
|
||||||
|
<form class="alert-form" (ngSubmit)="createClinicalAlert()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert-patient">Patient <span class="required">*</span></label>
|
||||||
|
<select id="alert-patient" [(ngModel)]="newAlert.patientId" name="patientId" required>
|
||||||
|
<option value="">Select a patient</option>
|
||||||
|
<option *ngFor="let patient of patients" [value]="patient.id">
|
||||||
|
{{ patient.firstName }} {{ patient.lastName }} ({{ patient.email }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert-type">Alert Type <span class="required">*</span></label>
|
||||||
|
<select id="alert-type" [(ngModel)]="newAlert.alertType" name="alertType" required>
|
||||||
|
<option value="DRUG_INTERACTION">Drug Interaction</option>
|
||||||
|
<option value="ALLERGY">Allergy</option>
|
||||||
|
<option value="CONTRAINDICATION">Contraindication</option>
|
||||||
|
<option value="OVERDOSE_RISK">Overdose Risk</option>
|
||||||
|
<option value="DUPLICATE_THERAPY">Duplicate Therapy</option>
|
||||||
|
<option value="DOSE_ADJUSTMENT">Dose Adjustment</option>
|
||||||
|
<option value="LAB_RESULT_ALERT">Lab Result Alert</option>
|
||||||
|
<option value="VITAL_SIGN_ALERT">Vital Sign Alert</option>
|
||||||
|
<option value="COMPLIANCE_ALERT">Compliance Alert</option>
|
||||||
|
<option value="OTHER">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert-severity">Severity <span class="required">*</span></label>
|
||||||
|
<select id="alert-severity" [(ngModel)]="newAlert.severity" name="severity" required>
|
||||||
|
<option value="INFO">Info</option>
|
||||||
|
<option value="WARNING">Warning</option>
|
||||||
|
<option value="CRITICAL">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert-title">Title <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="alert-title"
|
||||||
|
[(ngModel)]="newAlert.title"
|
||||||
|
name="title"
|
||||||
|
placeholder="e.g., Drug Interaction Detected"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert-description">Description <span class="required">*</span></label>
|
||||||
|
<textarea
|
||||||
|
id="alert-description"
|
||||||
|
[(ngModel)]="newAlert.description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Describe the alert in detail..."
|
||||||
|
rows="4"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alert-medication">Medication Name (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="alert-medication"
|
||||||
|
[(ngModel)]="newAlert.medicationName"
|
||||||
|
name="medicationName"
|
||||||
|
placeholder="e.g., Warfarin, Aspirin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" (click)="toggleCreateAlertForm()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-submit">
|
||||||
|
Create Alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clinical Alerts Section -->
|
||||||
|
<div class="alerts-section">
|
||||||
|
<h3>
|
||||||
|
Clinical Alerts
|
||||||
|
<span class="badge" *ngIf="unacknowledgedAlerts.length > 0">{{ unacknowledgedAlerts.length }}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div *ngIf="unacknowledgedAlerts.length === 0" class="no-alerts">
|
||||||
|
<p>No unacknowledged clinical alerts</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngFor="let alert of unacknowledgedAlerts" class="alert-card" [ngClass]="'severity-' + alert.severity.toLowerCase()">
|
||||||
|
<div class="alert-header">
|
||||||
|
<div class="alert-type">
|
||||||
|
<span class="severity-badge" [ngClass]="'severity-' + alert.severity.toLowerCase()">
|
||||||
|
{{ alert.severity }}
|
||||||
|
</span>
|
||||||
|
<span class="alert-type-label">{{ alert.alertType.replace('_', ' ') }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="alert-time">{{ getRelativeTime(alert.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert-body">
|
||||||
|
<h4 class="alert-title">{{ alert.title }}</h4>
|
||||||
|
<p class="alert-description">{{ alert.description }}</p>
|
||||||
|
<div class="alert-meta">
|
||||||
|
<span *ngIf="alert.patientName" class="patient-name">
|
||||||
|
<strong>Patient:</strong> {{ alert.patientName }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="alert.medicationName" class="medication">
|
||||||
|
<strong>Medication:</strong> {{ alert.medicationName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-actions">
|
||||||
|
<button class="btn-acknowledge" (click)="acknowledgeClinicalAlert(alert.id)">
|
||||||
|
Acknowledge
|
||||||
|
</button>
|
||||||
|
<button class="btn-resolve" (click)="resolveClinicalAlert(alert.id)">
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critical Lab Results Section -->
|
||||||
|
<div class="alerts-section">
|
||||||
|
<h3>
|
||||||
|
Critical Lab Results
|
||||||
|
<span class="badge" *ngIf="unacknowledgedCriticalResults.length > 0">{{ unacknowledgedCriticalResults.length }}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div *ngIf="unacknowledgedCriticalResults.length === 0" class="no-alerts">
|
||||||
|
<p>No unacknowledged critical lab results</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngFor="let result of unacknowledgedCriticalResults" class="alert-card critical-result">
|
||||||
|
<div class="alert-header">
|
||||||
|
<div class="alert-type">
|
||||||
|
<span class="severity-badge severity-critical">
|
||||||
|
{{ result.criticalityLevel }}
|
||||||
|
</span>
|
||||||
|
<span class="alert-type-label">{{ result.testName }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="alert-time">{{ getRelativeTime(result.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert-body">
|
||||||
|
<h4 class="alert-title">Critical Lab Result</h4>
|
||||||
|
<div class="lab-result-details">
|
||||||
|
<div class="detail-row" *ngIf="result.resultValue">
|
||||||
|
<strong>Result Value:</strong> {{ result.resultValue }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="result.referenceRange">
|
||||||
|
<strong>Reference Range:</strong> {{ result.referenceRange }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="result.clinicalSignificance">
|
||||||
|
<strong>Clinical Significance:</strong> {{ result.clinicalSignificance }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="result.patientName">
|
||||||
|
<strong>Patient:</strong> {{ result.patientName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-actions">
|
||||||
|
<button class="btn-acknowledge" (click)="acknowledgeCriticalResult(result.id)">
|
||||||
|
Acknowledge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security/2FA Tab Panel -->
|
||||||
|
<div class="tab-panel" *ngIf="activeTab === 'security'">
|
||||||
|
<app-security
|
||||||
|
[twoFAEnabled]="twoFAEnabled"
|
||||||
|
[twoFAStatus]="twoFAStatus"
|
||||||
|
(setup2FA)="setup2FA()"
|
||||||
|
(disable2FA)="disable2FA($event)">
|
||||||
|
</app-security>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Call Component - Always visible for incoming/outgoing calls -->
|
||||||
|
<app-call></app-call>
|
||||||
|
|
||||||
|
<!-- Facebook-style Chat Widget -->
|
||||||
|
<!-- Always render chat component but hide it when closed -->
|
||||||
|
<div class="chat-widget-container" [class.hidden]="!showChatWidget">
|
||||||
|
<div class="chat-widget-header">
|
||||||
|
<div class="chat-widget-title" (click)="toggleChatWidget()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Messages</span>
|
||||||
|
<span class="chat-widget-badge" *ngIf="chatUnreadCount > 0">{{ chatUnreadCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Action Buttons -->
|
||||||
|
<div class="chat-widget-actions" (click)="$event.stopPropagation()">
|
||||||
|
<button class="chat-action-btn call-btn"
|
||||||
|
[class.disabled]="!chatComponent?.selectedConversation || !chatComponent?.selectedConversation?.isOnline || chatComponent?.selectedConversation?.otherUserStatus === 'OFFLINE'"
|
||||||
|
(click)="chatComponent?.startAudioCall()"
|
||||||
|
[title]="!chatComponent?.selectedConversation ? 'Select a conversation first' : (!chatComponent?.selectedConversation?.isOnline || chatComponent?.selectedConversation?.otherUserStatus === 'OFFLINE') ? 'User is offline' : 'Audio call'"
|
||||||
|
[disabled]="!chatComponent?.selectedConversation || !chatComponent?.selectedConversation?.isOnline || chatComponent?.selectedConversation?.otherUserStatus === 'OFFLINE'">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 16.92V19.92C22 20.6204 21.719 21.2922 21.219 21.7922C20.719 22.2922 20.0474 22.5734 19.347 22.5134C15.8482 22.2324 12.4895 21.0003 9.6 18.86C7.21358 17.0896 5.35036 14.7065 4.2 12C3.04964 9.29352 2.66265 6.44787 3.07 3.64C3.13826 2.94623 3.43566 2.29903 3.91368 1.80642C4.39171 1.31381 5.01885 1.00758 5.7 1H8.7C9.29674 0.994966 9.87908 1.16796 10.3813 1.49754C10.8835 1.82712 11.2839 2.29965 11.53 2.86L13.08 6.58C13.2833 7.06943 13.3459 7.60609 13.2612 8.12757C13.1765 8.64906 12.9484 9.13502 12.6 9.53L11 11.13C12.7613 13.3728 15.1272 15.2387 17.37 17L18.97 15.4C19.365 15.0516 19.851 14.8235 20.3724 14.7388C20.8939 14.6541 21.4306 14.7167 21.92 14.92L25.64 16.47C26.1928 16.7132 26.6677 17.1107 26.9989 17.6098C27.3301 18.1088 27.5035 18.6879 27.5 19.28L27.5 22.28C27.4913 22.9658 27.2056 23.6191 26.7058 24.1051C26.206 24.5912 25.5326 24.8734 24.83 24.89C22.8968 24.9587 20.9658 24.7498 19.1 24.27" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="chat-action-btn video-call-btn"
|
||||||
|
[class.disabled]="!chatComponent?.selectedConversation || !chatComponent?.selectedConversation?.isOnline || chatComponent?.selectedConversation?.otherUserStatus === 'OFFLINE'"
|
||||||
|
(click)="chatComponent?.startVideoCall()"
|
||||||
|
[title]="!chatComponent?.selectedConversation ? 'Select a conversation first' : (!chatComponent?.selectedConversation?.isOnline || chatComponent?.selectedConversation?.otherUserStatus === 'OFFLINE') ? 'User is offline' : 'Video call'"
|
||||||
|
[disabled]="!chatComponent?.selectedConversation || !chatComponent?.selectedConversation?.isOnline || chatComponent?.selectedConversation?.otherUserStatus === 'OFFLINE'">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M23 7L16 12L23 17V7Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 5H3C1.89543 5 1 5.89543 1 7V17C1 18.1046 1.89543 19 3 19H14C15.1046 19 16 18.1046 16 17V7C16 5.89543 15.1046 5 14 5Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="chat-action-btn delete-btn"
|
||||||
|
(click)="chatComponent?.deleteConversation()"
|
||||||
|
[class.disabled]="!chatComponent?.selectedConversation"
|
||||||
|
[title]="!chatComponent?.selectedConversation ? 'Select a conversation first' : 'Delete all messages'"
|
||||||
|
[disabled]="!chatComponent?.selectedConversation">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 6H5H21" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="chat-widget-close" (click)="toggleChatWidget()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-widget-content">
|
||||||
|
<app-chat></app-chat>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
3251
frontend/src/app/pages/doctor/doctor.component.scss
Normal file
3251
frontend/src/app/pages/doctor/doctor.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
2627
frontend/src/app/pages/doctor/doctor.component.ts
Normal file
2627
frontend/src/app/pages/doctor/doctor.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
<div class="login-wrapper">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<svg class="medical-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V16M8 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">Forgot Password</h1>
|
||||||
|
<p class="login-subtitle">Enter your email address and we'll send you a link to reset your password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="login-form" (ngSubmit)="submit()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">Email Address</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<svg class="input-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 6L12 13L2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
[(ngModel)]="email"
|
||||||
|
name="email"
|
||||||
|
class="form-input"
|
||||||
|
[class.input-error]="emailError"
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
(blur)="validateEmail()"
|
||||||
|
(input)="emailError = ''; error = null; success = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="field-error" *ngIf="emailError">{{ emailError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-message" *ngIf="error">
|
||||||
|
<svg class="error-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="success-message" *ngIf="success">
|
||||||
|
<svg class="success-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.7088 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ success }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="submit-button"
|
||||||
|
[disabled]="loading || emailError || !email"
|
||||||
|
[class.loading]="loading"
|
||||||
|
>
|
||||||
|
<span *ngIf="!loading">Send Reset Link</span>
|
||||||
|
<span *ngIf="loading" class="loading-content">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" stroke-opacity="0.25"/>
|
||||||
|
<path d="M12 2C16.4183 2 20 5.58172 20 10" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Sending...</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<a routerLink="/login" class="back-to-login-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19 12H5M12 19L5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Back to Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
// Enterprise-Grade Color Palette
|
||||||
|
$primary-blue: #0066cc;
|
||||||
|
$primary-blue-dark: #0052a3;
|
||||||
|
$primary-blue-light: #e6f2ff;
|
||||||
|
$primary-blue-lighter: #f0f7ff;
|
||||||
|
$accent-teal: #0099a1;
|
||||||
|
$accent-teal-light: #00c4ce;
|
||||||
|
$text-dark: #0f172a;
|
||||||
|
$text-medium: #475569;
|
||||||
|
$text-light: #64748b;
|
||||||
|
$text-lighter: #94a3b8;
|
||||||
|
$border-color: #e2e8f0;
|
||||||
|
$border-color-light: #f1f5f9;
|
||||||
|
$border-focus: #0066cc;
|
||||||
|
$error-red: #dc2626;
|
||||||
|
$error-red-dark: #b91c1c;
|
||||||
|
$error-bg: #fef2f2;
|
||||||
|
$error-border: #fecaca;
|
||||||
|
$success-green: #10b981;
|
||||||
|
$success-green-dark: #059669;
|
||||||
|
$success-bg: #d1fae5;
|
||||||
|
$warning-orange: #f59e0b;
|
||||||
|
$info-blue: #3b82f6;
|
||||||
|
$white: #ffffff;
|
||||||
|
$background-light: #f8fafc;
|
||||||
|
$background-elevated: #ffffff;
|
||||||
|
$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%);
|
||||||
|
$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%);
|
||||||
|
$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
|
||||||
|
// Enterprise Shadow System
|
||||||
|
$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25);
|
||||||
|
$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3);
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: $gradient-bg;
|
||||||
|
background-attachment: fixed;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: backgroundShift 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
right: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%);
|
||||||
|
animation: rotateBackground 30s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backgroundShift {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotateBackground {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: rgba($white, 0.98);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 3.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border-radius: 18px;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1);
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(12px);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px) scale(1.05);
|
||||||
|
box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.medical-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: $white;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .medical-icon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: $gradient-primary;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
letter-spacing: -0.75px;
|
||||||
|
font-family: $font-family;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
font-family: $font-family;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1.125rem;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: $text-light;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 3rem 1rem 3rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $background-elevated;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: $shadow-inner;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-lighter;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:focus) {
|
||||||
|
border-color: $border-color-light;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $border-focus;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
|
||||||
|
~ .input-icon {
|
||||||
|
color: $border-focus;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $background-light;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-color: $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.input-error {
|
||||||
|
border-color: $error-red;
|
||||||
|
background: $error-bg;
|
||||||
|
|
||||||
|
~ .input-icon {
|
||||||
|
color: $error-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md;
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.code-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: $primary-blue-lighter;
|
||||||
|
border-color: $primary-blue-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.625rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-light;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-dark;
|
||||||
|
background: $background-light;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
background: $primary-blue-lighter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: $error-red;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
animation: slideDown 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $text-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $primary-blue;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $primary-blue;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
font-family: $font-family;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, $primary-blue-lighter 0%, lighten($primary-blue-lighter, 2%) 100%);
|
||||||
|
border: 2px solid $primary-blue-light;
|
||||||
|
border-left: 4px solid $primary-blue;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: slideInInfo 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInInfo {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%);
|
||||||
|
border: 2px solid $error-border;
|
||||||
|
border-left: 4px solid $error-red;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $error-red-dark;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInError {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-6px); }
|
||||||
|
75% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.125rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $white;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
|
||||||
|
transition: left 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-3px) scale(1.01);
|
||||||
|
box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||||
|
background: $gradient-primary-hover;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(-1px) scale(0.99);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
background: $text-lighter;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: $text-light;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-family: $font-family;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $primary-blue;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 2px solid $primary-blue;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: $primary-blue-light;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-blue-light;
|
||||||
|
border-color: $primary-blue-dark;
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus visible for accessibility
|
||||||
|
.form-input:focus-visible,
|
||||||
|
.submit-button:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode support (optional, if needed)
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.login-container {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: rgba($white, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: rgba($white, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #404040;
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $border-focus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: rgba($white, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-login-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: $primary-blue;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-forgot-password',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, RouterModule],
|
||||||
|
templateUrl: './forgot-password.component.html',
|
||||||
|
styleUrl: './forgot-password.component.scss'
|
||||||
|
})
|
||||||
|
export class ForgotPasswordComponent {
|
||||||
|
email = '';
|
||||||
|
error: string | null = null;
|
||||||
|
success: string | null = null;
|
||||||
|
loading = false;
|
||||||
|
emailError = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private auth: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
validateEmail() {
|
||||||
|
this.emailError = '';
|
||||||
|
if (!this.email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(this.email)) {
|
||||||
|
this.emailError = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
this.error = null;
|
||||||
|
this.success = null;
|
||||||
|
this.emailError = '';
|
||||||
|
|
||||||
|
if (!this.email) {
|
||||||
|
this.emailError = 'Email is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateEmail();
|
||||||
|
if (this.emailError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const result = await this.auth.forgotPassword(this.email);
|
||||||
|
this.success = result.message || 'If an account with that email exists, a password reset link has been sent.';
|
||||||
|
this.email = '';
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to send password reset email. Please try again.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
188
frontend/src/app/pages/login/login.component.html
Normal file
188
frontend/src/app/pages/login/login.component.html
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<div class="login-wrapper">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<svg class="medical-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V16M8 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">Welcome Back</h1>
|
||||||
|
<p class="login-subtitle">Sign in to your account to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA Info Message -->
|
||||||
|
<div class="info-message" *ngIf="requires2FA && !code">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 16V12M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Please enter your 2FA code to complete login</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="login-form" (ngSubmit)="submit()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">Email Address</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<svg class="input-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 6L12 13L2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
[(ngModel)]="email"
|
||||||
|
name="email"
|
||||||
|
class="form-input"
|
||||||
|
[class.input-error]="emailError"
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
(blur)="validateEmail()"
|
||||||
|
(input)="emailError = ''; error = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="field-error" *ngIf="emailError">{{ emailError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" *ngIf="!requires2FA">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<svg class="input-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M7 11V7C7 4.239 9.239 2 12 2C14.761 2 17 4.239 17 7V11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
[type]="showPassword ? 'text' : 'password'"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
name="password"
|
||||||
|
class="form-input"
|
||||||
|
[class.input-error]="passwordError"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
(blur)="validatePassword()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="password-toggle"
|
||||||
|
(click)="togglePasswordVisibility()"
|
||||||
|
tabindex="-1"
|
||||||
|
[attr.aria-label]="showPassword ? 'Hide password' : 'Show password'"
|
||||||
|
>
|
||||||
|
<svg *ngIf="!showPassword" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="showPassword" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.94 17.94C16.2307 19.243 14.1491 20.4649 12 20.4649C5 20.4649 1 12 1 12C2.24389 9.68192 3.96914 7.65661 6.06 6.06M9.9 4.24C10.5883 4.07888 11.2931 3.99834 12 4C19 4 23 12 23 12C22.393 13.1356 21.6691 14.2047 20.84 15.19M14.12 14.12C13.8454 14.4147 13.5141 14.6511 13.1462 14.8151C12.7782 14.9791 12.3809 15.0673 11.9781 15.0744C11.5753 15.0815 11.1751 15.0073 10.8016 14.8565C10.4281 14.7056 10.0887 14.481 9.80385 14.1961C9.51897 13.9113 9.29436 13.5719 9.14354 13.1984C8.99272 12.8249 8.91853 12.4247 8.92563 12.0219C8.93274 11.6191 9.02091 11.2218 9.18488 10.8538C9.34886 10.4859 9.58525 10.1546 9.88 9.88M1 1L23 23" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="field-error" *ngIf="passwordError">{{ passwordError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" *ngIf="requires2FA">
|
||||||
|
<label for="code" class="form-label">
|
||||||
|
<svg class="label-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Two-Factor Authentication Code
|
||||||
|
</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<svg class="input-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M7 11V7C7 4.239 9.239 2 12 2C14.761 2 17 4.239 17 7V11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="code"
|
||||||
|
name="code"
|
||||||
|
class="form-input code-input"
|
||||||
|
placeholder="000000"
|
||||||
|
required
|
||||||
|
maxlength="6"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
autofocus
|
||||||
|
inputmode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 16V12M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</p>
|
||||||
|
<button type="button" class="link-button" (click)="reset2FA()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19 12H5M12 19L5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Back to login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-message" *ngIf="error">
|
||||||
|
<svg class="error-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-options" *ngIf="!requires2FA">
|
||||||
|
<label class="remember-me">
|
||||||
|
<input type="checkbox" [(ngModel)]="rememberMe" name="rememberMe" />
|
||||||
|
<span>Remember me</span>
|
||||||
|
</label>
|
||||||
|
<a routerLink="/forgot-password" class="forgot-password-link">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="submit-button"
|
||||||
|
[disabled]="loading || (!requires2FA && (emailError || passwordError)) || (requires2FA && !code)"
|
||||||
|
[class.loading]="loading"
|
||||||
|
>
|
||||||
|
<span *ngIf="!loading">
|
||||||
|
<span *ngIf="!requires2FA">Sign In</span>
|
||||||
|
<span *ngIf="requires2FA">Verify Code</span>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="loading" class="loading-content">
|
||||||
|
<svg class="spinner" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" stroke-opacity="0.25"/>
|
||||||
|
<path d="M12 2C16.4183 2 20 5.58172 20 10" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span *ngIf="!requires2FA">Signing in...</span>
|
||||||
|
<span *ngIf="requires2FA">Verifying...</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<p class="footer-text">Protected by enterprise-grade security</p>
|
||||||
|
<div class="registration-links">
|
||||||
|
<a routerLink="/register/doctor" class="register-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 14V22M8 18H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Register as Doctor
|
||||||
|
</a>
|
||||||
|
<a routerLink="/register/patient" class="register-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 11C11.2091 11 13 9.20914 13 7C13 4.79086 11.2091 3 9 3C6.79086 3 5 4.79086 5 7C5 9.20914 6.79086 11 9 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Register as Patient
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
813
frontend/src/app/pages/login/login.component.scss
Normal file
813
frontend/src/app/pages/login/login.component.scss
Normal file
@@ -0,0 +1,813 @@
|
|||||||
|
// Enterprise-Grade Color Palette
|
||||||
|
$primary-blue: #0066cc;
|
||||||
|
$primary-blue-dark: #0052a3;
|
||||||
|
$primary-blue-light: #e6f2ff;
|
||||||
|
$primary-blue-lighter: #f0f7ff;
|
||||||
|
$accent-teal: #0099a1;
|
||||||
|
$accent-teal-light: #00c4ce;
|
||||||
|
$text-dark: #0f172a;
|
||||||
|
$text-medium: #475569;
|
||||||
|
$text-light: #64748b;
|
||||||
|
$text-lighter: #94a3b8;
|
||||||
|
$border-color: #e2e8f0;
|
||||||
|
$border-color-light: #f1f5f9;
|
||||||
|
$border-focus: #0066cc;
|
||||||
|
$error-red: #dc2626;
|
||||||
|
$error-red-dark: #b91c1c;
|
||||||
|
$error-bg: #fef2f2;
|
||||||
|
$error-border: #fecaca;
|
||||||
|
$success-green: #10b981;
|
||||||
|
$success-green-dark: #059669;
|
||||||
|
$success-bg: #d1fae5;
|
||||||
|
$warning-orange: #f59e0b;
|
||||||
|
$info-blue: #3b82f6;
|
||||||
|
$white: #ffffff;
|
||||||
|
$background-light: #f8fafc;
|
||||||
|
$background-elevated: #ffffff;
|
||||||
|
$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%);
|
||||||
|
$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%);
|
||||||
|
$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
|
||||||
|
// Enterprise Shadow System
|
||||||
|
$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25);
|
||||||
|
$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3);
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: $gradient-bg;
|
||||||
|
background-attachment: fixed;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: backgroundShift 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
right: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%);
|
||||||
|
animation: rotateBackground 30s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backgroundShift {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotateBackground {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: rgba($white, 0.98);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 3.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border-radius: 18px;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1);
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(12px);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px) scale(1.05);
|
||||||
|
box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.medical-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: $white;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .medical-icon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: $gradient-primary;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
letter-spacing: -0.75px;
|
||||||
|
font-family: $font-family;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
font-family: $font-family;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1.125rem;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: $text-light;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 3rem 1rem 3rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $background-elevated;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: $shadow-inner;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-lighter;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:focus) {
|
||||||
|
border-color: $border-color-light;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $border-focus;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
|
||||||
|
~ .input-icon {
|
||||||
|
color: $border-focus;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $background-light;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-color: $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.input-error {
|
||||||
|
border-color: $error-red;
|
||||||
|
background: $error-bg;
|
||||||
|
|
||||||
|
~ .input-icon {
|
||||||
|
color: $error-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md;
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.code-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: $primary-blue-lighter;
|
||||||
|
border-color: $primary-blue-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.625rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-light;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-dark;
|
||||||
|
background: $background-light;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
background: $primary-blue-lighter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: $error-red;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
animation: slideDown 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: $text-medium;
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $text-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $primary-blue;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $primary-blue;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
font-family: $font-family;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, $primary-blue-lighter 0%, lighten($primary-blue-lighter, 2%) 100%);
|
||||||
|
border: 2px solid $primary-blue-light;
|
||||||
|
border-left: 4px solid $primary-blue;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: slideInInfo 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInInfo {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%);
|
||||||
|
border: 2px solid $error-border;
|
||||||
|
border-left: 4px solid $error-red;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $error-red-dark;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInError {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-6px); }
|
||||||
|
75% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.125rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $white;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
|
||||||
|
transition: left 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-3px) scale(1.01);
|
||||||
|
box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||||
|
background: $gradient-primary-hover;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(-1px) scale(0.99);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
background: $text-lighter;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: $text-light;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-family: $font-family;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $primary-blue;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 2px solid $primary-blue;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: $primary-blue-light;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-blue-light;
|
||||||
|
border-color: $primary-blue-dark;
|
||||||
|
color: $primary-blue-dark;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus visible for accessibility
|
||||||
|
.form-input:focus-visible,
|
||||||
|
.submit-button:focus-visible {
|
||||||
|
outline: 2px solid $border-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode support (optional, if needed)
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.login-container {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: rgba($white, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: rgba($white, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #404040;
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $border-focus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: rgba($white, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
23
frontend/src/app/pages/login/login.component.spec.ts
Normal file
23
frontend/src/app/pages/login/login.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoginComponent } from './login.component';
|
||||||
|
|
||||||
|
describe('LoginComponent', () => {
|
||||||
|
let component: LoginComponent;
|
||||||
|
let fixture: ComponentFixture<LoginComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LoginComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
188
frontend/src/app/pages/login/login.component.ts
Normal file
188
frontend/src/app/pages/login/login.component.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { UserService } from '../../services/user.service';
|
||||||
|
import { LoggerService } from '../../services/logger.service';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, RouterModule],
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrl: './login.component.scss'
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
code = ''; // 2FA code
|
||||||
|
error: string | null = null;
|
||||||
|
loading = false;
|
||||||
|
requires2FA = false;
|
||||||
|
showPassword = false;
|
||||||
|
rememberMe = false;
|
||||||
|
emailError = '';
|
||||||
|
passwordError = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private auth: AuthService,
|
||||||
|
private userService: UserService,
|
||||||
|
private router: Router,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
private logger: LoggerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
validateEmail() {
|
||||||
|
this.emailError = '';
|
||||||
|
if (!this.email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(this.email)) {
|
||||||
|
this.emailError = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePassword() {
|
||||||
|
this.passwordError = '';
|
||||||
|
if (!this.password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.password.length < 3) {
|
||||||
|
this.passwordError = 'Password is too short';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
this.error = null;
|
||||||
|
this.emailError = '';
|
||||||
|
this.passwordError = '';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!this.email) {
|
||||||
|
this.emailError = 'Email is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateEmail();
|
||||||
|
if (this.emailError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.requires2FA && !this.password) {
|
||||||
|
this.passwordError = 'Password is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.requires2FA) {
|
||||||
|
this.validatePassword();
|
||||||
|
if (this.passwordError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.requires2FA && !this.code) {
|
||||||
|
this.error = 'Please enter your 2FA code';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const result = await this.auth.login(this.email, this.password, this.code || undefined);
|
||||||
|
|
||||||
|
if (result.requires2FA) {
|
||||||
|
this.logger.debug('Result indicates 2FA required, setting requires2FA to true');
|
||||||
|
this.requires2FA = true;
|
||||||
|
this.error = null;
|
||||||
|
// Clear the code when switching to 2FA mode
|
||||||
|
this.code = '';
|
||||||
|
// Force change detection to update the view
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
// Use requestAnimationFrame to ensure DOM is updated
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const codeInput = document.getElementById('code');
|
||||||
|
if (codeInput) {
|
||||||
|
codeInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (result.token) {
|
||||||
|
// Get user info to determine role-based redirect
|
||||||
|
const user = await this.userService.getCurrentUser();
|
||||||
|
if (user) {
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
this.router.navigateByUrl('/admin');
|
||||||
|
} else if (user.role === 'DOCTOR') {
|
||||||
|
this.router.navigateByUrl('/doctor');
|
||||||
|
} else if (user.role === 'PATIENT') {
|
||||||
|
this.router.navigateByUrl('/patient');
|
||||||
|
} else {
|
||||||
|
this.router.navigateByUrl('/admin');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.router.navigateByUrl('/admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const errorMessage = (e?.response?.data?.message || e?.response?.data?.error || '').toLowerCase();
|
||||||
|
const statusCode = e?.response?.status;
|
||||||
|
|
||||||
|
this.logger.debug('Login component error:', {
|
||||||
|
status: statusCode,
|
||||||
|
message: e?.response?.data?.message || e?.response?.data?.error,
|
||||||
|
requires2FA: this.requires2FA,
|
||||||
|
fullError: e?.response?.data
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we're in 2FA mode and got an error
|
||||||
|
if (this.requires2FA) {
|
||||||
|
// If it's an invalid 2FA code error, keep requires2FA true
|
||||||
|
if (errorMessage.includes('invalid 2fa') || errorMessage.includes('2fa code')) {
|
||||||
|
this.error = 'Invalid 2FA code. Please try again.';
|
||||||
|
this.code = ''; // Clear the code input
|
||||||
|
// Keep requires2FA = true
|
||||||
|
} else {
|
||||||
|
// Some other error - might be wrong credentials, reset 2FA
|
||||||
|
this.error = e?.response?.data?.message || e?.response?.data?.error || 'Login failed. Please try again.';
|
||||||
|
this.requires2FA = false;
|
||||||
|
this.code = '';
|
||||||
|
this.password = ''; // Clear password on error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not in 2FA mode - check if 2FA is required
|
||||||
|
// Check for "2FA code required" message or 400 status with 2FA in message
|
||||||
|
const is2FARequired = errorMessage.includes('2fa code required') ||
|
||||||
|
(statusCode === 400 && errorMessage.includes('2fa') && !errorMessage.includes('invalid'));
|
||||||
|
|
||||||
|
if (is2FARequired) {
|
||||||
|
this.logger.debug('Setting requires2FA to true');
|
||||||
|
this.requires2FA = true;
|
||||||
|
this.error = null;
|
||||||
|
// Don't clear password - user needs it for the next step
|
||||||
|
// Force change detection to update the view
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
// Use requestAnimationFrame to ensure DOM is updated
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const codeInput = document.getElementById('code');
|
||||||
|
if (codeInput) {
|
||||||
|
codeInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regular login error
|
||||||
|
this.error = e?.response?.data?.message || e?.response?.data?.error || 'Login failed. Please check your credentials.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePasswordVisibility() {
|
||||||
|
this.showPassword = !this.showPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset2FA() {
|
||||||
|
this.requires2FA = false;
|
||||||
|
this.code = '';
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<section class="appointments-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">My Appointments</h2>
|
||||||
|
<p class="info-text">
|
||||||
|
Slots auto-confirm when available. Otherwise, your care team approves the request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="request-btn" (click)="onToggleRequestForm(true)" *ngIf="!showRequestForm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Request Appointment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request-form-container" *ngIf="showRequestForm">
|
||||||
|
<div class="create-form-container">
|
||||||
|
<div class="form-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Request Appointment</h3>
|
||||||
|
<p>Select a doctor, choose a time, and we'll handle the rest.</p>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" (click)="onToggleRequestForm(false)" title="Close form">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form class="create-form" (ngSubmit)="onSubmitRequest()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="doctorId" class="form-label">Select Doctor <span class="required-indicator">*</span></label>
|
||||||
|
<select
|
||||||
|
id="doctorId"
|
||||||
|
[(ngModel)]="newAppointment.doctorId"
|
||||||
|
name="doctorId"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
(change)="onDoctorChange()"
|
||||||
|
>
|
||||||
|
<option value="">Choose a doctor...</option>
|
||||||
|
<option *ngFor="let doctor of doctors" [value]="doctor.id">
|
||||||
|
Dr. {{ doctor.firstName }} {{ doctor.lastName }} - {{ doctor.specialization }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduledDate" class="form-label">Select Date <span class="required-indicator">*</span></label>
|
||||||
|
<input
|
||||||
|
id="scheduledDate"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="newAppointment.scheduledDate"
|
||||||
|
name="scheduledDate"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
[min]="minDate"
|
||||||
|
(change)="onDateChange()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" *ngIf="newAppointment.doctorId && newAppointment.scheduledDate">
|
||||||
|
<label class="form-label">Available Time Slots</label>
|
||||||
|
<div *ngIf="loadingSlots" class="loading-slots">
|
||||||
|
<span>Loading enterprise availability...</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!loadingSlots && availableSlots.length === 0" class="no-slots">
|
||||||
|
<p>No available slots for this date. Please select a different date.</p>
|
||||||
|
</div>
|
||||||
|
<div class="time-slots-grid" *ngIf="!loadingSlots && availableSlots.length > 0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="time-slot-btn"
|
||||||
|
*ngFor="let slot of availableSlots"
|
||||||
|
[class.selected]="newAppointment.scheduledTime === slot"
|
||||||
|
(click)="onSelectSlot(slot)"
|
||||||
|
>
|
||||||
|
{{ slot }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" [(ngModel)]="newAppointment.scheduledTime" name="scheduledTime" required />
|
||||||
|
<div *ngIf="newAppointment.scheduledTime" class="selected-time-info">
|
||||||
|
<strong>Selected Time:</strong> {{ newAppointment.scheduledTime }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-btn" (click)="onToggleRequestForm(false)">Cancel</button>
|
||||||
|
<button type="submit" class="submit-button">Request Appointment</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="appointment-group" *ngIf="upcomingAppointments.length > 0">
|
||||||
|
<h3 class="group-title">Upcoming</h3>
|
||||||
|
<div class="appointments-grid">
|
||||||
|
<div class="appointment-card" *ngFor="let apt of upcomingAppointments">
|
||||||
|
<div class="appointment-header">
|
||||||
|
<div class="appointment-info">
|
||||||
|
<h4 class="doctor-name">Dr. {{ apt.doctorFirstName }} {{ apt.doctorLastName }}</h4>
|
||||||
|
<p class="appointment-date">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ formatDateFn(apt.scheduledDate) }} at {{ formatTimeFn(apt.scheduledTime) }}
|
||||||
|
</p>
|
||||||
|
<p class="appointment-duration">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ apt.durationInMinutes }} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" [ngClass]="statusClass(apt.status)">
|
||||||
|
{{ apt.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="appointment-actions" *ngIf="apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED'">
|
||||||
|
<button class="action-btn action-btn-cancel" (click)="onCancelAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-delete" (click)="onDeleteAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="appointment-warning" *ngIf="!apt.id && (apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 9V13M12 17H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Action unavailable: Appointment ID missing. Contact support for assistance.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="appointment-group" *ngIf="pastAppointments.length > 0">
|
||||||
|
<h3 class="group-title">Past</h3>
|
||||||
|
<div class="appointments-grid">
|
||||||
|
<div class="appointment-card" *ngFor="let apt of pastAppointments">
|
||||||
|
<div class="appointment-header">
|
||||||
|
<div class="appointment-info">
|
||||||
|
<h4 class="doctor-name">Dr. {{ apt.doctorFirstName }} {{ apt.doctorLastName }}</h4>
|
||||||
|
<p class="appointment-date">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ formatDateFn(apt.scheduledDate) }} at {{ formatTimeFn(apt.scheduledTime) }}
|
||||||
|
</p>
|
||||||
|
<p class="appointment-duration">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
{{ apt.durationInMinutes }} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" [ngClass]="statusClass(apt.status)">
|
||||||
|
{{ apt.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="appointment-actions">
|
||||||
|
<button class="action-btn action-btn-delete" (click)="onDeleteAppointment(apt)" [disabled]="!apt.id">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" *ngIf="appointments.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Appointments</h3>
|
||||||
|
<p>You don't have any appointments scheduled yet.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
$surface: #f7f9fc;
|
||||||
|
$surface-card: #ffffff;
|
||||||
|
$surface-accent: #eef2ff;
|
||||||
|
$border: #d7deea;
|
||||||
|
$primary: #1e4dd8;
|
||||||
|
$primary-strong: #1239a2;
|
||||||
|
$danger: #da1b4d;
|
||||||
|
$text: #0f172a;
|
||||||
|
$muted: #5c6474;
|
||||||
|
$success: #0f8b5f;
|
||||||
|
|
||||||
|
@mixin elevated-card {
|
||||||
|
background: $surface-card;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 45px rgba(15, 23, 42, 0.08),
|
||||||
|
0 2px 6px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin focus-ring {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(30, 77, 216, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointments-section {
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 1400px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(180deg, #fdfdff 0%, #f1f4fb 100%);
|
||||||
|
border-radius: 32px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.04);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 80px rgba(15, 23, 42, 0.12),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 1.5rem;
|
||||||
|
border-radius: 26px;
|
||||||
|
border: 1px solid rgba(30, 77, 216, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $text;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: none;
|
||||||
|
padding: 0.85rem 1.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(120deg, $primary, $primary-strong);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: capitalize;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 18px 35px rgba(30, 77, 216, 0.3);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 25px 45px rgba(18, 57, 162, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-form-container {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-container {
|
||||||
|
@include elevated-card;
|
||||||
|
padding: 1.75rem;
|
||||||
|
background: linear-gradient(160deg, rgba(30, 77, 216, 0.08), rgba(255, 255, 255, 0)) $surface-card;
|
||||||
|
border: 1px solid rgba(30, 77, 216, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: rgba(15, 23, 42, 0.07);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-indicator {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.85rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: $surface-card;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $primary;
|
||||||
|
@include focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-slots,
|
||||||
|
.no-slots,
|
||||||
|
.selected-time-info {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(30, 77, 216, 0.07);
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid rgba(30, 77, 216, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slots-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot-btn {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
background: $surface-card;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: $primary;
|
||||||
|
background: linear-gradient(120deg, rgba(30, 77, 216, 0.12), rgba(255, 255, 255, 0));
|
||||||
|
color: $primary;
|
||||||
|
box-shadow: 0 10px 18px rgba(30, 77, 216, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.selected) {
|
||||||
|
border-color: rgba(30, 77, 216, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn,
|
||||||
|
.submit-button {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
background: linear-gradient(120deg, $primary, $primary-strong);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 20px 30px rgba(30, 77, 216, 0.3);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 25px 40px rgba(18, 57, 162, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focus-ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointments-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-card {
|
||||||
|
@include elevated-card;
|
||||||
|
padding: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: linear-gradient(120deg, rgba(30, 77, 216, 0.15), rgba(255, 255, 255, 0)) border-box;
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) padding-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: destination-out;
|
||||||
|
mask:
|
||||||
|
linear-gradient(#fff 0 0) padding-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-date,
|
||||||
|
.appointment-duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&.status-completed {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
border-color: rgba(16, 185, 129, 0.25);
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
&.status-confirmed {
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
border-color: rgba(37, 99, 235, 0.25);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
&.status-cancelled {
|
||||||
|
background: rgba(234, 72, 72, 0.12);
|
||||||
|
border-color: rgba(234, 72, 72, 0.25);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
&.status-scheduled {
|
||||||
|
background: rgba(234, 179, 8, 0.14);
|
||||||
|
border-color: rgba(234, 179, 8, 0.3);
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
background: $surface-card;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focus-ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-cancel {
|
||||||
|
color: #b45309;
|
||||||
|
border-color: rgba(234, 179, 8, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-delete {
|
||||||
|
color: $danger;
|
||||||
|
border-color: rgba(218, 27, 77, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 2px dashed rgba(148, 163, 184, 0.5);
|
||||||
|
color: $muted;
|
||||||
|
background: $surface-accent;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: rgba(148, 163, 184, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Appointment } from '../../../../services/appointment.service';
|
||||||
|
|
||||||
|
interface AppointmentDraft {
|
||||||
|
doctorId: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
scheduledTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-appointments-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './patient-appointments-panel.component.html',
|
||||||
|
styleUrls: ['./patient-appointments-panel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientAppointmentsPanelComponent {
|
||||||
|
@Input() appointments: Appointment[] = [];
|
||||||
|
@Input() doctors: any[] = [];
|
||||||
|
@Input() newAppointment: AppointmentDraft = { doctorId: '', scheduledDate: '', scheduledTime: '' };
|
||||||
|
@Input() showRequestForm = false;
|
||||||
|
@Input() availableSlots: string[] = [];
|
||||||
|
@Input() loadingSlots = false;
|
||||||
|
@Input() selectedDate = '';
|
||||||
|
@Input() upcomingAppointments: Appointment[] = [];
|
||||||
|
@Input() pastAppointments: Appointment[] = [];
|
||||||
|
@Input() minDate = '';
|
||||||
|
@Input() formatDateFn: (value: string) => string = (value: string) => value;
|
||||||
|
@Input() formatTimeFn: (value: string) => string = (value: string) => value;
|
||||||
|
|
||||||
|
@Output() toggleRequestForm = new EventEmitter<boolean>();
|
||||||
|
@Output() requestAppointment = new EventEmitter<void>();
|
||||||
|
@Output() doctorChange = new EventEmitter<void>();
|
||||||
|
@Output() dateChange = new EventEmitter<void>();
|
||||||
|
@Output() selectSlot = new EventEmitter<string>();
|
||||||
|
@Output() cancelAppointment = new EventEmitter<Appointment>();
|
||||||
|
@Output() deleteAppointment = new EventEmitter<Appointment>();
|
||||||
|
|
||||||
|
onToggleRequestForm(open?: boolean): void {
|
||||||
|
this.toggleRequestForm.emit(open ?? !this.showRequestForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmitRequest(): void {
|
||||||
|
this.requestAppointment.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDoctorChange(): void {
|
||||||
|
this.doctorChange.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateChange(): void {
|
||||||
|
this.dateChange.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectSlot(slot: string): void {
|
||||||
|
this.selectSlot.emit(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancelAppointment(appointment: Appointment): void {
|
||||||
|
this.cancelAppointment.emit(appointment);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteAppointment(appointment: Appointment): void {
|
||||||
|
this.deleteAppointment.emit(appointment);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const statusLower = status?.toLowerCase() || '';
|
||||||
|
if (statusLower === 'completed') return 'status-completed';
|
||||||
|
if (statusLower === 'confirmed') return 'status-confirmed';
|
||||||
|
if (statusLower === 'cancelled') return 'status-cancelled';
|
||||||
|
return 'status-scheduled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<header class="dashboard-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo-section">
|
||||||
|
<svg class="medical-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V16M8 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1 class="dashboard-title">Patient Dashboard</h1>
|
||||||
|
<p class="dashboard-subtitle">
|
||||||
|
Welcome, {{ currentUser?.firstName }} {{ currentUser?.lastName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="notification-container">
|
||||||
|
<button
|
||||||
|
class="notification-button"
|
||||||
|
(click)="onToggleNotifications()"
|
||||||
|
[class.has-notifications]="notificationCount > 0"
|
||||||
|
title="Notifications"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8C6 9.65735 5.32843 11.2336 4.17157 12.4142C3.01472 13.5949 2 14.7712 2 16V17H22V16C22 14.7712 21.0147 13.5949 19.8579 12.4142C18.7011 11.2336 18 9.65735 18 8Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 21H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 3C10 2.44772 10.4477 2 11 2H13C13.5523 2 14 2.44772 14 3V4H10V3Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="notification-badge" *ngIf="notificationCount > 0">
|
||||||
|
{{ notificationCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="notification-dropdown" *ngIf="showNotifications">
|
||||||
|
<div class="notification-header">
|
||||||
|
<div>
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<p class="subtitle">Message and missed-call alerts curated for you</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="mark-all-read"
|
||||||
|
*ngIf="notificationCount > 0"
|
||||||
|
(click)="onMarkAllRead($event)"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="delete-all"
|
||||||
|
*ngIf="notifications.length > 0"
|
||||||
|
(click)="onDeleteAll($event)"
|
||||||
|
title="Delete all notifications"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification-list">
|
||||||
|
<div *ngIf="notifications.length === 0" class="no-notifications">
|
||||||
|
<p>No notifications</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
*ngFor="let notification of notifications"
|
||||||
|
class="notification-item"
|
||||||
|
[class.unread]="!notification.read"
|
||||||
|
(click)="onNotificationClick(notification)"
|
||||||
|
>
|
||||||
|
<div class="notification-icon">
|
||||||
|
<svg *ngIf="notification.type === 'message'" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<svg *ngIf="notification.type === 'missed-call'" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 16.92V19.92C22 20.6204 21.719 21.2922 21.219 21.7922C20.719 22.2922 20.0474 22.5734 19.347 22.5134C15.8482 22.2324 12.4895 21.0003 9.6 18.86C7.21358 17.0896 5.35036 14.7065 4.2 12C3.04964 9.29352 2.66265 6.44787 3.07 3.64C3.13826 2.94623 3.43566 2.29903 3.91368 1.80642C4.39171 1.31381 5.01885 1.00758 5.7 1H8.7C9.29674 0.994966 9.87908 1.16796 10.3813 1.49754C10.8835 1.82712 11.2839 2.29965 11.53 2.86L13.08 6.58C13.2833 7.06943 13.3459 7.60609 13.2612 8.12757C13.1765 8.64906 12.9484 9.13502 12.6 9.53L11 11.13C12.7613 13.3728 15.1272 15.2387 17.37 17L18.97 15.4C19.365 15.0516 19.851 14.8235 20.3724 14.7388C20.8939 14.6541 21.4306 14.7167 21.92 14.92L25.64 16.47C26.1928 16.7132 26.6677 17.1107 26.9989 17.6098C27.3301 18.1088 27.5035 18.6879 27.5 19.28L27.5 22.28C27.4913 22.9658 27.2056 23.6191 26.7058 24.1051C26.206 24.5912 25.5326 24.8734 24.83 24.89C22.8968 24.9587 20.9658 24.7498 19.1 24.27" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 2L22 22M22 2L2 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="notification-title">{{ notification.title }}</div>
|
||||||
|
<div class="notification-message">{{ notification.message }}</div>
|
||||||
|
<div class="notification-time">{{ getNotificationTime(notification.timestamp) }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="notification-delete"
|
||||||
|
(click)="onDeleteNotification(notification.id, $event)"
|
||||||
|
title="Delete notification"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Controls -->
|
||||||
|
<div class="chat-menu-container">
|
||||||
|
<button
|
||||||
|
class="chat-menu-button"
|
||||||
|
(click)="onToggleChatMenu()"
|
||||||
|
[class.active]="showChatMenu"
|
||||||
|
[class.has-unread]="chatUnreadCount > 0"
|
||||||
|
title="Messages"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="chat-menu-badge" *ngIf="chatUnreadCount > 0">{{ chatUnreadCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Blocked Users Panel inside chat menu -->
|
||||||
|
<div
|
||||||
|
class="blocked-users-panel-dropdown"
|
||||||
|
*ngIf="showBlockedUsers && showChatMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<div class="blocked-users-header-dropdown">
|
||||||
|
<h3>Blocked Users</h3>
|
||||||
|
<button class="close-blocked-users" (click)="onToggleBlockedUsers($event)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blocked-users-content-dropdown">
|
||||||
|
<div class="blocked-loading" *ngIf="isLoadingBlockedUsers">
|
||||||
|
<p>Loading blocked users...</p>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-users-list-dropdown" *ngIf="!isLoadingBlockedUsers">
|
||||||
|
<div
|
||||||
|
*ngFor="let user of blockedUsers"
|
||||||
|
class="blocked-user-item-dropdown"
|
||||||
|
>
|
||||||
|
<div class="blocked-user-avatar-dropdown">
|
||||||
|
<img
|
||||||
|
*ngIf="user.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(user.avatarUrl)"
|
||||||
|
[alt]="user.firstName + ' ' + user.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)"
|
||||||
|
/>
|
||||||
|
<div class="avatar-circle-dropdown" [style.display]="user.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-user-info-dropdown">
|
||||||
|
<div class="blocked-user-name-dropdown">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="blocked-user-details-dropdown">
|
||||||
|
<span *ngIf="user.specialization">{{ user.specialization }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="unblock-button-dropdown"
|
||||||
|
(click)="onUnblockUser(user, $event)"
|
||||||
|
title="Unblock user"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" pointer-events="none">
|
||||||
|
<path d="M18 6L6 18"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Unblock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="no-blocked-users-dropdown" *ngIf="blockedUsers.length === 0 && !isLoadingBlockedUsers">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<p>No blocked users</p>
|
||||||
|
<span>You haven't blocked anyone yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Menu -->
|
||||||
|
<div
|
||||||
|
class="chat-menu-dropdown"
|
||||||
|
*ngIf="showChatMenu && !showBlockedUsers"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<div class="chat-menu-header">
|
||||||
|
<h3>Messages</h3>
|
||||||
|
<div class="chat-menu-header-actions">
|
||||||
|
<button class="chat-menu-blocked-users" (click)="onToggleBlockedUsers($event)" title="Blocked Users">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
<circle cx="18" cy="8" r="2.5"></circle>
|
||||||
|
<line x1="16.5" y1="6.5" x2="19.5" y2="9.5"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="chat-menu-close" (click)="onToggleChatMenu()" title="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-menu-search">
|
||||||
|
<div class="search-input-container">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search for people..."
|
||||||
|
[value]="chatSearchQuery"
|
||||||
|
(input)="onChatSearch($event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
*ngIf="chatSearchQuery"
|
||||||
|
class="search-clear"
|
||||||
|
(click)="onClearChatSearch($event)"
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conversation-list-dropdown">
|
||||||
|
<div *ngIf="chatSearchQuery && chatSearchResults.length > 0" class="search-results-section">
|
||||||
|
<div class="search-results-header">Search Results ({{ chatSearchResults.length }})</div>
|
||||||
|
<div
|
||||||
|
*ngFor="let user of chatSearchResults"
|
||||||
|
class="conversation-item-dropdown"
|
||||||
|
(click)="onOpenChatWithUser(user.userId)"
|
||||||
|
>
|
||||||
|
<div class="conversation-avatar-dropdown">
|
||||||
|
<img
|
||||||
|
*ngIf="user.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(user.avatarUrl)"
|
||||||
|
[alt]="user.firstName + ' ' + user.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)"
|
||||||
|
/>
|
||||||
|
<div class="avatar-circle-dropdown" [style.display]="user.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="online-indicator-dropdown"
|
||||||
|
[class.online]="user.isOnline && user.status !== 'BUSY' && user.status !== 'OFFLINE'"
|
||||||
|
[class.offline]="!user.isOnline || user.status === 'OFFLINE'"
|
||||||
|
[class.busy]="user.status === 'BUSY'"
|
||||||
|
[title]="getStatusText(user.status)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-info-dropdown">
|
||||||
|
<div class="conversation-name-dropdown">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="conversation-preview-dropdown" *ngIf="user.specialization">{{ user.specialization }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conversations-section">
|
||||||
|
<div
|
||||||
|
class="conversations-header"
|
||||||
|
*ngIf="chatSearchQuery && conversations && conversations.length > 0"
|
||||||
|
>
|
||||||
|
Conversations ({{ filteredConversations.length }})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="(!chatSearchQuery && (!conversations || conversations.length === 0)) || (chatSearchQuery && filteredConversations.length === 0)"
|
||||||
|
class="no-conversations"
|
||||||
|
>
|
||||||
|
<p>{{ chatSearchQuery ? 'No conversations found' : 'No conversations yet' }}</p>
|
||||||
|
<p class="subtext">
|
||||||
|
{{ chatSearchQuery ? 'Try a different search term' : 'Start a conversation to begin messaging' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="chatSearchQuery && chatSearchResults.length === 0 && filteredConversations.length === 0 && chatSearchQuery.length >= 2"
|
||||||
|
class="no-conversations"
|
||||||
|
>
|
||||||
|
<p>No users found</p>
|
||||||
|
<p class="subtext">Try searching with a different name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
*ngFor="let conversation of (chatSearchQuery ? filteredConversations : conversations)"
|
||||||
|
class="conversation-item-dropdown"
|
||||||
|
[class.active]="activeConversationId === conversation.otherUserId"
|
||||||
|
(click)="onOpenChatConversation(conversation.otherUserId)"
|
||||||
|
>
|
||||||
|
<div class="conversation-avatar-dropdown">
|
||||||
|
<img
|
||||||
|
*ngIf="conversation.otherUserAvatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(conversation.otherUserAvatarUrl)"
|
||||||
|
[alt]="conversation.otherUserName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)"
|
||||||
|
/>
|
||||||
|
<div class="avatar-circle-dropdown" [style.display]="conversation.otherUserAvatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ conversation.otherUserName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="online-indicator-dropdown"
|
||||||
|
[class.online]="conversation.isOnline && conversation.otherUserStatus !== 'BUSY' && conversation.otherUserStatus !== 'OFFLINE'"
|
||||||
|
[class.offline]="!conversation.isOnline || conversation.otherUserStatus === 'OFFLINE'"
|
||||||
|
[class.busy]="conversation.otherUserStatus === 'BUSY'"
|
||||||
|
[title]="getStatusText(conversation.otherUserStatus)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-info-dropdown">
|
||||||
|
<div class="conversation-name-dropdown">{{ conversation.otherUserName }}</div>
|
||||||
|
<div class="conversation-preview-dropdown">
|
||||||
|
{{ conversation.lastMessage?.content || 'No messages yet' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-meta-dropdown">
|
||||||
|
<div class="conversation-time-dropdown" *ngIf="conversation.lastMessage?.createdAt">
|
||||||
|
{{ getConversationTime(conversation.lastMessage!.createdAt) }}
|
||||||
|
</div>
|
||||||
|
<div class="conversation-unread-dropdown" *ngIf="conversation.unreadCount > 0">
|
||||||
|
{{ conversation.unreadCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocked Users Button -->
|
||||||
|
<div class="blocked-users-container">
|
||||||
|
<button
|
||||||
|
class="blocked-users-button-header"
|
||||||
|
(click)="onToggleBlockedUsers($event)"
|
||||||
|
[class.active]="showBlockedUsers && !showChatMenu"
|
||||||
|
title="Blocked Users"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
<circle cx="18" cy="8" r="2.5"></circle>
|
||||||
|
<line x1="16.5" y1="6.5" x2="19.5" y2="9.5"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="blocked-users-panel-dropdown"
|
||||||
|
*ngIf="showBlockedUsers && !showChatMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<div class="blocked-users-header-dropdown">
|
||||||
|
<h3>Blocked Users</h3>
|
||||||
|
<button class="close-blocked-users" (click)="onToggleBlockedUsers($event)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blocked-users-content-dropdown">
|
||||||
|
<div class="blocked-loading" *ngIf="isLoadingBlockedUsers">
|
||||||
|
<p>Loading blocked users...</p>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-users-list-dropdown" *ngIf="!isLoadingBlockedUsers">
|
||||||
|
<div
|
||||||
|
*ngFor="let user of blockedUsers"
|
||||||
|
class="blocked-user-item-dropdown"
|
||||||
|
>
|
||||||
|
<div class="blocked-user-avatar-dropdown">
|
||||||
|
<img
|
||||||
|
*ngIf="user.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(user.avatarUrl)"
|
||||||
|
[alt]="user.firstName + ' ' + user.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
(error)="onChatImageError($event)"
|
||||||
|
/>
|
||||||
|
<div class="avatar-circle-dropdown" [style.display]="user.avatarUrl ? 'none' : 'flex'">
|
||||||
|
{{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="blocked-user-info-dropdown">
|
||||||
|
<div class="blocked-user-name-dropdown">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="blocked-user-details-dropdown">
|
||||||
|
<span *ngIf="user.specialization">{{ user.specialization }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="unblock-button-dropdown"
|
||||||
|
(click)="onUnblockUser(user, $event)"
|
||||||
|
title="Unblock user"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" pointer-events="none">
|
||||||
|
<path d="M18 6L6 18"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Unblock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="no-blocked-users-dropdown" *ngIf="blockedUsers.length === 0 && !isLoadingBlockedUsers">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<p>No blocked users</p>
|
||||||
|
<span>You haven't blocked anyone yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="refresh-button" (click)="onRefresh()" [disabled]="loading" title="Refresh">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 4V10H7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M23 20V14H17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14L18.36 18.36A9 9 0 0 1 3.51 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button class="logout-button" (click)="onLogout()" title="Logout">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 17L21 12L16 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 12H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
@@ -0,0 +1,862 @@
|
|||||||
|
$primary-blue: #1b64f2;
|
||||||
|
$primary-blue-dark: #154ecc;
|
||||||
|
$text-dark: #0f172a;
|
||||||
|
$text-muted: #65738b;
|
||||||
|
$border-color: #e2e8f0;
|
||||||
|
$surface: #ffffff;
|
||||||
|
$danger: #e53935;
|
||||||
|
$bg-highlight: #f1f5ff;
|
||||||
|
$shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
background: linear-gradient(135deg, #f4f7fb 0%, #ffffff 60%);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
box-shadow: $shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.medical-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-button {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-dark;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-blue;
|
||||||
|
color: $primary-blue;
|
||||||
|
box-shadow: 0 10px 20px rgba(27, 100, 242, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-notifications {
|
||||||
|
border-color: $primary-blue;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $danger;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 12px);
|
||||||
|
width: 360px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
box-shadow: 0 25px 60px rgba(15, 23, 42, 0.12);
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
width: 300px;
|
||||||
|
right: -40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: $primary-blue;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-all {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $danger;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-blue;
|
||||||
|
background: $bg-highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unread {
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 10px 30px rgba(27, 100, 242, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(27, 100, 242, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $primary-blue;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(15, 23, 42, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-delete {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(226, 232, 240, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $danger;
|
||||||
|
color: $danger;
|
||||||
|
background: rgba(229, 57, 53, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-notifications {
|
||||||
|
text-align: center;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button,
|
||||||
|
.logout-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-dark;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $primary-blue;
|
||||||
|
color: $primary-blue;
|
||||||
|
box-shadow: 0 10px 20px rgba(27, 100, 242, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
border-color: rgba(229, 57, 53, 0.4);
|
||||||
|
color: $danger;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 10px 20px rgba(229, 57, 53, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Chat Menu + Blocked Users Styles
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.chat-menu-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-sm, 0 10px 20px rgba(15, 23, 42, 0.08));
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-blue;
|
||||||
|
color: $primary-blue;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-unread {
|
||||||
|
animation: pulse-chat 2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-chat {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 12px);
|
||||||
|
right: 0;
|
||||||
|
width: 380px;
|
||||||
|
max-height: 520px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 40px 80px rgba(15, 23, 42, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideDown 0.2s ease-out;
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-close,
|
||||||
|
.chat-menu-blocked-users {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
color: $text-muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.1);
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-blocked-users {
|
||||||
|
&:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-menu-search {
|
||||||
|
padding: 1rem 1.5rem 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f7f8fc;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
color: $text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list-dropdown {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-section {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(37, 99, 235, 0.04);
|
||||||
|
|
||||||
|
.search-results-header {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-color: rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-primary, #2563eb);
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-avatar-dropdown {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.avatar-image,
|
||||||
|
.avatar-circle-dropdown {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle-dropdown {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary, #2563eb), var(--color-accent, #8b5cf6));
|
||||||
|
color: #fff;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-indicator-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
|
||||||
|
&.online {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
&.offline {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
&.busy {
|
||||||
|
background: #f97316;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-info-dropdown {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.conversation-name-dropdown {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-preview-dropdown {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-meta-dropdown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $text-muted;
|
||||||
|
|
||||||
|
.conversation-unread-dropdown {
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $primary-blue;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-conversations {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
color: $text-muted;
|
||||||
|
|
||||||
|
.subtext {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-users-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-users-button-header {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $danger;
|
||||||
|
color: $danger;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-users-panel-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 12px);
|
||||||
|
right: 0;
|
||||||
|
width: 360px;
|
||||||
|
max-height: 520px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
box-shadow: 0 40px 80px rgba(15, 23, 42, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideDown 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-users-header-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.05) 100%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-blocked-users {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
color: $text-muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.1);
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-users-content-dropdown {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-loading {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-muted;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-users-list-dropdown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-item-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 15px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-avatar-dropdown {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.avatar-image,
|
||||||
|
.avatar-circle-dropdown {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle-dropdown {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: #fff;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-info-dropdown {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
|
||||||
|
.blocked-user-name-dropdown {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-details-dropdown {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unblock-button-dropdown {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-blocked-users-dropdown {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: $text-muted;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Notification } from '../../../../services/notification.service';
|
||||||
|
import { UserInfo, UserService } from '../../../../services/user.service';
|
||||||
|
import { ChatUser, Conversation } from '../../../../services/chat.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-dashboard-header',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './patient-dashboard-header.component.html',
|
||||||
|
styleUrls: ['./patient-dashboard-header.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientDashboardHeaderComponent {
|
||||||
|
@Input() currentUser: UserInfo | null = null;
|
||||||
|
@Input() notificationCount = 0;
|
||||||
|
@Input() notifications: Notification[] = [];
|
||||||
|
@Input() showNotifications = false;
|
||||||
|
@Input() loading = false;
|
||||||
|
@Input() chatUnreadCount = 0;
|
||||||
|
@Input() showChatMenu = false;
|
||||||
|
@Input() showBlockedUsers = false;
|
||||||
|
@Input() blockedUsers: ChatUser[] = [];
|
||||||
|
@Input() isLoadingBlockedUsers = false;
|
||||||
|
@Input() chatSearchQuery = '';
|
||||||
|
@Input() chatSearchResults: ChatUser[] = [];
|
||||||
|
@Input() filteredConversations: Conversation[] = [];
|
||||||
|
@Input() conversations: Conversation[] = [];
|
||||||
|
@Input() activeConversationId: string | null = null;
|
||||||
|
|
||||||
|
@Output() refresh = new EventEmitter<void>();
|
||||||
|
@Output() logout = new EventEmitter<void>();
|
||||||
|
@Output() toggleNotifications = new EventEmitter<void>();
|
||||||
|
@Output() markAllNotificationsAsRead = new EventEmitter<void>();
|
||||||
|
@Output() deleteAllNotifications = new EventEmitter<void>();
|
||||||
|
@Output() deleteNotification = new EventEmitter<string>();
|
||||||
|
@Output() notificationClick = new EventEmitter<Notification>();
|
||||||
|
@Output() toggleChatMenu = new EventEmitter<void>();
|
||||||
|
@Output() toggleBlockedUsers = new EventEmitter<void>();
|
||||||
|
@Output() chatSearch = new EventEmitter<string>();
|
||||||
|
@Output() clearChatSearch = new EventEmitter<void>();
|
||||||
|
@Output() openChatConversation = new EventEmitter<string>();
|
||||||
|
@Output() openChatWithUser = new EventEmitter<string>();
|
||||||
|
@Output() unblockUser = new EventEmitter<ChatUser>();
|
||||||
|
|
||||||
|
constructor(public userService: UserService) {}
|
||||||
|
|
||||||
|
onToggleNotifications(): void {
|
||||||
|
this.toggleNotifications.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onRefresh(): void {
|
||||||
|
this.refresh.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogout(): void {
|
||||||
|
this.logout.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMarkAllRead(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.markAllNotificationsAsRead.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteAll(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.deleteAllNotifications.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteNotification(notificationId: string, event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.deleteNotification.emit(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onNotificationClick(notification: Notification): void {
|
||||||
|
this.notificationClick.emit(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleChatMenu(): void {
|
||||||
|
this.toggleChatMenu.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleBlockedUsers(event?: MouseEvent): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
this.toggleBlockedUsers.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChatSearch(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.chatSearch.emit(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClearChatSearch(event?: MouseEvent): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
this.clearChatSearch.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChatConversation(userId: string): void {
|
||||||
|
this.openChatConversation.emit(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChatWithUser(userId: string): void {
|
||||||
|
this.openChatWithUser.emit(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnblockUser(user: ChatUser, event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
this.unblockUser.emit(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNotificationTime(timestamp: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - new Date(timestamp).getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConversationTime(timestamp?: string | Date): string {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusText(status: string | undefined): string {
|
||||||
|
if (!status) return 'Offline';
|
||||||
|
switch (status) {
|
||||||
|
case 'ONLINE':
|
||||||
|
return 'Online';
|
||||||
|
case 'BUSY':
|
||||||
|
return 'Busy';
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'Offline';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChatImageError(event: Event): void {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
const avatarCircle = img.nextElementSibling as HTMLElement;
|
||||||
|
if (avatarCircle) {
|
||||||
|
avatarCircle.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<nav class="dashboard-tabs">
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'overview'"
|
||||||
|
(click)="selectTab('overview')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 22V12H15V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'appointments'"
|
||||||
|
(click)="selectTab('appointments')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Appointments
|
||||||
|
<span class="tab-badge" *ngIf="totalAppointments > 0">{{ totalAppointments }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'profile'"
|
||||||
|
(click)="selectTab('profile')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'doctors'"
|
||||||
|
(click)="selectTab('doctors')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 14V22M8 18H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Available Doctors
|
||||||
|
<span class="tab-badge" *ngIf="doctorsCount > 0">{{ doctorsCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'ehr'"
|
||||||
|
(click)="selectTab('ehr')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V16M8 12H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Medical Records
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'prescriptions'"
|
||||||
|
(click)="selectTab('prescriptions')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 8H16M8 12H16M8 16H14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Prescriptions
|
||||||
|
<span class="tab-badge" *ngIf="prescriptionsCount > 0">{{ prescriptionsCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
[class.active]="activeTab === 'security'"
|
||||||
|
(click)="selectTab('security')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 8V12M8 10H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
$primary-blue: #1b64f2;
|
||||||
|
$muted: #6b7280;
|
||||||
|
$border: #e5e7eb;
|
||||||
|
$surface: #fff;
|
||||||
|
$shadow: 0 15px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem 2rem 0;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: $surface;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: $muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(27, 100, 242, 0.08);
|
||||||
|
color: $primary-blue;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(27, 100, 242, 0.5);
|
||||||
|
color: $primary-blue;
|
||||||
|
box-shadow: $shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(135deg, #1b64f2, #6a8bff);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: $shadow;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -6px;
|
||||||
|
min-width: 26px;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-dashboard-tabs',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './patient-dashboard-tabs.component.html',
|
||||||
|
styleUrls: ['./patient-dashboard-tabs.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientDashboardTabsComponent {
|
||||||
|
@Input() activeTab: string = 'overview';
|
||||||
|
@Input() totalAppointments = 0;
|
||||||
|
@Input() doctorsCount = 0;
|
||||||
|
@Input() prescriptionsCount = 0;
|
||||||
|
|
||||||
|
@Output() tabChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
selectTab(tab: string): void {
|
||||||
|
if (this.activeTab === tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.tabChange.emit(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<section class="doctors-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Available Doctors</h2>
|
||||||
|
<p class="section-description">Enterprise-verified providers who have collaborated with you</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doctors-grid" *ngIf="doctors.length > 0">
|
||||||
|
<div class="doctor-card" *ngFor="let doctor of doctors" (click)="onViewDoctor(doctor.id)">
|
||||||
|
<div class="doctor-header">
|
||||||
|
<div class="doctor-avatar">
|
||||||
|
<img
|
||||||
|
*ngIf="doctor.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(doctor.avatarUrl)"
|
||||||
|
[alt]="doctor.firstName + ' ' + doctor.lastName"
|
||||||
|
class="avatar-image"
|
||||||
|
/>
|
||||||
|
<svg *ngIf="!doctor.avatarUrl" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 14V22M8 18H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="doctor-info">
|
||||||
|
<h3 class="doctor-name">Dr. {{ doctor.firstName }} {{ doctor.lastName }}</h3>
|
||||||
|
<p class="doctor-specialization">{{ doctor.specialization }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="verified-badge" *ngIf="doctor.isVerified">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="doctor-details">
|
||||||
|
<p class="doctor-experience">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ doctor.yearsOfExperience }} years of experience
|
||||||
|
</p>
|
||||||
|
<p class="doctor-fee">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2V22M17 5H9.5C8.57174 5 7.6815 5.36875 7.02513 6.02513C6.36875 6.6815 6 7.57174 6 8.5C6 9.42826 6.36875 10.3185 7.02513 10.9749C7.6815 11.6313 8.57174 12 9.5 12H14.5C15.4283 12 16.3185 12.3687 16.9749 13.0251C17.6313 13.6815 18 14.5717 18 15.5C18 16.4283 17.6313 17.3185 16.9749 17.9749C16.3185 18.6313 15.4283 19 14.5 19H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
${{ doctor.consultationFee || 'N/A' }} consultation fee
|
||||||
|
</p>
|
||||||
|
<p class="doctor-bio" *ngIf="doctor.biography">{{ doctor.biography }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="doctor-actions">
|
||||||
|
<button class="view-profile-btn" (click)="onViewDoctor(doctor.id); $event.stopPropagation()">
|
||||||
|
View Full Profile
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-remove" (click)="onRemoveDoctor(doctor, $event)" [disabled]="loading" title="Remove from history">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<path d="M3 6H5H21M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Remove from History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" *ngIf="doctors.length === 0">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Doctors Available</h3>
|
||||||
|
<p>There are no verified doctors in the system yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" *ngIf="showDoctorProfileModal" (click)="onCloseModal()">
|
||||||
|
<div class="modal-content profile-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Dr. {{ selectedDoctorProfile?.firstName }} {{ selectedDoctorProfile?.lastName }} - Full Profile</h2>
|
||||||
|
<button class="modal-close" (click)="onCloseModal()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" *ngIf="selectedDoctorProfile">
|
||||||
|
<div class="profile-full-view">
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3>Basic Information</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Full Name:</span>
|
||||||
|
<span class="info-value">Dr. {{ selectedDoctorProfile.firstName }} {{ selectedDoctorProfile.lastName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.email">
|
||||||
|
<span class="info-label">Email:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Phone:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Specialization:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.specialization }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Experience:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.yearsOfExperience }} years</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.medicalLicenseNumber">
|
||||||
|
<span class="info-label">License Number:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.medicalLicenseNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Consultation Fee:</span>
|
||||||
|
<span class="info-value">${{ selectedDoctorProfile.consultationFee }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.streetAddress || selectedDoctorProfile.city">
|
||||||
|
<h3>Address</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.streetAddress">
|
||||||
|
<span class="info-label">Street:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.streetAddress }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.city">
|
||||||
|
<span class="info-label">City:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.city }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.state">
|
||||||
|
<span class="info-label">State:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.state }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.zipCode">
|
||||||
|
<span class="info-label">Zip Code:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.zipCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.country">
|
||||||
|
<span class="info-label">Country:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.country }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.educationDegree || selectedDoctorProfile.educationUniversity">
|
||||||
|
<h3>Education</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.educationDegree">
|
||||||
|
<span class="info-label">Degree:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.educationDegree }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.educationUniversity">
|
||||||
|
<span class="info-label">University:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.educationUniversity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" *ngIf="selectedDoctorProfile.educationGraduationYear">
|
||||||
|
<span class="info-label">Graduation Year:</span>
|
||||||
|
<span class="info-value">{{ selectedDoctorProfile.educationGraduationYear }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.certifications?.length">
|
||||||
|
<h3>Certifications</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let cert of selectedDoctorProfile.certifications">{{ cert }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.languagesSpoken?.length">
|
||||||
|
<h3>Languages Spoken</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let lang of selectedDoctorProfile.languagesSpoken">{{ lang }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.hospitalAffiliations?.length">
|
||||||
|
<h3>Hospital Affiliations</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let hospital of selectedDoctorProfile.hospitalAffiliations">{{ hospital }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.insuranceAccepted?.length">
|
||||||
|
<h3>Insurance Accepted</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let insurance of selectedDoctorProfile.insuranceAccepted">{{ insurance }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.professionalMemberships?.length">
|
||||||
|
<h3>Professional Memberships</h3>
|
||||||
|
<div class="tags-list">
|
||||||
|
<span class="tag" *ngFor="let membership of selectedDoctorProfile.professionalMemberships">{{ membership }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-section" *ngIf="selectedDoctorProfile.biography">
|
||||||
|
<h3>Biography</h3>
|
||||||
|
<p class="biography-text">{{ selectedDoctorProfile.biography }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
$surface: #ffffff;
|
||||||
|
$border: #e2e8f0;
|
||||||
|
$primary: #1b64f2;
|
||||||
|
$muted: #6b7280;
|
||||||
|
$text: #0f172a;
|
||||||
|
$danger: #e11d48;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctors-section {
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 1400px;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: 0 25px 55px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-card {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(160deg, rgba(247, 250, 255, 0.9), #fff);
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 15px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($primary, 0.4);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba($primary, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-specialization {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #059669;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-details {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doctor-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-profile-btn,
|
||||||
|
.btn-remove {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-profile-btn {
|
||||||
|
background: rgba($primary, 0.12);
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: rgba($danger, 0.08);
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 2px dashed rgba(148, 163, 184, 0.6);
|
||||||
|
color: $muted;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 40px 80px rgba(15, 23, 42, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
border: none;
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-full-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(15, 23, 42, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba($primary, 0.1);
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.biography-text {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { UserService } from '../../../../services/user.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-doctors-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './patient-doctors-panel.component.html',
|
||||||
|
styleUrls: ['./patient-doctors-panel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientDoctorsPanelComponent {
|
||||||
|
@Input() doctors: any[] = [];
|
||||||
|
@Input() loading = false;
|
||||||
|
@Input() showDoctorProfileModal = false;
|
||||||
|
@Input() selectedDoctorProfile: any = null;
|
||||||
|
|
||||||
|
@Output() viewDoctorProfile = new EventEmitter<string>();
|
||||||
|
@Output() removeDoctorFromHistory = new EventEmitter<any>();
|
||||||
|
@Output() closeDoctorProfileModal = new EventEmitter<void>();
|
||||||
|
|
||||||
|
constructor(public userService: UserService) {}
|
||||||
|
|
||||||
|
onViewDoctor(doctorId: string): void {
|
||||||
|
this.viewDoctorProfile.emit(doctorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemoveDoctor(doctor: any, event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.removeDoctorFromHistory.emit(doctor);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCloseModal(): void {
|
||||||
|
this.closeDoctorProfileModal.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<section class="ehr-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Electronic Health Records</h2>
|
||||||
|
<p>Clinical data synchronized from your care team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" *ngIf="latestVitalSigns">
|
||||||
|
<h3>Latest Vital Signs</h3>
|
||||||
|
<div class="vital-signs-grid">
|
||||||
|
<div *ngIf="latestVitalSigns.temperature" class="vital-item">
|
||||||
|
<label>Temperature</label>
|
||||||
|
<span>{{ latestVitalSigns.temperature }}°C</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="latestVitalSigns.bloodPressureSystolic" class="vital-item">
|
||||||
|
<label>Blood Pressure</label>
|
||||||
|
<span>{{ latestVitalSigns.bloodPressureSystolic }}/{{ latestVitalSigns.bloodPressureDiastolic }} mmHg</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="latestVitalSigns.heartRate" class="vital-item">
|
||||||
|
<label>Heart Rate</label>
|
||||||
|
<span>{{ latestVitalSigns.heartRate }} bpm</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="latestVitalSigns.bmi" class="vital-item">
|
||||||
|
<label>BMI</label>
|
||||||
|
<span>{{ latestVitalSigns.bmi | number: '1.1-1' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="timestamp">Recorded: {{ formatDateFn(latestVitalSigns.recordedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Medical Records</h3>
|
||||||
|
<div *ngIf="medicalRecords.length === 0" class="empty-state">No medical records found</div>
|
||||||
|
<div *ngFor="let record of medicalRecords" class="record-item">
|
||||||
|
<div class="record-header">
|
||||||
|
<span class="record-type">{{ record.recordType }}</span>
|
||||||
|
<span class="record-date">{{ formatDateFn(record.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<h4>{{ record.title }}</h4>
|
||||||
|
<p>{{ record.content }}</p>
|
||||||
|
<div *ngIf="record.diagnosisCode" class="diagnosis-code">ICD-10: {{ record.diagnosisCode }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Lab Results</h3>
|
||||||
|
<div *ngIf="labResults.length === 0" class="empty-state">No lab results found</div>
|
||||||
|
<div *ngFor="let lab of labResults" class="lab-item">
|
||||||
|
<div class="lab-header">
|
||||||
|
<span class="lab-name">{{ lab.testName }}</span>
|
||||||
|
<span class="lab-status" [class]="'status-' + lab.status.toLowerCase()">{{ lab.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lab-result">{{ lab.resultValue }} {{ lab.unit }}</div>
|
||||||
|
<div *ngIf="lab.referenceRange" class="lab-reference">Normal: {{ lab.referenceRange }}</div>
|
||||||
|
<p class="timestamp">Ordered: {{ formatDateFn(lab.orderedDate) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
$surface: #ffffff;
|
||||||
|
$border: #e2e8f0;
|
||||||
|
$primary: #1b64f2;
|
||||||
|
$muted: #6b7280;
|
||||||
|
$text: #0f172a;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ehr-section {
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 1400px;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(170deg, rgba(248, 250, 255, 0.7), #fff);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-signs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-item {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(27, 100, 242, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item,
|
||||||
|
.lab-item {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-header,
|
||||||
|
.lab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $muted;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-code {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-status {
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.status-completed {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
&.status-pending {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
&.status-flagged {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-result {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-reference {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
color: $muted;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MedicalRecord, VitalSigns, LabResult } from '../../../../services/medical-record.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-ehr-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './patient-ehr-panel.component.html',
|
||||||
|
styleUrls: ['./patient-ehr-panel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientEhrPanelComponent {
|
||||||
|
@Input() latestVitalSigns: VitalSigns | null = null;
|
||||||
|
@Input() medicalRecords: MedicalRecord[] = [];
|
||||||
|
@Input() labResults: LabResult[] = [];
|
||||||
|
@Input() formatDateFn: (value: string) => string = (value: string) => value;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<section class="overview-panel">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Appointment Intelligence</h2>
|
||||||
|
<p>Enterprise-level visibility into your care journey</p>
|
||||||
|
</div>
|
||||||
|
<button class="cta" (click)="goTo('appointments')">
|
||||||
|
Manage appointments
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card primary">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p>Total Appointments</p>
|
||||||
|
<h3>{{ totalAppointments }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card success">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 14L11 16L15 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p>Upcoming</p>
|
||||||
|
<h3>{{ upcomingCount }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card info">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p>Completed</p>
|
||||||
|
<h3>{{ completedCount }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<p>Cancelled</p>
|
||||||
|
<h3>{{ cancelledCount }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<article class="action-card" (click)="goTo('appointments')">
|
||||||
|
<div class="action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V6M16 2V6M3 10H21M5 4H19C20.1046 4 21 4.89543 21 6V20C21 21.1046 20.1046 22 19 22H5C3.89543 22 3 21.1046 3 20V6C3 4.89543 3.89543 4 5 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>View Appointments</h4>
|
||||||
|
<p>Manage confirmed, pending, and past visits</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="action-card" (click)="goTo('doctors')">
|
||||||
|
<div class="action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 14V22M8 18H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Find Doctors</h4>
|
||||||
|
<p>Browse enterprise verified care teams</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="action-card" (click)="goTo('profile')">
|
||||||
|
<div class="action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>My Profile</h4>
|
||||||
|
<p>Keep personal and clinical data current</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
@use 'sass:color';
|
||||||
|
|
||||||
|
$surface: #ffffff;
|
||||||
|
$bg: #f7f9fc;
|
||||||
|
$border: #e5e7eb;
|
||||||
|
$primary: #1b64f2;
|
||||||
|
$success: #10b981;
|
||||||
|
$info: #0ea5e9;
|
||||||
|
$warning: #f97316;
|
||||||
|
$text: #0f172a;
|
||||||
|
$muted: #6b7280;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel {
|
||||||
|
background: $surface;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 1400px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: 0 25px 70px rgba(15, 23, 42, 0.12);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: $success;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: $bg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary .stat-icon {
|
||||||
|
background: rgba($primary, 0.12);
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success .stat-icon {
|
||||||
|
background: rgba($success, 0.12);
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.info .stat-icon {
|
||||||
|
background: rgba($info, 0.12);
|
||||||
|
color: $info;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning .stat-icon {
|
||||||
|
background: rgba($warning, 0.12);
|
||||||
|
color: $warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: $surface;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($primary, 0.4);
|
||||||
|
box-shadow: 0 20px 35px rgba(15, 23, 42, 0.1);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba($primary, 0.1);
|
||||||
|
color: $primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-overview-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './patient-overview-panel.component.html',
|
||||||
|
styleUrls: ['./patient-overview-panel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientOverviewPanelComponent {
|
||||||
|
@Input() totalAppointments = 0;
|
||||||
|
@Input() upcomingCount = 0;
|
||||||
|
@Input() completedCount = 0;
|
||||||
|
@Input() cancelledCount = 0;
|
||||||
|
|
||||||
|
@Output() quickNav = new EventEmitter<string>();
|
||||||
|
|
||||||
|
goTo(tab: string): void {
|
||||||
|
this.quickNav.emit(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<section class="prescriptions-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Prescriptions</h2>
|
||||||
|
<p>Medication orders managed by your clinical team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="prescriptions.length === 0" class="empty-state">No prescriptions found</div>
|
||||||
|
|
||||||
|
<div *ngFor="let prescription of prescriptions" class="prescription-card">
|
||||||
|
<div class="prescription-header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ prescription.medicationName }}</h3>
|
||||||
|
<p class="prescription-number">#{{ prescription.prescriptionNumber }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="prescription-status" [class]="'status-' + prescription.status.toLowerCase()">
|
||||||
|
{{ prescription.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="prescription-details">
|
||||||
|
<p><strong>Dosage:</strong> {{ prescription.dosage }}</p>
|
||||||
|
<p><strong>Frequency:</strong> {{ prescription.frequency }}</p>
|
||||||
|
<p><strong>Quantity:</strong> {{ prescription.quantity }}</p>
|
||||||
|
<p *ngIf="prescription.refills > 0"><strong>Refills:</strong> {{ prescription.refills }}</p>
|
||||||
|
<p *ngIf="prescription.instructions"><strong>Instructions:</strong> {{ prescription.instructions }}</p>
|
||||||
|
<p><strong>Start Date:</strong> {{ formatDateFn(prescription.startDate) }}</p>
|
||||||
|
<p *ngIf="prescription.endDate"><strong>End Date:</strong> {{ formatDateFn(prescription.endDate) }}</p>
|
||||||
|
<p *ngIf="prescription.pharmacyName"><strong>Pharmacy:</strong> {{ prescription.pharmacyName }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
$surface: #ffffff;
|
||||||
|
$border: #e2e8f0;
|
||||||
|
$primary: #1b64f2;
|
||||||
|
$muted: #6b7280;
|
||||||
|
$text: #0f172a;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prescriptions-section {
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 1400px;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: 0 15px 45px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prescription-card {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: linear-gradient(160deg, rgba(247, 250, 255, 0.6), #fff);
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prescription-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prescription-number {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prescription-status {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.status-active {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
&.status-expired {
|
||||||
|
background: rgba(248, 113, 113, 0.2);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
&.status-pending {
|
||||||
|
background: rgba(234, 179, 8, 0.2);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prescription-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: $text;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Prescription } from '../../../../services/prescription.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-patient-prescriptions-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './patient-prescriptions-panel.component.html',
|
||||||
|
styleUrls: ['./patient-prescriptions-panel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class PatientPrescriptionsPanelComponent {
|
||||||
|
@Input() prescriptions: Prescription[] = [];
|
||||||
|
@Input() formatDateFn: (value: string) => string = (value: string) => value;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<section class="profile-section">
|
||||||
|
<div class="profile-header-section">
|
||||||
|
<div class="profile-header-content">
|
||||||
|
<h2 class="section-title">My Profile</h2>
|
||||||
|
<p class="section-description">View and manage your personal and medical information</p>
|
||||||
|
</div>
|
||||||
|
<button class="edit-profile-btn" (click)="onStartEdit()" *ngIf="!showEditProfile">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-card" *ngIf="!showEditProfile">
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar-wrapper">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<img
|
||||||
|
*ngIf="currentUser?.avatarUrl"
|
||||||
|
[src]="userService.getAvatarUrl(currentUser?.avatarUrl)"
|
||||||
|
[alt]="currentUser?.firstName + ' ' + currentUser?.lastName"
|
||||||
|
class="avatar-image-large"
|
||||||
|
/>
|
||||||
|
<svg *ngIf="!currentUser?.avatarUrl" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button class="avatar-upload-btn" (click)="fileInput.click()" title="Upload photo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 3V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input #fileInput type="file" accept="image/*" style="display: none;" (change)="onAvatarChange($event)" />
|
||||||
|
</div>
|
||||||
|
<div class="profile-name">
|
||||||
|
<h3>{{ currentUser?.firstName }} {{ currentUser?.lastName }}</h3>
|
||||||
|
<p>{{ currentUser?.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Phone Number</span>
|
||||||
|
<span class="detail-value">{{ currentUser?.phoneNumber || patientProfile?.emergencyContactPhone || 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="patientInfo">
|
||||||
|
<span class="detail-label">Blood Type</span>
|
||||||
|
<span class="detail-value">{{ patientInfo.bloodType || 'Not specified' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="patientInfo">
|
||||||
|
<span class="detail-label">Allergies</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span *ngIf="patientInfo.allergies && patientInfo.allergies.length > 0">
|
||||||
|
{{ patientInfo.allergies.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="!patientInfo.allergies || patientInfo.allergies.length === 0">None</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" *ngIf="patientInfo">
|
||||||
|
<span class="detail-label">Emergency Contact</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span *ngIf="patientInfo.emergencyContactName">
|
||||||
|
{{ patientInfo.emergencyContactName }}
|
||||||
|
<span *ngIf="patientInfo.emergencyContactPhone"> - {{ patientInfo.emergencyContactPhone }}</span>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="!patientInfo.emergencyContactName">Not specified</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Account Status</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span class="status-badge" [ngClass]="currentUser?.isActive ? 'status-confirmed' : 'status-cancelled'">
|
||||||
|
{{ currentUser?.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edit-profile-form" *ngIf="showEditProfile && editProfileData">
|
||||||
|
<div class="form-header">
|
||||||
|
<h3 class="form-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89782 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Edit Profile
|
||||||
|
</h3>
|
||||||
|
<p class="form-subtitle">Update your personal and medical information</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="profile-form" (ngSubmit)="onSaveProfile()" #profileForm="ngForm">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-subtitle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Basic Information
|
||||||
|
</h4>
|
||||||
|
<p class="section-description">Your personal contact details</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="firstName">First Name <span class="required-indicator">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
[(ngModel)]="editUserData.firstName"
|
||||||
|
name="firstName"
|
||||||
|
class="form-input"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
pattern="[A-Za-z\\s]{2,50}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-50 characters, letters only</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="lastName">Last Name <span class="required-indicator">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
[(ngModel)]="editUserData.lastName"
|
||||||
|
name="lastName"
|
||||||
|
class="form-input"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
pattern="[A-Za-z\\s]{2,50}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-50 characters, letters only</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="phoneNumber">Phone Number <span class="required-indicator">*</span></label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phoneNumber"
|
||||||
|
[(ngModel)]="editUserData.phoneNumber"
|
||||||
|
name="phoneNumber"
|
||||||
|
class="form-input"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="15"
|
||||||
|
pattern="[+]?[0-9]{10,15}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint">10-15 digits, can include country code with +</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-subtitle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7V10C2 16 6 21.4 12 22C18 21.4 22 16 22 10V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Medical Information
|
||||||
|
</h4>
|
||||||
|
<p class="section-description">Important health details for emergency situations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="bloodType">Blood Type</label>
|
||||||
|
<select id="bloodType" [(ngModel)]="editProfileData.bloodType" name="bloodType" class="form-input form-select">
|
||||||
|
<option value="">Select blood type</option>
|
||||||
|
<option value="A+">A+</option>
|
||||||
|
<option value="A-">A-</option>
|
||||||
|
<option value="B+">B+</option>
|
||||||
|
<option value="B-">B-</option>
|
||||||
|
<option value="AB+">AB+</option>
|
||||||
|
<option value="AB-">AB-</option>
|
||||||
|
<option value="O+">O+</option>
|
||||||
|
<option value="O-">O-</option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">Required for medical emergencies</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="emergencyContactName">Emergency Contact Name <span class="required-indicator">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="emergencyContactName"
|
||||||
|
[(ngModel)]="editProfileData.emergencyContactName"
|
||||||
|
name="emergencyContactName"
|
||||||
|
class="form-input"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint">2-50 characters</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="emergencyContactPhone">Emergency Contact Phone <span class="required-indicator">*</span></label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="emergencyContactPhone"
|
||||||
|
[(ngModel)]="editProfileData.emergencyContactPhone"
|
||||||
|
name="emergencyContactPhone"
|
||||||
|
class="form-input"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="15"
|
||||||
|
pattern="[+]?[0-9]{10,15}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint">10-15 digits, can include country code with +</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<label class="form-label" for="allergies">Allergies</label>
|
||||||
|
<div class="allergies-input-container">
|
||||||
|
<div class="allergies-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="allergies"
|
||||||
|
[ngModel]="newAllergy"
|
||||||
|
(ngModelChange)="newAllergyChange.emit($event)"
|
||||||
|
name="newAllergy"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter allergy name (e.g., Penicillin, Peanuts)"
|
||||||
|
(keyup.enter)="onAddAllergy()"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="onAddAllergy()" title="Add allergy">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">Add known allergies for medical safety</span>
|
||||||
|
<div class="allergies-list" *ngIf="editProfileData.allergies && editProfileData.allergies.length > 0">
|
||||||
|
<div class="allergy-tag" *ngFor="let allergy of editProfileData.allergies; let i = index">
|
||||||
|
<span class="allergy-name">{{ allergy }}</span>
|
||||||
|
<button type="button" class="remove-allergy" (click)="onRemoveAllergy(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="allergies-empty" *ngIf="!editProfileData.allergies || editProfileData.allergies.length === 0">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>No allergies added yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional enterprise fields -->
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-subtitle">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 7.61305 3.94821 5.32387 5.63604 3.63604C7.32387 1.94821 9.61305 1 12 1C14.3869 1 16.6761 1.94821 18.364 3.63604C20.0518 5.32387 21 7.61305 21 10Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
Additional Information
|
||||||
|
</h4>
|
||||||
|
<p class="section-description">Complete your profile for better care</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="dateOfBirth">Date of Birth</label>
|
||||||
|
<input type="date" id="dateOfBirth" [(ngModel)]="editProfileData.dateOfBirth" name="dateOfBirth" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="gender">Gender</label>
|
||||||
|
<select id="gender" [(ngModel)]="editProfileData.gender" name="gender" class="form-input form-select">
|
||||||
|
<option value="">Select gender</option>
|
||||||
|
<option value="Male">Male</option>
|
||||||
|
<option value="Female">Female</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="streetAddress">Street Address</label>
|
||||||
|
<input type="text" id="streetAddress" [(ngModel)]="editProfileData.streetAddress" name="streetAddress" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="city">City</label>
|
||||||
|
<input type="text" id="city" [(ngModel)]="editProfileData.city" name="city" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="state">State</label>
|
||||||
|
<input type="text" id="state" [(ngModel)]="editProfileData.state" name="state" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="zipCode">Zip/Postal Code</label>
|
||||||
|
<input type="text" id="zipCode" [(ngModel)]="editProfileData.zipCode" name="zipCode" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="country">Country</label>
|
||||||
|
<input type="text" id="country" [(ngModel)]="editProfileData.country" name="country" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Insurance Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="insuranceProvider">Insurance Provider</label>
|
||||||
|
<input type="text" id="insuranceProvider" [(ngModel)]="editProfileData.insuranceProvider" name="insuranceProvider" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="insurancePolicyNumber">Policy Number</label>
|
||||||
|
<input type="text" id="insurancePolicyNumber" [(ngModel)]="editProfileData.insurancePolicyNumber" name="insurancePolicyNumber" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<h5 class="subsection-title">Primary Care Physician</h5>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="primaryCarePhysicianName">Physician Name</label>
|
||||||
|
<input type="text" id="primaryCarePhysicianName" [(ngModel)]="editProfileData.primaryCarePhysicianName" name="primaryCarePhysicianName" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="primaryCarePhysicianPhone">Physician Phone</label>
|
||||||
|
<input type="tel" id="primaryCarePhysicianPhone" [(ngModel)]="editProfileData.primaryCarePhysicianPhone" name="primaryCarePhysicianPhone" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<label class="form-label" for="medicalHistorySummary">Medical History Summary</label>
|
||||||
|
<textarea
|
||||||
|
id="medicalHistorySummary"
|
||||||
|
[(ngModel)]="editProfileData.medicalHistorySummary"
|
||||||
|
name="medicalHistorySummary"
|
||||||
|
class="form-input form-textarea"
|
||||||
|
rows="4"
|
||||||
|
maxlength="2000"
|
||||||
|
></textarea>
|
||||||
|
<div class="textarea-footer">
|
||||||
|
<span class="form-hint">Brief summary of medical history (max 2000 characters)</span>
|
||||||
|
<span class="char-counter" *ngIf="editProfileData.medicalHistorySummary">
|
||||||
|
{{ (editProfileData.medicalHistorySummary || '').length }}/2000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<label class="form-label" for="currentMedications">Current Medications</label>
|
||||||
|
<div class="allergies-input-container">
|
||||||
|
<div class="allergies-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="currentMedications"
|
||||||
|
[ngModel]="newMedication"
|
||||||
|
(ngModelChange)="newMedicationChange.emit($event)"
|
||||||
|
name="newMedication"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter medication name"
|
||||||
|
(keyup.enter)="onAddMedication()"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button type="button" class="add-btn" (click)="onAddMedication()" title="Add medication">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">Add all medications you're currently taking</span>
|
||||||
|
<div class="allergies-list" *ngIf="editProfileData.currentMedications && editProfileData.currentMedications.length > 0">
|
||||||
|
<div class="allergy-tag" *ngFor="let medication of editProfileData.currentMedications; let i = index">
|
||||||
|
<span class="allergy-name">{{ medication }}</span>
|
||||||
|
<button type="button" class="remove-allergy" (click)="onRemoveMedication(i)" title="Remove">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="allergies-empty" *ngIf="!editProfileData.currentMedications || editProfileData.currentMedications.length === 0">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>No medications added yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="cancel-btn" (click)="onCancelEdit()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="submit-button" [disabled]="profileForm.invalid">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
$surface: #ffffff;
|
||||||
|
$border: #e2e8f0;
|
||||||
|
$primary: #1b64f2;
|
||||||
|
$danger: #e11d48;
|
||||||
|
$text: #0f172a;
|
||||||
|
$muted: #6b7280;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
max-width: 1400px;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: 0 25px 55px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-profile-btn {
|
||||||
|
border: 1px solid rgba($primary, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: rgba($primary, 0.08);
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, rgba(248, 250, 255, 0.9), #fff);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba($primary, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.avatar-image-large {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 20px rgba(27, 100, 242, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.status-confirmed {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-cancelled {
|
||||||
|
background: rgba(248, 113, 113, 0.2);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-profile-form {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
border: 1px solid #edf0f7;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
|
||||||
|
&.form-group-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $primary;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(27, 100, 242, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergies-input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergies-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 0.9rem;
|
||||||
|
background: rgba($primary, 0.12);
|
||||||
|
color: $primary;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergies-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergy-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-allergy {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: $danger;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergies-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn,
|
||||||
|
.submit-button {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
background: linear-gradient(135deg, #1b64f2, #7b5bff);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 15px 30px rgba(27, 100, 242, 0.2);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user