Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/services/AIService.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ export default class AIService extends BaseService {
},
});
}

static generateFlashcards(payload) {
const headers = this.getHeaders();
return axios.post(BaseService.getURL("v2/ai/generate-flashcards"), payload, headers);
}
}
11 changes: 11 additions & 0 deletions src/services/CouponService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import axios from "axios";
import BaseService from "./BaseService";

class CouponService extends BaseService {
static getCoupons() {
const headers = this.getHeaders();
return axios.get(BaseService.getURL("v2/coupons/me"), headers);
}
}

export default CouponService;
5 changes: 5 additions & 0 deletions src/services/FlashcardService.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ export default class FlashcardService extends BaseService {
const headers = this.getHeaders();
return axios.post(BaseService.getURL(`flashcards/${id}/review`), { quality }, headers);
}

static createFlashcard(payload) {
const headers = this.getHeaders();
return axios.post(BaseService.getURL("v2/flashcards"), payload, headers);
}
}
7 changes: 6 additions & 1 deletion src/services/UserService.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class UserService extends BaseService {
static getPublicProfile(userId) {
const headers = this.getHeaders();
return axios.get(BaseService.getURL(`users/${userId}/public-profile`), headers);
return axios.get(BaseService.getURL(`users/${userId}/public-profile`), headers);
}

// Admin: Listar usuarios
Expand All @@ -57,6 +56,12 @@ class UserService extends BaseService {
return axios.delete(BaseService.getURL(`users/${id}`), headers);
}

// Admin V2: Estadísticas globales
static getAdminStats() {
const headers = this.getHeaders();
return axios.get(BaseService.getURL("v2/admin/stats"), headers);
}

// Aliases para compatibilidad durante la transición si es necesario
static createPlayer(params) {
return this.createUser(params);
Expand Down
87 changes: 69 additions & 18 deletions src/v2/pages/V2AIFlashcardGenerator.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import AIService from '../../services/AIService';
import FlashcardService from '../../services/FlashcardService';
import '../styles/v2-theme.css';

const V2AIFlashcardGenerator = () => {
Expand All @@ -8,19 +10,56 @@ const V2AIFlashcardGenerator = () => {
const [count, setCount] = useState(5);
const [generating, setGenerating] = useState(false);
const [suggestions, setSuggestions] = useState([]);
const [savingAll, setSavingAll] = useState(false);

const handleGenerate = (e) => {
const handleGenerate = async (e) => {
e.preventDefault();
setGenerating(true);
// Mocking API call to POST /v2/ai/generate-flashcards
setTimeout(() => {
setSuggestions([
{ id: 's1', front: 'Tratamiento inicial de la cetoacidosis diabética', back: 'Reposición hídrica con solución salina al 0.9%' },
{ id: 's2', front: 'Criterios diagnósticos de preeclampsia', back: 'TA > 140/90 mmHg y proteinuria > 300mg/24h después de la semana 20' },
{ id: 's3', front: 'Signo de Murphy positivo indica:', back: 'Colecistitis aguda' }
]);
try {
const response = await AIService.generateFlashcards({ topic, count });
setSuggestions(response.data || []);
} catch (err) {
console.error("Error generating flashcards:", err);
alert("Error al generar sugerencias. Por favor, intenta de nuevo.");
} finally {
setGenerating(false);
}, 1500);
}
};

const handleSaveOne = async (suggestion, index) => {
try {
await FlashcardService.createFlashcard({
front: suggestion.front,
back: suggestion.back,
specialty_id: suggestion.specialty_id || 1 // Fallback or derived specialty
});
// Mark as saved in UI
const newSuggestions = [...suggestions];
newSuggestions[index].saved = true;
setSuggestions(newSuggestions);
} catch (err) {
console.error("Error saving individual flashcard:", err);
}
};

const handleSaveAll = async () => {
setSavingAll(true);
try {
const unsaved = suggestions.filter(s => !s.saved);
for (const s of unsaved) {
await FlashcardService.createFlashcard({
front: s.front,
back: s.back,
specialty_id: s.specialty_id || 1
});
}
history.push('/v2/flashcards/repaso');
} catch (err) {
console.error("Error saving all flashcards:", err);
alert("Ocurrió un error al guardar todas las flashcards.");
} finally {
setSavingAll(false);
}
};

return (
Expand All @@ -32,7 +71,7 @@ const V2AIFlashcardGenerator = () => {
<h1 className="v2-headline-small">Generador de Flashcards con IA</h1>
</header>

<div style={{ display: 'grid', gridTemplateColumns: suggestions.length > 0 ? '1fr 1fr' : '1fr', gap: '32px' }}>
<div style={{ display: 'grid', gridTemplateColumns: suggestions.length > 0 ? '1fr 1.5fr' : '1fr', gap: '32px' }}>
<section>
<form className="v2-card" onSubmit={handleGenerate}>
<h2 className="v2-title-large" style={{ marginBottom: '16px' }}>Configuración</h2>
Expand All @@ -46,6 +85,7 @@ const V2AIFlashcardGenerator = () => {
value={topic}
onChange={(e) => setTopic(e.target.value)}
required
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'transparent', color: 'inherit' }}
/>
</div>
<div style={{ marginBottom: '24px' }}>
Expand All @@ -57,7 +97,8 @@ const V2AIFlashcardGenerator = () => {
min="1"
max="20"
value={count}
onChange={(e) => setCount(e.target.value)}
onChange={(e) => setCount(parseInt(e.target.value))}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'transparent', color: 'inherit' }}
/>
</div>
<button className="v2-btn-primary" style={{ width: '100%' }} disabled={generating}>
Expand All @@ -69,23 +110,33 @@ const V2AIFlashcardGenerator = () => {
{suggestions.length > 0 && (
<section>
<h2 className="v2-title-large" style={{ marginBottom: '16px' }}>Sugerencias Generadas</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxHeight: '600px', overflowY: 'auto', paddingRight: '8px' }}>
{suggestions.map((s, idx) => (
<div key={s.id} className="v2-card-tonal">
<div key={idx} className="v2-card-tonal" style={{ position: 'relative' }}>
<div className="v2-label-medium" style={{ opacity: 0.7, marginBottom: '8px' }}>Sugerencia {idx + 1}</div>
<div className="v2-body-large" style={{ fontWeight: 'bold', marginBottom: '8px' }}>Q: {s.front}</div>
<div className="v2-body-medium">A: {s.back}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
<button className="v2-btn-tonal" style={{ height: '32px', fontSize: '12px' }}>
<i className="material-icons" style={{ fontSize: '16px', marginRight: '4px' }}>add</i>
Guardar
<button
className="v2-btn-tonal"
style={{ height: '32px', fontSize: '12px' }}
onClick={() => handleSaveOne(s, idx)}
disabled={s.saved}
>
<i className="material-icons" style={{ fontSize: '16px', marginRight: '4px' }}>{s.saved ? 'check' : 'add'}</i>
{s.saved ? 'Guardada' : 'Guardar'}
</button>
</div>
</div>
))}
</div>
<button className="v2-btn-primary" style={{ width: '100%', marginTop: '24px' }}>
Guardar Todas
<button
className="v2-btn-primary"
style={{ width: '100%', marginTop: '24px' }}
onClick={handleSaveAll}
disabled={savingAll || suggestions.every(s => s.saved)}
>
{savingAll ? 'Guardando...' : 'Guardar Todas'}
</button>
</section>
)}
Expand Down
56 changes: 47 additions & 9 deletions src/v2/pages/V2AIFlashcardGenerator.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,72 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2AIFlashcardGenerator from './V2AIFlashcardGenerator';
import AIService from '../../services/AIService';
import FlashcardService from '../../services/FlashcardService';

const mockPush = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useHistory: () => ({
push: mockPush,
goBack: vi.fn()
})
};
});

vi.mock('../../services/AIService');
vi.mock('../../services/FlashcardService');

describe('V2AIFlashcardGenerator', () => {
it('renders generator form', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('generates suggestions on form submit', async () => {
AIService.generateFlashcards.mockResolvedValue({
data: [
{ front: 'AI Question', back: 'AI Answer' }
]
});

render(
<MemoryRouter>
<V2AIFlashcardGenerator />
</MemoryRouter>
);

expect(screen.getByPlaceholderText(/Ej: Diabetes Mellitus Tipo 2/i)).toBeTruthy();
expect(screen.getByText('Generar Sugerencias')).toBeTruthy();
fireEvent.change(screen.getByPlaceholderText(/Diabetes Mellitus/i), { target: { value: 'Diabetes' } });
fireEvent.click(screen.getByText('Generar Sugerencias'));

await waitFor(() => {
expect(screen.getByText('Q: AI Question')).toBeTruthy();
expect(screen.getByText('A: AI Answer')).toBeTruthy();
});
});

it('generates suggestions on form submission', async () => {
it('saves all suggestions', async () => {
AIService.generateFlashcards.mockResolvedValue({
data: [{ front: 'Q1', back: 'A1' }, { front: 'Q2', back: 'A2' }]
});
FlashcardService.createFlashcard.mockResolvedValue({});

render(
<MemoryRouter>
<V2AIFlashcardGenerator />
</MemoryRouter>
);

fireEvent.change(screen.getByPlaceholderText(/Ej: Diabetes Mellitus Tipo 2/i), { target: { value: 'HTA' } });
fireEvent.change(screen.getByPlaceholderText(/Diabetes Mellitus/i), { target: { value: 'Diabetes' } });
fireEvent.click(screen.getByText('Generar Sugerencias'));

expect(screen.getByText('Generando...')).toBeTruthy();
await waitFor(() => screen.getByText('Guardar Todas'));
fireEvent.click(screen.getByText('Guardar Todas'));

await waitFor(() => {
expect(screen.getByText('Sugerencias Generadas')).toBeTruthy();
expect(screen.getByText(/Q: Signo de Murphy positivo indica:/i)).toBeTruthy();
}, { timeout: 2000 });
expect(FlashcardService.createFlashcard).toHaveBeenCalledTimes(2);
expect(mockPush).toHaveBeenCalledWith('/v2/flashcards/repaso');
});
});
});
60 changes: 43 additions & 17 deletions src/v2/pages/V2AdminDashboard.jsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,52 @@
import { useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import UserService from '../../services/UserService';
import '../styles/v2-theme.css';

const V2AdminDashboard = () => {
const history = useHistory();
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
// Mocking API call for admin stats
setTimeout(() => {
setStats({
totalUsers: 15420,
activeUsersToday: 1205,
newSubscriptions: 45,
revenueToday: ',250.00'
});
setLoading(false);
}, 800);
const fetchStats = async () => {
try {
const response = await UserService.getAdminStats();
setStats(response.data);
} catch (err) {
console.error("Error fetching admin stats:", err);
setError("Error al cargar estadísticas generales.");
} finally {
setLoading(false);
}
};

fetchStats();
}, []);

if (loading) return <div className="center-align" style={{ padding: '40px' }}><div className="preloader-wrapper big active"><div className="spinner-layer spinner-green-only"><div className="circle-clipper left"><div className="circle"></div></div><div className="gap-patch"><div className="circle"></div></div><div className="circle-clipper right"><div className="circle"></div></div></div></div></div>;
if (loading) return (
<div className="v2-page-container center-align" style={{ padding: '80px' }}>
<div className="preloader-wrapper big active">
<div className="spinner-layer spinner-green-only">
<div className="circle-clipper left"><div className="circle"></div></div>
<div className="gap-patch"><div className="circle"></div></div>
<div className="circle-clipper right"><div className="circle"></div></div>
</div>
</div>
</div>
);

if (error || !stats) return (
<div className="v2-page-container center-align">
<div className="v2-card" style={{ padding: '32px' }}>
<i className="material-icons" style={{ fontSize: '48px', color: 'var(--md-sys-color-error)', marginBottom: '16px' }}>error</i>
<h2 className="v2-title-large">Error</h2>
<p className="v2-body-large">{error || "No se pudieron obtener los datos."}</p>
<button className="v2-btn-primary" style={{ marginTop: '24px' }} onClick={() => window.location.reload()}>Reintentar</button>
</div>
</div>
);

return (
<div className="v2-page-container">
Expand All @@ -29,26 +55,26 @@ const V2AdminDashboard = () => {
<p className="v2-body-medium" style={{ opacity: 0.7 }}>Resumen general de la plataforma</p>
</header>

<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '24px', marginBottom: '40px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '24px', marginBottom: '40px' }}>
<div className="v2-card">
<div className="v2-label-medium">Usuarios Totales</div>
<div className="v2-headline-medium">{stats.totalUsers}</div>
<div className="v2-headline-medium">{stats.totalUsers || 0}</div>
</div>
<div className="v2-card">
<div className="v2-label-medium">Activos Hoy</div>
<div className="v2-headline-medium">{stats.activeUsersToday}</div>
<div className="v2-headline-medium">{stats.activeUsersToday || 0}</div>
</div>
<div className="v2-card">
<div className="v2-label-medium">Nuevas Suscripciones</div>
<div className="v2-headline-medium">+{stats.newSubscriptions}</div>
<div className="v2-headline-medium">+{stats.newSubscriptions || 0}</div>
</div>
<div className="v2-card">
<div className="v2-label-medium">Ingresos Hoy</div>
<div className="v2-headline-medium" style={{ color: 'var(--md-sys-color-primary)' }}>{stats.revenueToday}</div>
<div className="v2-headline-medium" style={{ color: 'var(--md-sys-color-primary)' }}>{stats.revenueToday || '$0.00'}</div>
</div>
</div>

<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '32px' }}>
<section className="v2-card">
<h2 className="v2-title-large" style={{ marginBottom: '24px' }}>Acciones Rápidas</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
Expand Down
Loading
Loading