feat: fase 3 - frontend next.js setup
- Next.js 15 + TypeScript + Bootstrap 5 + FontAwesome - Layout replica SAM.Master (sidebar collapsable + header) - Sidebar, Header, LoadingSpinner, ModalConfirmacion components - Login page with RUT validation - Dashboard page with summary cards - AuthContext + JWT auth with cookies - api.ts centralized HTTP client - useInactividad hook (30min timeout) - validarRut.ts + utils.ts migrated - Original CSS + images copied from project - Dockerfile multi-stage build
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { api } from '@/services/api';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const cards = [
|
||||
{ title: 'Nuevos Leads', icon: 'fas fa-users', color: '#4e73df', endpoint: '/lead/nuevos' },
|
||||
{ title: 'Cotizaciones', icon: 'fas fa-file-invoice-dollar', color: '#1cc88a', endpoint: '/cotizacion/buscar' },
|
||||
{ title: 'Cursos', icon: 'fas fa-book', color: '#36b9cc', endpoint: '/curso' },
|
||||
{ title: 'Alumnos', icon: 'fas fa-user-graduate', color: '#f6c23e', endpoint: '/alumno' },
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.push('/login');
|
||||
if (!loading) setPageLoading(false);
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (pageLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4">Bienvenido, {user?.nombre}</h2>
|
||||
<div className="row">
|
||||
{cards.map(card => (
|
||||
<div className="col-xl-3 col-md-6 mb-4" key={card.title}>
|
||||
<div className="card border-left-primary shadow h-100 py-2" style={{ borderLeft: `.25rem solid ${card.color}` }}>
|
||||
<div className="card-body">
|
||||
<div className="row no-gutters align-items-center">
|
||||
<div className="col mr-2">
|
||||
<div className="text-xs font-weight-bold text-primary text-uppercase mb-1">{card.title}</div>
|
||||
<div className="h5 mb-0 font-weight-bold text-gray-800">-</div>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<i className={`${card.icon} fa-2x text-gray-300`}></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { AuthProvider } from '@/hooks/useAuth';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Header from '@/components/Header';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'SAM - Sistema de Administración de Ventas',
|
||||
description: 'Módulo de Ventas - Instituto Chileno Norteamericano',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Fira+Sans&display=swap" rel="stylesheet" />
|
||||
<link href="https://unpkg.com/boxicons@2.0.7/css/boxicons.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<Sidebar />
|
||||
<section className="home-section alert-dark">
|
||||
<Header />
|
||||
<div className="container-fluid mt-3 px-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { validarRut, formatRut } from '@/lib/validarRut';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [rut, setRut] = useState('');
|
||||
const [clave, setClave] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleRutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/[^0-9kK-]/g, '');
|
||||
setRut(value);
|
||||
if (value.length > 2) {
|
||||
setRut(formatRut(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validarRut(rut)) {
|
||||
setError('RUT inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(rut, clave);
|
||||
router.push('/dashboard');
|
||||
} catch {
|
||||
setError('Credenciales inválidas');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-4">
|
||||
<div className="card shadow">
|
||||
<div className="card-body p-4">
|
||||
<div className="text-center mb-4">
|
||||
<img src="/img/ichnHD.png" alt="ICHN" height="60" />
|
||||
<h4 className="mt-2">Iniciar Sesión</h4>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">RUT</label>
|
||||
<input className="form-control" value={rut} onChange={handleRutChange} placeholder="12.345.678-5" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Clave</label>
|
||||
<input type="password" className="form-control" value={clave} onChange={e => setClave(e.target.value)} />
|
||||
</div>
|
||||
{error && <div className="alert alert-danger py-2">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary w-100">Ingresar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
router.push(user ? '/dashboard' : '/login');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header>
|
||||
<div className="header-content">
|
||||
<span className="badge">Menu de Aplicaciones</span>
|
||||
<div className="user-info">
|
||||
<span className="user-name">
|
||||
<i className="fas fa-user"></i> {user?.nombre || 'Usuario'}
|
||||
</span>
|
||||
<button className="btn-logout" onClick={handleLogout}>
|
||||
<i className="fas fa-sign-out-alt"></i> Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function LoadingSpinner({ text = 'Cargando...' }: { text?: string }) {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
export default function ModalConfirmacion({ open, title, message, onConfirm, onCancel, confirmText = 'Aceptar' }: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<h3>{title}</h3>
|
||||
<p>{message}</p>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-secondary" onClick={onCancel}>Cancelar</button>
|
||||
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
const menuItems = [
|
||||
{ href: '/dashboard', icon: 'fas fa-home', label: 'Dashboard' },
|
||||
{ href: '/leads', icon: 'fas fa-users', label: 'Leads' },
|
||||
{ href: '/cotizaciones', icon: 'fas fa-file-invoice-dollar', label: 'Cotizaciones' },
|
||||
{ href: '/cursos', icon: 'fas fa-book', label: 'Cursos' },
|
||||
{ href: '/alumnos', icon: 'fas fa-user-graduate', label: 'Alumnos' },
|
||||
{ href: '/arqueo', icon: 'fas fa-cash-register', label: 'Arqueo' },
|
||||
{ href: '/empresas', icon: 'fas fa-building', label: 'Empresas' },
|
||||
{ href: '/reportes/ventas', icon: 'fas fa-chart-bar', label: 'Reportes' },
|
||||
{ href: '/autorizador', icon: 'fas fa-check-double', label: 'Autorizador' },
|
||||
{ href: '/contacto', icon: 'fas fa-envelope', label: 'Contacto' },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="logo-details">
|
||||
<img className="icon" src="/img/favicon.png" width="60" alt="SAM" />
|
||||
<div className="logo_name">SAM</div>
|
||||
<i className="bx bx-menu" id="btn" onClick={() => setCollapsed(!collapsed)}></i>
|
||||
</div>
|
||||
<ul className="nav-list">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.href} className={pathname.startsWith(item.href) ? 'active' : ''}>
|
||||
<Link href={item.href}>
|
||||
<i className={item.icon}></i>
|
||||
<span className="links_name">{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
interface User {
|
||||
nombre: string;
|
||||
sede?: string;
|
||||
perfil?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (rut: string, clave: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({} as AuthContextType);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem('sam_user');
|
||||
if (stored) {
|
||||
setUser(JSON.parse(stored));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (rut: string, clave: string) => {
|
||||
const res = await api.post<{ nombre: string; sede?: string; token: string }>('/auth/login', { rut, clave });
|
||||
sessionStorage.setItem('sam_user', JSON.stringify({ nombre: res.nombre, sede: res.sede }));
|
||||
setUser({ nombre: res.nombre, sede: res.sede });
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem('sam_user');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export function useInactividad(onTimeout: () => void) {
|
||||
const timerRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const resetTimer = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(onTimeout, TIMEOUT_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||
resetTimer();
|
||||
|
||||
events.forEach(event => window.addEventListener(event, resetTimer));
|
||||
return () => {
|
||||
events.forEach(event => window.removeEventListener(event, resetTimer));
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [onTimeout]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', maximumFractionDigits: 0 }).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('es-CL', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function classNames(...classes: (string | boolean | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function validarRut(rut: string): boolean {
|
||||
const limpio = rut.replace(/\./g, '').replace(/-/g, '').toUpperCase();
|
||||
if (!/^[0-9]+[0-9K]$/.test(limpio)) return false;
|
||||
|
||||
const cuerpo = limpio.slice(0, -1);
|
||||
const dv = limpio.slice(-1);
|
||||
|
||||
let suma = 0;
|
||||
let multiplo = 2;
|
||||
|
||||
for (let i = cuerpo.length - 1; i >= 0; i--) {
|
||||
suma += parseInt(cuerpo[i]) * multiplo;
|
||||
multiplo = multiplo < 7 ? multiplo + 1 : 2;
|
||||
}
|
||||
|
||||
const dvEsperado = 11 - (suma % 11);
|
||||
const dvChar = dvEsperado === 11 ? '0' : dvEsperado === 10 ? 'K' : String(dvEsperado);
|
||||
|
||||
return dvChar === dv;
|
||||
}
|
||||
|
||||
export function formatRut(rut: string): string {
|
||||
const limpio = rut.replace(/\./g, '').replace(/-/g, '');
|
||||
if (limpio.length < 2) return limpio;
|
||||
const cuerpo = limpio.slice(0, -1);
|
||||
const dv = limpio.slice(-1);
|
||||
const formateado = cuerpo.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
return `${formateado}-${dv}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5087/api';
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
params?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, params } = options;
|
||||
|
||||
let url = `${API_URL}${endpoint}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => searchParams.append(k, String(v)));
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, params?: Record<string, string | number | boolean>) =>
|
||||
request<T>(endpoint, { params }),
|
||||
|
||||
post: <T>(endpoint: string, body?: unknown) =>
|
||||
request<T>(endpoint, { method: 'POST', body }),
|
||||
|
||||
put: <T>(endpoint: string, body?: unknown) =>
|
||||
request<T>(endpoint, { method: 'PUT', body }),
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
@import url('https://fonts.googleapis.com/css?family=Fira+Sans&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Fira Sans', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #e9ecef;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar (replica SAM.Master) */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 78px;
|
||||
background: #11101d;
|
||||
padding: 6px 14px;
|
||||
z-index: 99;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.sidebar .logo-details {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar .logo-details .icon {
|
||||
opacity: 0;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.sidebar.open .logo-details .icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar .logo-details .logo_name {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: all 0.5s ease;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.sidebar.open .logo-details .logo_name {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar .logo-details #btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
font-size: 23px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.5s ease;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar.open .logo-details #btn {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sidebar i {
|
||||
color: #fff;
|
||||
height: 60px;
|
||||
min-width: 50px;
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
.sidebar .nav-list {
|
||||
margin-top: 20px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
transition: all 0.4s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li a:hover {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li a:hover i,
|
||||
.sidebar .nav-list li a:hover .links_name {
|
||||
color: #11101d;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li.active a {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li.active a i,
|
||||
.sidebar .nav-list li.active a .links_name {
|
||||
color: #11101d;
|
||||
}
|
||||
|
||||
.sidebar .nav-list li .links_name {
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.4s;
|
||||
}
|
||||
|
||||
.sidebar.open .nav-list li .links_name {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Home section */
|
||||
.home-section {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
left: 78px;
|
||||
width: calc(100% - 78px);
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.sidebar.open ~ .home-section {
|
||||
left: 250px;
|
||||
width: calc(100% - 250px);
|
||||
}
|
||||
|
||||
.home-section header {
|
||||
background: #fff;
|
||||
padding: 10px 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #4e73df;
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: none;
|
||||
border: 1px solid #dc3545;
|
||||
color: #dc3545;
|
||||
padding: 5px 12px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #4e73df;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card.shadow {
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58,59,69,0.15);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
}
|
||||
.sidebar.open {
|
||||
width: 200px;
|
||||
}
|
||||
.home-section {
|
||||
left: 60px;
|
||||
width: calc(100% - 60px);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user