feat: implement approval workflow interface and content management services

This commit is contained in:
Jessica Orozco 2026-06-16 15:04:14 -04:00
parent 8063a6c0b6
commit f6177fbdb5
8 changed files with 1828 additions and 692 deletions

56
doc/infohub.md Normal file
View File

@ -0,0 +1,56 @@
desarrollado los módulos completos para Info Hub y Workflows de Aprobación, unificando la lógica a través de un servicio reactivo para que la información fluya dinámicamente.
La aplicación compila al 100% sin advertencias y mantiene el estándar de alta gama (Executive Suite UI) con tarjetas interactivas, animaciones suaves y gráficas vectoriales.
Aquí tienes el detalle técnico de la implementación de nivel arquitectónico:
1. Estado Compartido y Lógica Reactiva (Signals)
Para comunicar ambos módulos sin acoplar los componentes creados, implementé un servicio core centralizado en
content.service.ts
. Este servicio actúa como la base de datos simulada en memoria y utiliza Angular Signals (signal y computed de lectura optimizada) para sincronizar en tiempo real el catálogo de comunicados y la cola de revisión:
Mantiene los comunicados ya aprobados y listos para publicarse.
Controla la cola de pendientes con niveles incrementales de validación (approvalLevel).
Provee métodos para reaccionar a likes, comentarios y compartidos en redes sociales.
2. Módulo Info Hub (CMS & Integración Social)
Construí el componente en
info-hub.component.ts
con las siguientes características premium:
Feed de Noticias: Muestra tarjetas elegantes de comunicados de forma cronológica (título, cuerpo, etiquetas de categoría y targets de redes).
Sección de Comentarios: Desplegable interactivo por artículo para leer aportes y agregar nuevos en tiempo real.
Módulo de Redes Sociales: Widgets estilo "Executive Suite" que muestran el alcance simulado en LinkedIn, X y la Intranet.
Analíticas: Un gráfico de líneas SVG fluido y responsivo que proyecta el alcance de contenido en las últimas semanas (sin requerir librerías pesadas externas).
Modal de Compartido (Glassmorphism): Al hacer clic en "Compartir", se despliega una interfaz translúcida que permite seleccionar las redes objetivo (LinkedIn, X, Intranet) y simular el incremento del alcance orgánico.
3. Módulo Workflows (Formulario & Motor de Aprobación BPM)
Creé el componente en
workflows.component.ts
enfocado en la administración de publicaciones:
Formulario de Redacción: Permite escribir nuevos anuncios, asignarlos a categorías (Tecnología, RR.HH., Operaciones, Seguridad) y marcar los canales objetivo donde se desea propagar.
Cola de Aprobaciones Jerárquicas: Lista de forma interactiva las propuestas en curso.
Nivel 1 (Amarillo): Revisión Técnica.
Nivel 2 (Púrpura): Autorización de Recursos Humanos.
Si se rechaza, se elimina de la cola. Si se aprueba en el Nivel 2, el artículo se publica automáticamente en el Info Hub, desapareciendo de la cola del motor BPM y actualizando la lista global al instante.
Historial Rápido: Una sección lateral para auditar los últimos artículos aprobados con un enlace rápido para ir a visualizarlos en el portal general.
4. Rutas y Cableado de Navegación
Registré las dos nuevas rutas lazy-loaded (info-hub y workflows) en
app.routes.ts
, protegiéndolas bajo el guardia de seguridad Active Directory (authGuard).
Modifiqué el archivo
dashboard.component.ts
(las tarjetas del menú inicial) para sustituir las pestañas locales anteriores por navegación de rutas reales a través de this.router.navigate(...).
Al hacer clic en la tarjeta de Info Hub, te redirige a /info-hub.
Al hacer clic en la tarjeta de Workflows, te redirige a /workflows.
Cómo probar el flujo completo:
Ve al panel de inicio de la Intranet y haz clic en la tarjeta de Workflows.
Escribe una nueva propuesta (ej: "Plan de Capacitación Angular 18"), selecciona canales de redes y haz clic en "Enviar a Flujo de Aprobación".
En la lista de aprobaciones a la derecha, verás tu post en amarillo (Nivel 1). Haz clic en "Avanzar a Nivel 2". El post cambiará de color a púrpura (Nivel 2).
Vuelve a hacer clic en "Aprobar y Publicar". El post se eliminará de la cola BPM y se posteará de forma automatizada.
Haz clic en el enlace "Ver en Info Hub" de abajo o haz clic en el logo de la Intranet arriba (que te llevará de vuelta al Dashboard) e ingresa al Info Hub.
¡Verás tu nuevo post listo en el feed general! Puedes darle a ❤️, abrir los comentarios para escribir tu opinión, o presionar el botón 🔗 para simular compartirlo en LinkedIn o X.

View File

@ -21,6 +21,16 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('./features/directorio/directorio.component').then(m => m.DirectorioComponent) loadComponent: () => import('./features/directorio/directorio.component').then(m => m.DirectorioComponent)
}, },
{
path: 'info-hub',
canActivate: [authGuard],
loadComponent: () => import('./features/info-hub/info-hub.component').then(m => m.InfoHubComponent)
},
{
path: 'workflows',
canActivate: [authGuard],
loadComponent: () => import('./features/workflows/workflows.component').then(m => m.WorkflowsComponent)
},
{ {
path: 'reservas', path: 'reservas',
canActivate: [authGuard], canActivate: [authGuard],

View File

@ -0,0 +1,178 @@
import { Injectable, signal, computed } from '@angular/core';
export interface Article {
id: number;
title: string;
category: string;
body: string;
author: string;
date: string;
status: 'pending' | 'approved' | 'rejected';
approvalLevel: number; // 1 = Tech Review, 2 = HR Review, 3 = Approved
likes: number;
shares: number;
commentsCount: number;
shareTargets: string[];
comments: string[];
}
@Injectable({
providedIn: 'root'
})
export class ContentService {
private currentId = 5;
// Signal for published articles (visible in Info Hub)
private _articles = signal<Article[]>([
{
id: 1,
title: 'Lanzamiento de la Nueva Intranet Corporativa',
category: 'Tecnología',
body: 'Hoy lanzamos oficialmente nuestra nueva plataforma de intranet con integración a Active Directory y un sistema unificado de reservas de espacios. ¡Optimiza tus flujos diarios desde hoy!',
author: 'Jess Miller',
date: 'Hace 2 horas',
status: 'approved',
approvalLevel: 3,
likes: 42,
shares: 12,
commentsCount: 3,
shareTargets: ['LinkedIn', 'X', 'Intranet'],
comments: [
'Excelente diseño, muy responsivo.',
'Me encanta el asistente de reservas con IA.',
'Gran trabajo a todo el equipo!'
]
},
{
id: 2,
title: 'Políticas de Trabajo Híbrido 2026',
category: 'Recursos Humanos',
body: 'Actualizamos las directrices de trabajo híbrido. Los equipos podrán acordar hasta 3 días de trabajo remoto a la semana, coordinando la ocupación de puestos a través de la autogestión de espacios.',
author: 'Dirección de RR.HH.',
date: 'Ayer',
status: 'approved',
approvalLevel: 3,
likes: 85,
shares: 24,
commentsCount: 2,
shareTargets: ['LinkedIn', 'Intranet'],
comments: [
'Gran paso adelante para la flexibilidad.',
'Muy claro el proceso de reserva de escritorios.'
]
},
{
id: 3,
title: 'Proyecto de Sostenibilidad y Oficina Verde',
category: 'Operaciones',
body: 'Iniciamos el programa Oficina Verde para reducir el consumo de papel y optimizar la climatización de salas mediante sensores IoT integrados. Coordina tus reuniones en salas eco-eficientes.',
author: 'Marta Soler (Operaciones)',
date: 'Hace 3 días',
status: 'approved',
approvalLevel: 3,
likes: 56,
shares: 8,
commentsCount: 1,
shareTargets: ['X', 'Intranet'],
comments: [
'¡Apoyo total a esta iniciativa!'
]
}
]);
// Signal for pending approvals (visible in Workflow Module)
private _pending = signal<Article[]>([
{
id: 4,
title: 'Manual de Seguridad de Datos para Desarrolladores',
category: 'Seguridad',
body: 'Documento técnico con las directrices de codificación segura, rotación de secretos en Azure y cumplimiento con OWASP Top 10 para APIs REST.',
author: 'David Chen',
date: 'Hace 1 hora',
status: 'pending',
approvalLevel: 1, // 1st level approval needed
likes: 0,
shares: 0,
commentsCount: 0,
shareTargets: ['Intranet'],
comments: []
}
]);
articles = computed(() => this._articles());
pending = computed(() => this._pending());
addPending(title: string, category: string, body: string, author: string, shareTargets: string[]): void {
const newArticle: Article = {
id: this.currentId++,
title,
category,
body,
author,
date: 'Hace un momento',
status: 'pending',
approvalLevel: 1,
likes: 0,
shares: 0,
commentsCount: 0,
shareTargets,
comments: []
};
this._pending.update(prev => [newArticle, ...prev]);
}
advanceApproval(id: number): void {
let articleToMove: Article | undefined;
this._pending.update(prev => {
const items = prev.map(art => {
if (art.id === id) {
const nextLevel = art.approvalLevel + 1;
if (nextLevel > 2) {
// Reaches level 3 -> Approved & Published
articleToMove = { ...art, status: 'approved', approvalLevel: 3, date: 'Publicado hace un momento' };
return null;
}
return { ...art, approvalLevel: nextLevel };
}
return art;
});
return items.filter(art => art !== null) as Article[];
});
if (articleToMove) {
this._articles.update(prev => [articleToMove!, ...prev]);
}
}
rejectArticle(id: number): void {
this._pending.update(prev => prev.filter(art => art.id !== id));
}
likeArticle(id: number): void {
this._articles.update(prev => {
return prev.map(art => art.id === id ? { ...art, likes: art.likes + 1 } : art);
});
}
shareArticle(id: number): void {
this._articles.update(prev => {
return prev.map(art => art.id === id ? { ...art, shares: art.shares + 1 } : art);
});
}
addComment(id: number, comment: string): void {
this._articles.update(prev => {
return prev.map(art => {
if (art.id === id) {
return {
...art,
comments: [...art.comments, comment],
commentsCount: art.commentsCount + 1
};
}
return art;
});
});
}
}

View File

@ -78,7 +78,7 @@ interface UserProfile {
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:20px;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:20px;">
<!-- Card 1: Info Hub — Deep Navy #0D1B2A --> <!-- Card 1: Info Hub — Deep Navy #0D1B2A -->
<div (click)="setActiveTab('workflow')" <div (click)="navigateToInfoHub()"
style="position:relative;background:#0D1B2A;border:none;border-radius:20px;padding:40px 24px;cursor:pointer;display:flex;flex-direction:column;align-items:center;text-align:center;gap:22px;transition:all 0.22s cubic-bezier(0.34,1.56,0.64,1);overflow:hidden;min-height:270px;justify-content:center;box-shadow:0 8px 32px rgba(13,27,42,0.28);"> style="position:relative;background:#0D1B2A;border:none;border-radius:20px;padding:40px 24px;cursor:pointer;display:flex;flex-direction:column;align-items:center;text-align:center;gap:22px;transition:all 0.22s cubic-bezier(0.34,1.56,0.64,1);overflow:hidden;min-height:270px;justify-content:center;box-shadow:0 8px 32px rgba(13,27,42,0.28);">
<!-- Inner decorative border --> <!-- Inner decorative border -->
<div style="position:absolute;inset:10px;border:1px solid rgba(255,255,255,0.10);border-radius:12px;pointer-events:none;"></div> <div style="position:absolute;inset:10px;border:1px solid rgba(255,255,255,0.10);border-radius:12px;pointer-events:none;"></div>
@ -118,7 +118,7 @@ interface UserProfile {
</div> </div>
<!-- Card 3: Workflows — Deep Viridian #0A2520 --> <!-- Card 3: Workflows — Deep Viridian #0A2520 -->
<div (click)="setActiveTab('workflow')" <div (click)="navigateToWorkflows()"
style="position:relative;background:#0A2520;border:none;border-radius:20px;padding:40px 24px;cursor:pointer;display:flex;flex-direction:column;align-items:center;text-align:center;gap:22px;transition:all 0.22s cubic-bezier(0.34,1.56,0.64,1);overflow:hidden;min-height:270px;justify-content:center;box-shadow:0 8px 32px rgba(10,37,32,0.30);"> style="position:relative;background:#0A2520;border:none;border-radius:20px;padding:40px 24px;cursor:pointer;display:flex;flex-direction:column;align-items:center;text-align:center;gap:22px;transition:all 0.22s cubic-bezier(0.34,1.56,0.64,1);overflow:hidden;min-height:270px;justify-content:center;box-shadow:0 8px 32px rgba(10,37,32,0.30);">
<div style="position:absolute;inset:10px;border:1px solid rgba(255,255,255,0.08);border-radius:12px;pointer-events:none;"></div> <div style="position:absolute;inset:10px;border:1px solid rgba(255,255,255,0.08);border-radius:12px;pointer-events:none;"></div>
<div style="position:absolute;bottom:-40px;right:-40px;width:180px;height:180px;background:radial-gradient(circle,rgba(16,185,129,0.14) 0%,transparent 70%);pointer-events:none;"></div> <div style="position:absolute;bottom:-40px;right:-40px;width:180px;height:180px;background:radial-gradient(circle,rgba(16,185,129,0.14) 0%,transparent 70%);pointer-events:none;"></div>
@ -803,6 +803,14 @@ export class DashboardComponent implements OnInit {
this.router.navigate(['/directorio']); this.router.navigate(['/directorio']);
} }
navigateToInfoHub(): void {
this.router.navigate(['/info-hub']);
}
navigateToWorkflows(): void {
this.router.navigate(['/workflows']);
}
bookSpace() { bookSpace() {
this.bookingSuccess.set(true); this.bookingSuccess.set(true);
} }

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,17 @@ interface Employee {
initials: string; initials: string;
role: string; role: string;
department: string; department: string;
contractType: string;
rating: number; rating: number;
clients: number; clients: number;
rate: number; rate: number;
tags: string[]; tags: string[];
email: string; email: string;
phone: string;
joinedDate: string;
avatarColor: string; avatarColor: string;
avatarTextColor: string;
status: string;
} }
@Component({ @Component({
@ -24,187 +29,426 @@ interface Employee {
imports: [CommonModule, FormsModule, RouterModule], imports: [CommonModule, FormsModule, RouterModule],
template: ` template: `
<div class="dir-shell"> <div class="dir-shell">
<!-- ═══════════════════════════════ TIMI PEOPLE NAVBAR ═══════════════════════════════ -->
<nav class="dir-topbar">
<div class="dir-topbar__left">
<div class="dir-topbar__logo">
<span class="dir-topbar__logo-icon"></span>
<span class="dir-topbar__logo-text">App <strong>People</strong></span>
</div>
<ul class="dir-topbar__menu">
<li class="dir-topbar__menu-item dir-topbar__menu-item--active">Employee</li>
<li class="dir-topbar__menu-item">Contract</li>
<li class="dir-topbar__menu-item">Department</li>
<li class="dir-topbar__menu-item">Manpower Planning</li>
<li class="dir-topbar__menu-item">Organization Structure</li>
</ul>
</div>
<div class="dir-topbar__right">
<button class="dir-topbar__btn-add" (click)="resetFilters()">+</button>
<button class="dir-topbar__btn-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button class="dir-topbar__btn-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
</button>
<div class="dir-topbar__user-avatar">JM</div>
</div>
</nav>
<!-- ═══════════════════════════════ MAIN PANEL ═══════════════════════════════ --> <!-- ═══════════════════════════════ MAIN CONTENT ═══════════════════════════════ -->
<main class="dir-main"> <main class="dir-main">
<!-- Module Header --> <!-- Header: Employee Title and Import/Export/Add Buttons -->
<header class="dir-header"> <div class="dir-content-header">
<div class="dir-header__title-block"> <div class="dir-content-header__left">
<h1 class="dir-header__title">Directorio de Personal</h1> <h1 class="dir-content-header__title">Employee</h1>
<div class="dir-header__badge"> <div class="dir-content-header__stats">
<span class="dir-header__badge-dot"></span> <span class="dir-content-header__stat-active"> Active 28</span>
Sincronizado con Active Directory <span class="dir-content-header__stat-inactive"> Inactive 4</span>
</div> </div>
</div> </div>
<span class="dir-header__count">{{ filteredEmployees().length }} empleados</span> <div class="dir-content-header__right">
</header> <button class="dir-btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Search + Filters Bar --> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<div class="dir-filters"> <polyline points="7 10 12 15 17 10"></polyline>
<!-- Search --> <line x1="12" y1="15" x2="12" y2="3"></line>
<div class="dir-filters__search"> </svg>
<svg class="dir-filters__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none"> Import
<circle cx="11" cy="11" r="7" stroke="#78716C" stroke-width="1.8"/>
<path d="M16.5 16.5L21 21" stroke="#78716C" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<input
class="dir-filters__input"
type="text"
placeholder="Buscar por nombre, rol o habilidad..."
[(ngModel)]="searchQuery"
(ngModelChange)="onSearch($event)"
/>
@if (searchQuery) {
<button class="dir-filters__clear" (click)="clearSearch()"></button>
}
</div>
<!-- Department dropdown -->
<div class="dir-filters__select-wrap">
<select class="dir-filters__select" [(ngModel)]="selectedDept" (ngModelChange)="onDeptChange($event)">
<option value="ALL">Todos los Departamentos</option>
<option value="IT">IT & Desarrollo</option>
<option value="Diseño">Diseño y UI/UX</option>
<option value="Infraestructura">Infraestructura</option>
<option value="Seguridad">Seguridad</option>
</select>
<svg class="dir-filters__select-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 4l4 4 4-4" stroke="#78716C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- Reset pill -->
@if (selectedDept !== 'ALL' || searchQuery) {
<button class="dir-filters__reset" (click)="resetFilters()">
Limpiar filtros
</button> </button>
} <button class="dir-btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Export
</button>
<button class="dir-btn-primary">
<span class="dir-btn-primary__icon">+</span>
Add Employee
</button>
</div>
</div> </div>
<!-- Results summary --> <!-- Filters & Search Bar -->
<p class="dir-results-summary"> <div class="dir-filters-bar">
Mostrando <strong>{{ filteredEmployees().length }}</strong> de <strong>{{ employees().length }}</strong> empleados registrados <div class="dir-filters-bar__left">
</p> <div class="dir-search-input">
<svg class="dir-search-input__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="text"
placeholder="Search"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearch($event)"
class="dir-search-input__field"
/>
</div>
<div class="dir-filter-dropdown">
<button class="dir-filter-dropdown__trigger">
Type
<span class="dir-filter-dropdown__arrow"></span>
</button>
</div>
<div class="dir-filter-dropdown">
<button class="dir-filter-dropdown__trigger">
Status
<span class="dir-filter-dropdown__arrow"></span>
</button>
</div>
<div class="dir-filter-dropdown">
<button class="dir-filter-dropdown__trigger">
Role
<span class="dir-filter-dropdown__arrow"></span>
</button>
</div>
<button class="dir-btn-advanced">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
Advance Filter
</button>
</div>
<div class="dir-filters-bar__right">
<button class="dir-btn-transfer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 2.1l4 4-4 4M3 6h18M7 21.9l-4-4 4-4M21 18H3"></path>
</svg>
Transfer Employee
</button>
<div class="dir-view-toggle">
<button class="dir-view-toggle__btn dir-view-toggle__btn--active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</button>
<button class="dir-view-toggle__btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
</div>
</div>
<!-- Employee Grid --> <!-- Employee Grid -->
<div class="dir-grid"> <div class="dir-grid">
@for (emp of filteredEmployees(); track emp.id) { @for (emp of filteredEmployees(); track emp.id) {
<article class="dir-card"> <article class="dir-card">
<!-- Top bar with Active badge & Options -->
<!-- AD ID chip (monospace, top-right) --> <div class="dir-card__top">
<div class="dir-card__ad-id">{{ emp.adId }}</div> <span class="dir-card__status-badge"
[ngClass]="{
<!-- Avatar + Rating row --> 'dir-card__status-badge--active': emp.status === 'Active',
<div class="dir-card__avatar-row"> 'dir-card__status-badge--invited': emp.status === 'Invited'
<div class="dir-card__avatar" [style.background]="emp.avatarColor"> }">
{{ emp.initials }} {{ emp.status }}
</div> </span>
<div class="dir-card__rating"> <button class="dir-card__options">···</button>
<svg width="11" height="11" viewBox="0 0 12 12" fill="#C9973C">
<path d="M6 1l1.39 2.82L10.5 4.27l-2.25 2.19.53 3.1L6 8.1l-2.78 1.46.53-3.1L1.5 4.27l3.11-.45L6 1z"/>
</svg>
<span>{{ emp.rating }}</span>
</div>
</div> </div>
<!-- Name + Role --> <!-- Avatar & Identity -->
<div class="dir-card__identity"> <div class="dir-card__body">
<div class="dir-card__avatar-container">
<div class="dir-card__avatar" [style.background]="emp.avatarColor" [style.color]="emp.avatarTextColor">
{{ emp.initials }}
</div>
</div>
<h3 class="dir-card__name">{{ emp.name }}</h3> <h3 class="dir-card__name">{{ emp.name }}</h3>
<span class="dir-card__role">{{ emp.role }}</span> <span class="dir-card__role">{{ emp.role }}</span>
<span class="dir-card__dept">{{ emp.department }}</span>
</div>
<!-- Tags -->
<div class="dir-card__tags">
@for (tag of emp.tags; track tag) {
<span class="dir-card__tag">{{ tag }}</span>
}
</div> </div>
<!-- Divider --> <!-- Divider -->
<div class="dir-card__divider"></div> <div class="dir-card__divider"></div>
<!-- Metrics --> <!-- Detailed Info Block -->
<div class="dir-card__metrics"> <div class="dir-card__details">
<div class="dir-card__metric"> <div class="dir-card__detail-item">
<span class="dir-card__metric-label">Clientes</span> <span class="dir-card__detail-label">#{{ emp.adId }}</span>
<span class="dir-card__metric-value">{{ emp.clients }}</span>
</div> </div>
<div class="dir-card__metric-sep"></div>
<div class="dir-card__metric"> <div class="dir-card__detail-item">
<span class="dir-card__metric-label">Tarifa/Hora</span> <svg class="dir-card__detail-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<span class="dir-card__metric-value">\${{ emp.rate }}</span> <rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
<span class="dir-card__detail-text">{{ emp.department }} - {{ emp.contractType }}</span>
</div>
<div class="dir-card__detail-item">
<svg class="dir-card__detail-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<span class="dir-card__detail-text text-truncate">{{ emp.email }}</span>
</div>
<div class="dir-card__detail-item">
<svg class="dir-card__detail-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
<span class="dir-card__detail-text">{{ emp.phone }}</span>
</div> </div>
</div> </div>
<!-- CTA --> <!-- Divider -->
<button class="dir-card__cta" (click)="contact(emp)"> <div class="dir-card__divider"></div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
Get In Touch
</button>
<!-- Footer info -->
<div class="dir-card__footer">
<span class="dir-card__joined">Joined on {{ emp.joinedDate }}</span>
<button class="dir-card__btn-details" (click)="contact(emp)">
View details &gt;
</button>
</div>
</article> </article>
} }
<!-- Empty state -->
@if (filteredEmployees().length === 0) {
<div class="dir-empty">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="22" cy="22" r="14" stroke="#D4C5A9" stroke-width="2"/>
<path d="M32 32L42 42" stroke="#D4C5A9" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<p>Sin resultados para <strong>"{{ searchQuery }}"</strong></p>
<button (click)="resetFilters()">Limpiar búsqueda</button>
</div>
}
</div> </div>
</main> <!-- Empty state -->
</div> @if (filteredEmployees().length === 0) {
<div class="dir-empty">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="22" cy="22" r="14"></circle>
<line x1="32" y1="32" x2="42" y2="42"></line>
</svg>
<p class="dir-empty__text">No se encontraron resultados para <strong>"{{ searchQuery }}"</strong></p>
<button (click)="resetFilters()" class="dir-empty__btn">Limpiar filtros</button>
</div>
}
<!-- Contact toast --> </main>
@if (contactToast()) {
<div class="dir-toast"> <!-- Contact toast -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"> @if (contactToast()) {
<path d="M20 6L9 17l-5-5" stroke="#065F46" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <div class="dir-toast">
</svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
Mensaje enviado a {{ contactToast() }} <polyline points="20 6 9 17 4 12"></polyline>
</div> </svg>
} Sincronizando canal AD para <strong>{{ contactToast() }}</strong>...
</div>
}
</div>
`, `,
styleUrls: ['./directorio.component.css'] styleUrls: ['./directorio.component.css']
}) })
export class DirectorioComponent implements OnInit { export class DirectorioComponent implements OnInit {
readonly router = inject(Router);
contactToast = signal(''); contactToast = signal('');
searchQuery = ''; searchQuery = '';
selectedDept = 'ALL';
private _searchSignal = signal(''); private _searchSignal = signal('');
private _deptSignal = signal('ALL');
employees = signal<Employee[]>([]); employees = signal<Employee[]>([
{
id: 1,
adId: 'EMP01',
name: 'Bagus Fikri',
initials: 'BF',
role: 'CEO',
department: 'Managerial',
contractType: 'Fulltime',
rating: 4.9,
clients: 85,
rate: 150,
tags: ['Leadership', 'Strategy', 'Active Directory'],
email: 'bagusfikri@gmail.com',
phone: '+62 123 456 789',
joinedDate: '29 Oct, 2020',
avatarColor: '#DEF7EC',
avatarTextColor: '#03543F',
status: 'Active'
},
{
id: 2,
adId: 'EMP02',
name: 'Indrapm',
initials: 'IP',
role: 'Illustrator',
department: 'Design',
contractType: 'Fulltime',
rating: 4.8,
clients: 64,
rate: 65,
tags: ['Illustration', 'Vector', 'Figma'],
email: 'indrapm@gmail.com',
phone: '+62 765 432 109',
joinedDate: '1 Feb, 2019',
avatarColor: '#E1EFFE',
avatarTextColor: '#1E429F',
status: 'Active'
},
{
id: 3,
adId: 'EMP03',
name: 'Mufti Hidayat',
initials: 'MH',
role: 'Project Manager',
department: 'Managerial',
contractType: 'Fulltime',
rating: 4.7,
clients: 42,
rate: 90,
tags: ['Agile', 'Scrum', 'Planning'],
email: 'mufti@gmail.com',
phone: '+62 112 233 445',
joinedDate: '15 Feb, 2022',
avatarColor: '#FDF2F2',
avatarTextColor: '#9B1C1C',
status: 'Active'
},
{
id: 4,
adId: 'EMP04',
name: 'Fauzan Arditianyah',
initials: 'FA',
role: 'UI Designer',
department: 'Design',
contractType: 'Fulltime',
rating: 4.9,
clients: 75,
rate: 80,
tags: ['UI Design', 'Wireframing', 'Prototyping'],
email: 'fauzan@gmail.com',
phone: '+62 998 877 665',
joinedDate: '22 Sep, 2018',
avatarColor: '#F0F5FF',
avatarTextColor: '#5850EC',
status: 'Active'
},
{
id: 5,
adId: 'EMP05',
name: 'Raihan Fikri',
initials: 'RF',
role: 'QC & Research',
department: 'Human Resources',
contractType: 'Fulltime',
rating: 4.6,
clients: 20,
rate: 70,
tags: ['Quality', 'Testing', 'Research'],
email: 'raihan@gmail.com',
phone: '+62 555 666 777',
joinedDate: '10 Oct, 2020',
avatarColor: '#EDEBFE',
avatarTextColor: '#5521B5',
status: 'Invited'
},
{
id: 6,
adId: 'EMP06',
name: 'Panji Dwi',
initials: 'PD',
role: 'UI Designer',
department: 'Design',
contractType: 'Fulltime',
rating: 4.8,
clients: 58,
rate: 75,
tags: ['UI/UX', 'Figma', 'Responsive'],
email: 'panji@gmail.com',
phone: '+62 333 444 555',
joinedDate: '5 May, 2021',
avatarColor: '#E3F2FD',
avatarTextColor: '#0D47A1',
status: 'Invited'
},
{
id: 7,
adId: 'EMP07',
name: 'Bagas',
initials: 'B',
role: 'UI Designer',
department: 'Design',
contractType: 'Fulltime',
rating: 4.8,
clients: 60,
rate: 75,
tags: ['UI Design', 'Design Systems'],
email: 'bagas@gmail.com',
phone: '+62 888 999 000',
joinedDate: '12 Aug, 2022',
avatarColor: '#FCE8E6',
avatarTextColor: '#C53030',
status: 'Active'
},
{
id: 8,
adId: 'EMP08',
name: 'Luqito Raymorley',
initials: 'LR',
role: 'Project Manager',
department: 'Administration',
contractType: 'Fulltime',
rating: 4.9,
clients: 90,
rate: 110,
tags: ['Management', 'Delivery', 'Budgets'],
email: 'luqito@gmail.com',
phone: '+62 444 333 222',
joinedDate: '30 Nov, 2017',
avatarColor: '#E8F5E9',
avatarTextColor: '#1B5E20',
status: 'Active'
}
]);
filteredEmployees = computed(() => { filteredEmployees = computed(() => {
const q = this._searchSignal().toLowerCase().trim(); const q = this._searchSignal().toLowerCase().trim();
const dept = this._deptSignal();
return this.employees().filter(emp => { return this.employees().filter(emp => {
const matchesSearch = !q || return !q ||
emp.name.toLowerCase().includes(q) || emp.name.toLowerCase().includes(q) ||
emp.role.toLowerCase().includes(q) || emp.role.toLowerCase().includes(q) ||
emp.tags.some(t => t.toLowerCase().includes(q)); emp.department.toLowerCase().includes(q) ||
emp.adId.toLowerCase().includes(q);
const matchesDept = dept === 'ALL' ||
emp.department === dept;
return matchesSearch && matchesDept;
}); });
}); });
@ -214,10 +458,6 @@ export class DirectorioComponent implements OnInit {
this._searchSignal.set(value); this._searchSignal.set(value);
} }
onDeptChange(value: string): void {
this._deptSignal.set(value);
}
clearSearch(): void { clearSearch(): void {
this.searchQuery = ''; this.searchQuery = '';
this._searchSignal.set(''); this._searchSignal.set('');
@ -225,9 +465,7 @@ export class DirectorioComponent implements OnInit {
resetFilters(): void { resetFilters(): void {
this.searchQuery = ''; this.searchQuery = '';
this.selectedDept = 'ALL';
this._searchSignal.set(''); this._searchSignal.set('');
this._deptSignal.set('ALL');
} }
contact(emp: Employee): void { contact(emp: Employee): void {

View File

@ -0,0 +1,344 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ContentService, Article } from '../../core/services/content.service';
@Component({
selector: 'app-info-hub',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="flex flex-col gap-6 p-2 lg:p-6 min-h-[85vh] animate-fadeIn">
<!-- Module Header -->
<header class="flex justify-between items-center flex-wrap gap-4 border-b border-black/5 pb-4">
<div>
<h1 class="text-3xl font-black text-slate-900 uppercase tracking-tight">Info Hub</h1>
<p class="text-sm text-slate-500 font-medium">Información corporativa centralizada e integración de redes sociales</p>
</div>
<div class="flex gap-3 text-xs">
<span class="bg-indigo-50 border border-indigo-100 px-3 py-1.5 rounded-full text-indigo-600 font-bold flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-indigo-500 animate-pulse"></span>
Canal Interno Activo
</span>
</div>
</header>
<!-- Grid Layout -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Left 2 columns: News Feed -->
<div class="xl:col-span-2 flex flex-col gap-6">
<div class="flex justify-between items-center">
<h2 class="text-lg font-bold text-slate-800 tracking-tight">Comunicados y Publicaciones Recientes</h2>
<span class="text-xs font-semibold text-slate-400 font-mono">{{ contentService.articles().length }} artículos publicados</span>
</div>
<!-- Feed Grid -->
<div class="flex flex-col gap-6">
@for (art of contentService.articles(); track art.id) {
<!-- Article Card (Jowel style / Glassmorphic look in light mode) -->
<article class="bg-white border border-slate-200/80 rounded-3xl p-6 shadow-sm hover:shadow-md transition-all duration-300 relative flex flex-col gap-4">
<!-- Category tag & date -->
<div class="flex justify-between items-center text-xs">
<span class="px-3 py-1 rounded-full bg-slate-100 text-slate-600 font-bold uppercase tracking-wider text-[9px]">
{{ art.category }}
</span>
<span class="text-slate-400 font-medium">{{ art.date }}</span>
</div>
<!-- Title and body -->
<div class="flex flex-col gap-2">
<h3 class="text-xl font-extrabold text-slate-900 leading-snug">{{ art.title }}</h3>
<p class="text-sm text-slate-600 leading-relaxed font-light">{{ art.body }}</p>
</div>
<!-- Share targets preview -->
<div class="flex items-center gap-2 flex-wrap text-[10px] text-slate-400">
<span class="font-bold uppercase tracking-wider">Publicado en:</span>
@for (t of art.shareTargets; track t) {
<span class="bg-slate-50 border border-slate-200 px-2 py-0.5 rounded text-slate-600 font-mono">
{{ t }}
</span>
}
</div>
<hr class="border-slate-100" />
<!-- Card Actions (Likes, comments, shares) -->
<div class="flex justify-between items-center flex-wrap gap-4 text-xs font-bold">
<div class="flex items-center gap-4">
<!-- Like button -->
<button (click)="contentService.likeArticle(art.id)" class="flex items-center gap-1.5 text-slate-500 hover:text-red-500 transition-colors py-1 px-2.5 rounded-lg hover:bg-red-50">
<span></span>
<span>{{ art.likes }}</span>
</button>
<!-- Comments trigger -->
<button (click)="toggleComments(art.id)" class="flex items-center gap-1.5 text-slate-500 hover:text-blue-500 transition-colors py-1 px-2.5 rounded-lg hover:bg-blue-50">
<span>💬</span>
<span>{{ art.commentsCount }}</span>
</button>
</div>
<!-- Share trigger button -->
<button (click)="openShareModal(art)" class="flex items-center gap-1.5 bg-slate-900 hover:bg-amber-600 text-white hover:text-white px-4 py-2 rounded-2xl transition-all duration-300 font-bold uppercase tracking-wider text-[10px]">
<span>🔗 Compartir</span>
</button>
</div>
<!-- Expanded Comments Section -->
@if (commentsExpanded(art.id)) {
<div class="mt-2 p-4 bg-slate-50 rounded-2xl flex flex-col gap-3 animate-fadeIn">
<h4 class="text-xs font-bold text-slate-500 uppercase tracking-widest">Comentarios ({{ art.comments.length }})</h4>
<div class="flex flex-col gap-2.5 max-h-48 overflow-y-auto pr-1">
@for (com of art.comments; track com) {
<div class="text-xs bg-white p-2.5 border border-slate-200/50 rounded-xl leading-relaxed">
<span class="font-semibold text-slate-700 block mb-0.5">Colaborador</span>
<span class="text-slate-600 font-light">{{ com }}</span>
</div>
}
</div>
<!-- Add Comment Form -->
<div class="flex gap-2 mt-1">
<input
type="text"
placeholder="Escribe un comentario..."
[(ngModel)]="newComments[art.id]"
(keyup.enter)="submitComment(art.id)"
class="flex-grow px-4 py-2 border border-slate-200 rounded-full text-xs outline-none focus:border-slate-400 bg-white" />
<button (click)="submitComment(art.id)" class="px-4 py-2 bg-slate-900 text-white rounded-full text-xs font-bold hover:bg-slate-800 transition">
Enviar
</button>
</div>
</div>
}
</article>
}
</div>
</div>
<!-- Right column: Social Integration and Reach Charts -->
<div class="flex flex-col gap-6">
<h2 class="text-lg font-bold text-slate-800 tracking-tight">Redes Sociales & Impacto</h2>
<!-- Social Networks Reach Widget (Navy design style) -->
<div class="relative bg-[#0D1B2A] rounded-3xl p-6 text-white overflow-hidden shadow-lg border border-white/5 flex flex-col gap-5 min-h-[220px]">
<div class="absolute inset-10 border border-white/10 rounded-2xl pointer-events-none"></div>
<div class="absolute -top-10 -right-10 w-36 h-36 bg-radial-gradient from-amber-500/10 to-transparent blur-xl pointer-events-none"></div>
<span class="text-amber-500 font-bold uppercase tracking-widest text-[9px]">Métricas Globales</span>
<div class="flex justify-between items-end">
<div>
<h3 class="text-3xl font-black tracking-tight leading-none">12,450</h3>
<span class="text-[10px] text-white/50 font-bold uppercase tracking-widest mt-1 block">Impresiones Totales</span>
</div>
<span class="bg-emerald-500/20 text-emerald-400 font-bold text-xs px-2.5 py-0.5 rounded-full">+24% este mes</span>
</div>
<hr class="border-white/10" />
<div class="grid grid-cols-2 gap-4 text-center">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<span class="text-[9px] text-white/40 font-bold uppercase tracking-wider block mb-1">LinkedIn</span>
<span class="text-lg font-extrabold text-white">8.4k reach</span>
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<span class="text-[9px] text-white/40 font-bold uppercase tracking-wider block mb-1">X (Twitter)</span>
<span class="text-lg font-extrabold text-white">4.0k reach</span>
</div>
</div>
</div>
<!-- Reach Projection SVG Chart (Plum style) -->
<div class="relative bg-[#1A0E2E] rounded-3xl p-6 text-white overflow-hidden shadow-lg border border-white/5 flex flex-col gap-4">
<div class="flex justify-between items-center">
<h3 class="text-xs font-bold uppercase tracking-widest text-purple-300">Alcance de Contenidos</h3>
<span class="text-[10px] font-mono text-purple-400 font-bold bg-purple-500/15 px-2 py-0.5 rounded-full">KPI Activo</span>
</div>
<p class="text-xs text-white/50 font-light">Visualización del crecimiento e impacto de comunicados internos.</p>
<!-- Pure SVG Line Chart -->
<div class="w-full h-32 mt-2">
<svg class="w-full h-full" viewBox="0 0 400 150" preserveAspectRatio="none">
<defs>
<linearGradient id="chartGradInfo" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#C9973C" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#C9973C" stop-opacity="0.0"/>
</linearGradient>
</defs>
<line x1="40" y1="120" x2="380" y2="120" stroke="rgba(255,255,255,0.06)" stroke-width="1" />
<line x1="40" y1="75" x2="380" y2="75" stroke="rgba(255,255,255,0.06)" stroke-width="1" />
<line x1="40" y1="30" x2="380" y2="30" stroke="rgba(255,255,255,0.06)" stroke-width="1" />
<path d="M 40,120 Q 120,110 200,60 T 360,25 L 360,130 L 40,130 Z" fill="url(#chartGradInfo)" />
<path d="M 40,120 Q 120,110 200,60 T 360,25" fill="none" stroke="#C9973C" stroke-width="3.5" />
<circle cx="40" cy="120" r="4.5" fill="#C9973C" />
<circle cx="200" cy="60" r="4.5" fill="#C9973C" />
<circle cx="360" cy="25" r="5" fill="#E9D5FF" />
</svg>
</div>
<div class="flex justify-between text-[9px] text-white/40 font-mono mt-1">
<span>Semana 1</span>
<span>Semana 2</span>
<span>Semana 3</span>
<span>Semana 4</span>
</div>
</div>
<!-- Social Sharing Activity Feed (Viridian style) -->
<div class="relative bg-[#0A2520] rounded-3xl p-6 text-white overflow-hidden shadow-lg border border-white/5 flex flex-col gap-4">
<h3 class="text-xs font-bold uppercase tracking-widest text-emerald-400">Canales de Redes Sociales</h3>
<div class="flex flex-col gap-3">
<div class="flex justify-between items-center text-xs p-3 rounded-2xl bg-white/5 border border-white/5">
<div class="flex flex-col">
<span class="font-bold">Campañas en LinkedIn</span>
<span class="text-[10px] text-emerald-300/70">Automatización de posts habilitada</span>
</div>
<span class="w-2.5 h-2.5 rounded-full bg-emerald-400 animate-pulse"></span>
</div>
<div class="flex justify-between items-center text-xs p-3 rounded-2xl bg-white/5 border border-white/5">
<div class="flex flex-col">
<span class="font-bold">API Integrada de X</span>
<span class="text-[10px] text-emerald-300/70">Publicación inmediata activa</span>
</div>
<span class="w-2.5 h-2.5 rounded-full bg-emerald-400 animate-pulse"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Sharing Modal Overlay (Glassmorphism) -->
@if (activeShareModal()) {
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center z-50 animate-fadeIn" (click)="closeShareModal()">
<div class="bg-white border border-slate-200 w-full max-w-lg p-6 rounded-3xl shadow-2xl flex flex-col gap-5 m-4 animate-scaleUp" (click)="$event.stopPropagation()">
<div class="flex justify-between items-center">
<h3 class="text-lg font-black text-slate-900 uppercase tracking-tight">Compartir Publicación</h3>
<button (click)="closeShareModal()" class="text-slate-400 hover:text-slate-700 text-lg"></button>
</div>
<p class="text-xs text-slate-500 font-light">Selecciona los destinos de red social donde deseas propagar este artículo corporativo:</p>
<div class="bg-slate-50 p-4 border border-slate-100 rounded-2xl">
<strong class="text-sm text-slate-800 block mb-1">{{ activeShareModal()?.title }}</strong>
<p class="text-xs text-slate-500 line-clamp-3 leading-relaxed">{{ activeShareModal()?.body }}</p>
</div>
<!-- Social check options -->
<div class="flex flex-col gap-3">
<label class="flex items-center gap-3 p-3 border border-slate-150 rounded-2xl bg-white hover:bg-slate-50 cursor-pointer text-xs font-semibold text-slate-700 transition">
<input type="checkbox" [(ngModel)]="shareLinkedIn" class="w-4 h-4 accent-indigo-600 rounded" />
<span>🔗 Publicar en <strong>LinkedIn corporativo</strong></span>
</label>
<label class="flex items-center gap-3 p-3 border border-slate-150 rounded-2xl bg-white hover:bg-slate-50 cursor-pointer text-xs font-semibold text-slate-700 transition">
<input type="checkbox" [(ngModel)]="shareX" class="w-4 h-4 accent-indigo-600 rounded" />
<span>🔗 Publicar en <strong>X (Twitter) oficial</strong></span>
</label>
<label class="flex items-center gap-3 p-3 border border-slate-150 rounded-2xl bg-white hover:bg-slate-50 cursor-pointer text-xs font-semibold text-slate-700 transition">
<input type="checkbox" [(ngModel)]="shareIntranet" class="w-4 h-4 accent-indigo-600 rounded" />
<span>🔗 Publicar en la <strong>Intranet de la Empresa</strong></span>
</label>
</div>
<!-- Modal Action Buttons -->
<div class="flex gap-4 mt-2 justify-end">
<button (click)="closeShareModal()" class="px-5 py-2.5 rounded-full border border-slate-200 text-xs font-bold hover:bg-slate-50 transition text-slate-600">
Cancelar
</button>
<button (click)="confirmShare()" class="px-5 py-2.5 rounded-full bg-slate-900 hover:bg-amber-600 text-white font-bold text-xs uppercase tracking-wider transition-all duration-300">
Publicar e Incrementar Alcance
</button>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.animate-fadeIn {
animation: fadeIn 0.35s ease-out forwards;
}
.animate-scaleUp {
animation: scaleUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleUp {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
`]
})
export class InfoHubComponent {
contentService = inject(ContentService);
// Expanded comments indices map
expandedComments = signal<{ [key: number]: boolean }>({});
newComments: { [key: number]: string } = {};
// Share modal state
activeShareModal = signal<Article | null>(null);
shareLinkedIn = true;
shareX = false;
shareIntranet = true;
toggleComments(id: number): void {
this.expandedComments.update(prev => ({
...prev,
[id]: !prev[id]
}));
}
commentsExpanded(id: number): boolean {
return !!this.expandedComments()[id];
}
submitComment(id: number): void {
const com = this.newComments[id]?.trim();
if (com) {
this.contentService.addComment(id, com);
this.newComments[id] = '';
}
}
openShareModal(art: Article): void {
this.activeShareModal.set(art);
// Reset check state
this.shareLinkedIn = art.shareTargets.includes('LinkedIn');
this.shareX = art.shareTargets.includes('X');
this.shareIntranet = art.shareTargets.includes('Intranet');
}
closeShareModal(): void {
this.activeShareModal.set(null);
}
confirmShare(): void {
const art = this.activeShareModal();
if (art) {
this.contentService.shareArticle(art.id);
// Update targets list dynamically
const newTargets: string[] = [];
if (this.shareLinkedIn) newTargets.push('LinkedIn');
if (this.shareX) newTargets.push('X');
if (this.shareIntranet) newTargets.push('Intranet');
art.shareTargets = newTargets;
this.closeShareModal();
}
}
}

View File

@ -0,0 +1,354 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ContentService, Article } from '../../core/services/content.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-workflows',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="relative bg-gradient-to-br from-[#06120F] via-[#0A2520] to-[#0D1B2A] text-white p-6 lg:p-8 rounded-[32px] border border-white/10 shadow-2xl min-h-[85vh] flex flex-col gap-8 animate-fadeIn overflow-hidden">
<!-- Ambient background glows -->
<div class="absolute -top-40 -right-40 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.01)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.01)_1px,transparent_1px)] bg-[size:32px_32px] pointer-events-none opacity-20"></div>
<!-- Module Header -->
<header class="flex justify-between items-center flex-wrap gap-4 border-b border-white/10 pb-6 relative z-10">
<div>
<h1 class="text-3xl font-black text-white uppercase tracking-wider flex items-center gap-3">
<svg class="w-8 h-8 text-emerald-400 filter drop-shadow-[0_0_8px_rgba(52,211,153,0.5)]" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="12" r="5" stroke="currentColor" stroke-width="2.5"/>
<circle cx="38" cy="12" r="5" stroke="currentColor" stroke-width="2.5"/>
<circle cx="24" cy="36" r="5" stroke="currentColor" stroke-width="2.5"/>
<path d="M15 12H33" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M10 17V28c0 2.5 2 4.5 4.5 4.5H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M38 17V28c0 2.5-2 4.5-4.5 4.5H29" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Workflows de Aprobación
</h1>
<p class="text-xs text-emerald-300/60 font-medium tracking-wide mt-1">Motor de reglas multinivel para aprobación y publicación de contenidos</p>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="bg-emerald-500/10 border border-emerald-500/30 px-4 py-2 rounded-full text-emerald-400 font-bold flex items-center gap-2 shadow-[0_0_15px_rgba(16,185,129,0.1)]">
<span class="w-2 h-2 bg-emerald-400 rounded-full animate-ping"></span>
Motor AD: Conectado
</span>
</div>
</header>
<!-- Grid Columns -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-8 relative z-10 flex-grow">
<!-- Left column: Add New Proposal Form & Engine Info -->
<div class="flex flex-col gap-6">
<h2 class="text-xs uppercase font-black tracking-widest text-emerald-400 flex items-center gap-2">
<span></span> Agregar Nueva Propuesta
</h2>
<div class="bg-black/30 backdrop-blur-md border border-white/10 rounded-3xl p-6 flex flex-col gap-5 shadow-xl">
<!-- Title Input -->
<div class="flex flex-col gap-2">
<label class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Título del Artículo</label>
<input type="text" [(ngModel)]="newTitle" placeholder="Ej. Resultados del Segundo Trimestre"
class="px-4 py-3 rounded-xl border border-white/10 outline-none text-xs w-full focus:border-emerald-500/50 bg-white/5 text-white placeholder-white/30 focus:shadow-[0_0_10px_rgba(52,211,153,0.15)] transition" />
</div>
<!-- Category Select -->
<div class="flex flex-col gap-2">
<label class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Categoría</label>
<div class="relative">
<select [(ngModel)]="newCategory"
class="px-4 py-3 rounded-xl border border-white/10 outline-none text-xs w-full focus:border-emerald-500/50 bg-[#0B1512] text-white appearance-none cursor-pointer">
<option value="Tecnología">Tecnología & Innovación</option>
<option value="Recursos Humanos">Recursos Humanos</option>
<option value="Operaciones">Operaciones</option>
<option value="Seguridad">Seguridad & Redes</option>
</select>
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 text-[10px]"></div>
</div>
</div>
<!-- Body Textarea -->
<div class="flex flex-col gap-2">
<label class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Cuerpo del Comunicado</label>
<textarea [(ngModel)]="newBody" rows="4" placeholder="Escribe el contenido del mensaje corporativo aquí..."
class="px-4 py-3 rounded-xl border border-white/10 outline-none text-xs w-full focus:border-emerald-500/50 bg-white/5 text-white placeholder-white/30 focus:shadow-[0_0_10px_rgba(52,211,153,0.15)] resize-none transition"></textarea>
</div>
<!-- Target Channels Checklist -->
<div class="flex flex-col gap-2">
<label class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Canales de Publicación Objetivo</label>
<div class="flex flex-col gap-3 mt-1">
<label class="flex items-center gap-3 text-xs text-slate-300 font-semibold cursor-pointer group">
<input type="checkbox" [(ngModel)]="targetIntranet" class="sr-only peer" />
<div class="w-5 h-5 rounded border border-white/15 bg-white/5 peer-checked:bg-emerald-500 peer-checked:border-emerald-400 flex items-center justify-center transition peer-checked:shadow-[0_0_8px_rgba(16,185,129,0.3)]">
<svg class="w-3.5 h-3.5 text-white fill-current opacity-0 peer-checked:opacity-100 transition" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z"/></svg>
</div>
<span class="group-hover:text-white transition">Intranet Corporativa</span>
</label>
<label class="flex items-center gap-3 text-xs text-slate-300 font-semibold cursor-pointer group">
<input type="checkbox" [(ngModel)]="targetLinkedIn" class="sr-only peer" />
<div class="w-5 h-5 rounded border border-white/15 bg-white/5 peer-checked:bg-emerald-500 peer-checked:border-emerald-400 flex items-center justify-center transition peer-checked:shadow-[0_0_8px_rgba(16,185,129,0.3)]">
<svg class="w-3.5 h-3.5 text-white fill-current opacity-0 peer-checked:opacity-100 transition" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z"/></svg>
</div>
<span class="group-hover:text-white transition">LinkedIn de la Empresa</span>
</label>
<label class="flex items-center gap-3 text-xs text-slate-300 font-semibold cursor-pointer group">
<input type="checkbox" [(ngModel)]="targetX" class="sr-only peer" />
<div class="w-5 h-5 rounded border border-white/15 bg-white/5 peer-checked:bg-emerald-500 peer-checked:border-emerald-400 flex items-center justify-center transition peer-checked:shadow-[0_0_8px_rgba(16,185,129,0.3)]">
<svg class="w-3.5 h-3.5 text-white fill-current opacity-0 peer-checked:opacity-100 transition" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z"/></svg>
</div>
<span class="group-hover:text-white transition">Red Social X (Twitter)</span>
</label>
</div>
</div>
<!-- Submit Button -->
<button (click)="submitProposal()" class="w-full py-3 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 text-white rounded-xl text-xs font-bold uppercase tracking-widest shadow-[0_4px_15px_rgba(16,185,129,0.25)] hover:shadow-[0_4px_20px_rgba(16,185,129,0.4)] transition-all duration-300 transform hover:-translate-y-0.5 mt-2 active:translate-y-0">
Enviar a Flujo de Aprobación
</button>
@if (formError()) {
<div class="text-[11px] text-red-400 font-bold text-center bg-red-950/30 border border-red-500/20 py-2.5 rounded-xl animate-fadeIn">{{ formError() }}</div>
}
@if (formSuccess()) {
<div class="text-[11px] text-emerald-400 font-bold text-center bg-emerald-950/30 border border-emerald-500/20 py-2.5 rounded-xl animate-fadeIn">¡Propuesta enviada con éxito al nivel de revisión 1!</div>
}
</div>
<!-- AD Rules Engine Widget (Viridian/Cyberpunk style) -->
<div class="bg-black/30 backdrop-blur-md border border-white/10 rounded-3xl p-6 flex flex-col gap-4 shadow-xl relative overflow-hidden group hover:border-emerald-500/20 transition-all duration-300">
<div class="flex justify-between items-center">
<h3 class="text-xs font-bold uppercase tracking-widest text-emerald-400">Visualización del Motor AD</h3>
<span class="text-[9px] font-mono text-emerald-400 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-full animate-pulse">Servicio Activo</span>
</div>
<p class="text-[11px] text-slate-400 leading-relaxed font-light">Visualización técnica del flujo de validación del Active Directory para jerarquías organizacionales.</p>
<!-- Gears Animation -->
<div class="flex items-center justify-center py-4 bg-white/[0.02] border border-white/[0.03] rounded-2xl">
<svg class="w-24 h-24 text-emerald-400/80 filter drop-shadow-[0_0_6px_rgba(52,211,153,0.3)]" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" stroke="currentColor" stroke-width="2" stroke-dasharray="10, 5" class="animate-spin-slow" />
<circle cx="50" cy="50" r="20" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5, 3" class="animate-spin-reverse" />
<g transform="translate(50, 50)">
<rect x="-3" y="-24" width="6" height="48" fill="currentColor" opacity="0.25" class="animate-spin-medium" />
<rect x="-24" y="-3" width="48" height="6" fill="currentColor" opacity="0.25" class="animate-spin-medium" />
</g>
<text x="50" y="53" text-anchor="middle" font-size="8" fill="#fff" font-weight="bold" class="font-mono tracking-wider opacity-90">AD ENGINE</text>
</svg>
</div>
<div class="grid grid-cols-2 gap-3 text-center text-[10px]">
<div class="bg-white/5 p-2 rounded-xl border border-white/5 flex flex-col gap-0.5">
<span class="text-slate-400 font-bold uppercase tracking-wider">Servidor AD</span>
<span class="text-emerald-400 font-bold font-mono">LDAP SECURE</span>
</div>
<div class="bg-white/5 p-2 rounded-xl border border-white/5 flex flex-col gap-0.5">
<span class="text-slate-400 font-bold uppercase tracking-wider">Latencia</span>
<span class="text-emerald-400 font-bold font-mono">1.2ms</span>
</div>
</div>
</div>
</div>
<!-- Middle & Right column: Approval Queue & Published list -->
<div class="xl:col-span-2 flex flex-col gap-6">
<div class="flex justify-between items-center flex-wrap gap-2">
<h2 class="text-xs uppercase font-black tracking-widest text-emerald-400 flex items-center gap-2">
<span>📋</span> Cola de Aprobaciones Pendientes
</h2>
<span class="text-xs font-bold text-slate-400 font-mono bg-white/5 border border-white/10 px-3 py-1 rounded-full">{{ contentService.pending().length }} solicitudes activas</span>
</div>
<!-- Pending List -->
<div class="flex flex-col gap-4">
@for (art of contentService.pending(); track art.id) {
<div class="bg-black/30 backdrop-blur-md border border-white/10 rounded-3xl p-6 shadow-xl flex flex-col gap-4 relative overflow-hidden group hover:border-white/15 transition-all duration-300">
<!-- Accent glow bar based on approval level -->
<div class="absolute left-0 top-0 bottom-0 w-2.5"
[ngClass]="{
'bg-gradient-to-b from-amber-400 to-amber-500 shadow-[2px_0_12px_rgba(245,158,11,0.4)]': art.approvalLevel === 1,
'bg-gradient-to-b from-purple-500 to-purple-600 shadow-[2px_0_12px_rgba(147,51,234,0.4)]': art.approvalLevel === 2
}">
</div>
<!-- Info Header -->
<div class="flex justify-between items-start gap-4 pl-3">
<div>
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider flex items-center gap-2">
<span>👤</span> Autor: <strong class="text-slate-300 font-semibold">{{ art.author }}</strong> · {{ art.date }}
</span>
<h3 class="text-lg font-black text-white leading-tight mt-1.5">{{ art.title }}</h3>
</div>
<!-- Approval Level Badge -->
<span class="px-3 py-1.5 rounded-full text-[9px] font-bold uppercase tracking-widest text-white shadow-sm"
[ngClass]="{
'bg-amber-500/15 border border-amber-500/30 text-amber-300': art.approvalLevel === 1,
'bg-purple-600/15 border border-purple-600/30 text-purple-300': art.approvalLevel === 2
}">
{{ art.approvalLevel === 1 ? 'Nivel 1: Revisión Técnica' : 'Nivel 2: Autorización RR.HH.' }}
</span>
</div>
<!-- Content Body -->
<p class="text-xs text-slate-300 leading-relaxed font-light pl-3 bg-white/[0.02] border border-white/[0.03] p-3.5 rounded-2xl">{{ art.body }}</p>
<!-- Share Targets -->
<div class="flex items-center gap-2 flex-wrap text-[10px] text-slate-400 pl-3">
<span class="font-bold uppercase tracking-wider text-[9px]">Destinos Objetivo:</span>
@for (t of art.shareTargets; track t) {
<span class="bg-white/5 border border-white/10 px-2.5 py-1 rounded text-slate-300 font-mono font-bold">{{ t }}</span>
}
</div>
<!-- Action buttons -->
<div class="flex gap-3 justify-end mt-2 border-t border-white/10 pt-4 pl-3">
<button (click)="contentService.rejectArticle(art.id)"
class="px-5 py-2.5 border border-white/10 hover:border-red-500/40 rounded-xl text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-red-400 hover:bg-red-500/5 transition duration-200">
Rechazar Propuesta
</button>
<button (click)="approveItem(art.id)"
[ngClass]="{
'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 shadow-[0_4px_12px_rgba(245,158,11,0.25)] hover:shadow-[0_4px_16px_rgba(245,158,11,0.4)]': art.approvalLevel === 1,
'bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-400 hover:to-purple-500 shadow-[0_4px_12px_rgba(147,51,234,0.25)] hover:shadow-[0_4px_16px_rgba(147,51,234,0.4)]': art.approvalLevel === 2
}"
class="px-5 py-2.5 text-white rounded-xl text-[10px] font-bold uppercase tracking-wider transition-all duration-300 transform hover:-translate-y-0.5 active:translate-y-0">
{{ art.approvalLevel === 1 ? 'Avanzar a Nivel 2 ➔' : 'Aprobar y Publicar ✓' }}
</button>
</div>
</div>
}
@if (contentService.pending().length === 0) {
<div class="bg-white/5 border border-white/10 border-dashed rounded-3xl p-12 text-center text-slate-400 flex flex-col items-center justify-center gap-4 animate-fadeIn">
<div class="w-16 h-16 rounded-full bg-emerald-500/10 flex items-center justify-center text-3xl text-emerald-400 border border-emerald-500/20 shadow-[0_0_15px_rgba(16,185,129,0.1)]">
🎉
</div>
<div>
<p class="text-sm font-black text-white uppercase tracking-wider">¡Cola de Solicitudes al Día!</p>
<p class="text-xs text-slate-400 mt-1">No hay propuestas pendientes para revisar en el AD Engine en este momento.</p>
</div>
</div>
}
</div>
<!-- Recently Published Feed Shortcut -->
<div class="mt-8 flex flex-col gap-6">
<h2 class="text-xs uppercase font-black tracking-widest text-emerald-400 flex items-center gap-2">
<span>📰</span> Artículos Publicados Recientemente
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
@for (art of contentService.articles().slice(0, 2); track art.id) {
<div class="bg-black/20 backdrop-blur-md border border-white/10 rounded-3xl p-6 shadow-xl flex flex-col gap-3 hover:border-emerald-500/20 transition-all duration-300">
<div class="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-emerald-400">
<span>{{ art.category }}</span>
<span class="text-emerald-500 flex items-center gap-1.5"><span class="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse"></span> Publicado</span>
</div>
<h4 class="text-base font-black text-white leading-tight">{{ art.title }}</h4>
<p class="text-xs text-slate-300 line-clamp-2 leading-relaxed font-light bg-white/[0.01] p-2.5 rounded-xl border border-white/[0.02]">{{ art.body }}</p>
<hr class="border-white/10 my-1" />
<div class="flex justify-end">
<button (click)="router.navigate(['/info-hub'])"
class="text-emerald-400 hover:text-emerald-300 font-bold uppercase tracking-wider text-[10px] flex items-center gap-1.5 transition">
Ver en Info Hub
</button>
</div>
</div>
}
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.animate-fadeIn {
animation: fadeIn 0.35s ease-out forwards;
}
.animate-spin-slow {
animation: spin 16s linear infinite;
}
.animate-spin-medium {
animation: spin 10s linear infinite;
}
.animate-spin-reverse {
animation: spin-back 8s linear infinite;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-back {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
`]
})
export class WorkflowsComponent {
contentService = inject(ContentService);
router = inject(Router);
// Form states
newTitle = '';
newCategory = 'Tecnología';
newBody = '';
targetIntranet = true;
targetLinkedIn = false;
targetX = false;
formError = signal<string>('');
formSuccess = signal<boolean>(false);
submitProposal(): void {
const t = this.newTitle.trim();
const b = this.newBody.trim();
if (!t || !b) {
this.formError.set('Por favor, completa el título y el cuerpo del comunicado.');
setTimeout(() => this.formError.set(''), 3000);
return;
}
const targets: string[] = [];
if (this.targetIntranet) targets.push('Intranet');
if (this.targetLinkedIn) targets.push('LinkedIn');
if (this.targetX) targets.push('X');
if (targets.length === 0) {
this.formError.set('Debes seleccionar al menos un canal de publicación.');
setTimeout(() => this.formError.set(''), 3000);
return;
}
this.contentService.addPending(t, this.newCategory, b, 'Jess Miller', targets);
// Clear form
this.newTitle = '';
this.newBody = '';
this.targetIntranet = true;
this.targetLinkedIn = false;
this.targetX = false;
this.formSuccess.set(true);
setTimeout(() => this.formSuccess.set(false), 3000);
}
approveItem(id: number): void {
this.contentService.advanceApproval(id);
}
}