897 lines
33 KiB
Rust
897 lines
33 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
response::Html,
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use diesel::prelude::*;
|
|
use serde::Deserialize;
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
use crate::core::bot::get_default_bot;
|
|
use crate::core::shared::schema::people::people as people_table;
|
|
use crate::core::shared::schema::{people_departments, people_teams, people_time_off};
|
|
use crate::core::shared::state::AppState;
|
|
|
|
#[derive(Queryable)]
|
|
struct PersonListRow {
|
|
id: Uuid,
|
|
first_name: String,
|
|
last_name: Option<String>,
|
|
email: Option<String>,
|
|
job_title: Option<String>,
|
|
department: Option<String>,
|
|
avatar_url: Option<String>,
|
|
is_active: bool,
|
|
}
|
|
|
|
#[derive(Queryable)]
|
|
struct PersonCardRow {
|
|
id: Uuid,
|
|
first_name: String,
|
|
last_name: Option<String>,
|
|
email: Option<String>,
|
|
job_title: Option<String>,
|
|
department: Option<String>,
|
|
avatar_url: Option<String>,
|
|
phone: Option<String>,
|
|
}
|
|
|
|
#[derive(Queryable)]
|
|
struct PersonDetailRow {
|
|
id: Uuid,
|
|
first_name: String,
|
|
last_name: Option<String>,
|
|
email: Option<String>,
|
|
phone: Option<String>,
|
|
mobile: Option<String>,
|
|
job_title: Option<String>,
|
|
department: Option<String>,
|
|
office_location: Option<String>,
|
|
avatar_url: Option<String>,
|
|
bio: Option<String>,
|
|
hire_date: Option<chrono::NaiveDate>,
|
|
is_active: bool,
|
|
last_seen_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Queryable)]
|
|
struct PersonSearchRow {
|
|
id: Uuid,
|
|
first_name: String,
|
|
last_name: Option<String>,
|
|
email: Option<String>,
|
|
job_title: Option<String>,
|
|
avatar_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct PeopleQuery {
|
|
pub department: Option<String>,
|
|
pub team_id: Option<Uuid>,
|
|
pub is_active: Option<bool>,
|
|
pub search: Option<String>,
|
|
pub limit: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SearchQuery {
|
|
pub q: Option<String>,
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
pub fn configure_people_ui_routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/api/ui/people", get(handle_people_list))
|
|
.route("/api/ui/people/count", get(handle_people_count))
|
|
.route("/api/ui/people/active-count", get(handle_active_count))
|
|
.route("/api/ui/people/cards", get(handle_people_cards))
|
|
.route("/api/ui/people/search", get(handle_people_search))
|
|
.route("/api/ui/people/:id", get(handle_person_detail))
|
|
.route("/api/ui/people/departments", get(handle_departments_list))
|
|
.route("/api/ui/people/teams", get(handle_teams_list))
|
|
.route("/api/ui/people/time-off", get(handle_time_off_list))
|
|
.route("/api/ui/people/stats", get(handle_people_stats))
|
|
.route("/api/ui/people/new", get(handle_new_person_form))
|
|
}
|
|
|
|
async fn handle_people_list(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<PeopleQuery>,
|
|
) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result: Option<Vec<PersonListRow>> = tokio::task::spawn_blocking(move || -> Option<Vec<PersonListRow>> {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
let mut db_query = people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(ref dept) = query.department {
|
|
db_query = db_query.filter(people_table::department.eq(dept));
|
|
}
|
|
|
|
if let Some(is_active) = query.is_active {
|
|
db_query = db_query.filter(people_table::is_active.eq(is_active));
|
|
}
|
|
|
|
if let Some(ref search) = query.search {
|
|
let term = format!("%{search}%");
|
|
let term2 = term.clone();
|
|
let term3 = term.clone();
|
|
db_query = db_query.filter(
|
|
people_table::first_name.ilike(term)
|
|
.or(people_table::last_name.ilike(term2))
|
|
.or(people_table::email.ilike(term3))
|
|
);
|
|
}
|
|
|
|
db_query = db_query.order(people_table::first_name.asc());
|
|
|
|
let limit = query.limit.unwrap_or(50);
|
|
db_query = db_query.limit(limit);
|
|
|
|
db_query
|
|
.select((
|
|
people_table::id,
|
|
people_table::first_name,
|
|
people_table::last_name,
|
|
people_table::email,
|
|
people_table::job_title,
|
|
people_table::department,
|
|
people_table::avatar_url,
|
|
people_table::is_active,
|
|
))
|
|
.load::<PersonListRow>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(persons) if !persons.is_empty() => {
|
|
let mut html = String::from(
|
|
r##"<table class="people-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Job Title</th>
|
|
<th>Department</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>"##
|
|
);
|
|
|
|
for row in persons {
|
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
|
let email_str = row.email.unwrap_or_else(|| "-".to_string());
|
|
let title_str = row.job_title.unwrap_or_else(|| "-".to_string());
|
|
let dept_str = row.department.unwrap_or_else(|| "-".to_string());
|
|
let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
|
let is_active = row.is_active;
|
|
let id = row.id;
|
|
let status_class = if is_active { "status-active" } else { "status-inactive" };
|
|
let status_text = if is_active { "Active" } else { "Inactive" };
|
|
|
|
html.push_str(&format!(
|
|
r##"<tr class="person-row" data-id="{id}">
|
|
<td class="person-name">
|
|
<img src="{}" class="avatar-sm" alt="" />
|
|
<span>{}</span>
|
|
</td>
|
|
<td class="person-email">{}</td>
|
|
<td class="person-title">{}</td>
|
|
<td class="person-department">{}</td>
|
|
<td class="person-status"><span class="{}">{}</span></td>
|
|
<td class="person-actions">
|
|
<button class="btn-sm" hx-get="/api/ui/people/{id}" hx-target="#person-detail">View</button>
|
|
<button class="btn-sm btn-secondary" hx-get="/api/people/{id}/edit" hx-target="#modal-content">Edit</button>
|
|
</td>
|
|
</tr>"##,
|
|
html_escape(&avatar),
|
|
html_escape(&full_name),
|
|
html_escape(&email_str),
|
|
html_escape(&title_str),
|
|
html_escape(&dept_str),
|
|
status_class,
|
|
status_text
|
|
));
|
|
}
|
|
|
|
html.push_str("</tbody></table>");
|
|
Html(html)
|
|
}
|
|
_ => Html(
|
|
r##"<div class="empty-state">
|
|
<div class="empty-icon">👥</div>
|
|
<p>No people found</p>
|
|
<p class="empty-hint">Add people to your directory</p>
|
|
<button class="btn btn-primary" hx-get="/api/ui/people/new" hx-target="#modal-content">Add Person</button>
|
|
</div>"##.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn handle_people_cards(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<PeopleQuery>,
|
|
) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result: Option<Vec<PersonCardRow>> = tokio::task::spawn_blocking(move || -> Option<Vec<PersonCardRow>> {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
let mut db_query = people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.filter(people_table::is_active.eq(true))
|
|
.into_boxed();
|
|
|
|
if let Some(ref dept) = query.department {
|
|
db_query = db_query.filter(people_table::department.eq(dept));
|
|
}
|
|
|
|
db_query = db_query.order(people_table::first_name.asc());
|
|
|
|
let limit = query.limit.unwrap_or(20);
|
|
db_query = db_query.limit(limit);
|
|
|
|
db_query
|
|
.select((
|
|
people_table::id,
|
|
people_table::first_name,
|
|
people_table::last_name,
|
|
people_table::email,
|
|
people_table::job_title,
|
|
people_table::department,
|
|
people_table::avatar_url,
|
|
people_table::phone,
|
|
))
|
|
.load::<PersonCardRow>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(persons) if !persons.is_empty() => {
|
|
let mut html = String::from(r##"<div class="people-cards-grid">"##);
|
|
|
|
for row in persons {
|
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
|
let email_str = row.email.unwrap_or_default();
|
|
let title_str = row.job_title.unwrap_or_else(|| "Team Member".to_string());
|
|
let dept_str = row.department.unwrap_or_default();
|
|
let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
|
let phone_str = row.phone.unwrap_or_default();
|
|
let id = row.id;
|
|
|
|
html.push_str(&format!(
|
|
r##"<div class="person-card" data-id="{id}" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
|
|
<div class="card-avatar">
|
|
<img src="{}" alt="{}" class="avatar-lg" />
|
|
</div>
|
|
<div class="card-info">
|
|
<h4 class="card-name">{}</h4>
|
|
<p class="card-title">{}</p>
|
|
<p class="card-department">{}</p>
|
|
</div>
|
|
<div class="card-contact">
|
|
<span class="card-email">{}</span>
|
|
<span class="card-phone">{}</span>
|
|
</div>
|
|
</div>"##,
|
|
html_escape(&avatar),
|
|
html_escape(&full_name),
|
|
html_escape(&full_name),
|
|
html_escape(&title_str),
|
|
html_escape(&dept_str),
|
|
html_escape(&email_str),
|
|
html_escape(&phone_str)
|
|
));
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
Html(html)
|
|
}
|
|
_ => Html(
|
|
r##"<div class="empty-state">
|
|
<div class="empty-icon">👥</div>
|
|
<p>No people in directory</p>
|
|
</div>"##.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn handle_people_count(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result: Option<i64> = tokio::task::spawn_blocking(move || -> Option<i64> {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.count()
|
|
.get_result::<i64>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
Html(format!("{}", result.unwrap_or(0)))
|
|
}
|
|
|
|
async fn handle_active_count(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result: Option<i64> = tokio::task::spawn_blocking(move || -> Option<i64> {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.filter(people_table::is_active.eq(true))
|
|
.count()
|
|
.get_result::<i64>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
Html(format!("{}", result.unwrap_or(0)))
|
|
}
|
|
|
|
async fn handle_person_detail(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result: Option<PersonDetailRow> = tokio::task::spawn_blocking(move || -> Option<PersonDetailRow> {
|
|
let mut conn = pool.get().ok()?;
|
|
|
|
people_table::table
|
|
.find(id)
|
|
.select((
|
|
people_table::id,
|
|
people_table::first_name,
|
|
people_table::last_name,
|
|
people_table::email,
|
|
people_table::phone,
|
|
people_table::mobile,
|
|
people_table::job_title,
|
|
people_table::department,
|
|
people_table::office_location,
|
|
people_table::avatar_url,
|
|
people_table::bio,
|
|
people_table::hire_date,
|
|
people_table::is_active,
|
|
people_table::last_seen_at,
|
|
))
|
|
.first::<PersonDetailRow>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(row) => {
|
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
|
let email_str = row.email.unwrap_or_else(|| "-".to_string());
|
|
let phone_str = row.phone.unwrap_or_else(|| "-".to_string());
|
|
let mobile_str = row.mobile.unwrap_or_else(|| "-".to_string());
|
|
let title_str = row.job_title.unwrap_or_else(|| "-".to_string());
|
|
let dept_str = row.department.unwrap_or_else(|| "-".to_string());
|
|
let office_str = row.office_location.unwrap_or_else(|| "-".to_string());
|
|
let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
|
let bio_str = row.bio.unwrap_or_else(|| "No bio available".to_string());
|
|
let hire_str = row.hire_date.map(|d| d.format("%B %d, %Y").to_string()).unwrap_or_else(|| "-".to_string());
|
|
let last_seen_str = row.last_seen_at.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| "Never".to_string());
|
|
let is_active = row.is_active;
|
|
let id = row.id;
|
|
let status_class = if is_active { "status-active" } else { "status-inactive" };
|
|
let status_text = if is_active { "Active" } else { "Inactive" };
|
|
|
|
Html(format!(
|
|
r##"<div class="person-detail-card">
|
|
<div class="detail-header">
|
|
<img src="{}" alt="{}" class="avatar-xl" />
|
|
<div class="header-info">
|
|
<h2>{}</h2>
|
|
<p class="job-title">{}</p>
|
|
<span class="{}">{}</span>
|
|
</div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Contact Information</h4>
|
|
<div class="detail-grid">
|
|
<div class="detail-item">
|
|
<label>Email</label>
|
|
<span><a href="mailto:{}">{}</a></span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<label>Phone</label>
|
|
<span>{}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<label>Mobile</label>
|
|
<span>{}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<label>Office</label>
|
|
<span>{}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Work Information</h4>
|
|
<div class="detail-grid">
|
|
<div class="detail-item">
|
|
<label>Department</label>
|
|
<span>{}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<label>Hire Date</label>
|
|
<span>{}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<label>Last Seen</label>
|
|
<span>{}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Bio</h4>
|
|
<p class="bio-text">{}</p>
|
|
</div>
|
|
<div class="detail-actions">
|
|
<button class="btn btn-primary" hx-get="/api/people/{}/edit" hx-target="#modal-content">Edit</button>
|
|
<button class="btn btn-secondary" hx-get="/api/ui/people/{}/reports" hx-target="#reports-panel">View Reports</button>
|
|
<button class="btn btn-danger" hx-delete="/api/people/{}" hx-swap="none" hx-confirm="Deactivate this person?">Deactivate</button>
|
|
</div>
|
|
</div>"##,
|
|
html_escape(&avatar),
|
|
html_escape(&full_name),
|
|
html_escape(&full_name),
|
|
html_escape(&title_str),
|
|
status_class,
|
|
status_text,
|
|
html_escape(&email_str),
|
|
html_escape(&email_str),
|
|
html_escape(&phone_str),
|
|
html_escape(&mobile_str),
|
|
html_escape(&office_str),
|
|
html_escape(&dept_str),
|
|
hire_str,
|
|
last_seen_str,
|
|
html_escape(&bio_str),
|
|
id, id, id
|
|
))
|
|
}
|
|
None => Html(
|
|
r##"<div class="empty-state">
|
|
<p>Person not found</p>
|
|
</div>"##.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn handle_departments_list(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
people_departments::table
|
|
.filter(people_departments::bot_id.eq(bot_id))
|
|
.filter(people_departments::is_active.eq(true))
|
|
.order(people_departments::name.asc())
|
|
.select((
|
|
people_departments::id,
|
|
people_departments::name,
|
|
people_departments::description,
|
|
people_departments::code,
|
|
))
|
|
.load::<(Uuid, String, Option<String>, Option<String>)>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(depts) if !depts.is_empty() => {
|
|
let mut html = String::from(r##"<div class="departments-list">"##);
|
|
|
|
for (id, name, description, code) in depts {
|
|
let desc_str = description.unwrap_or_default();
|
|
let code_str = code.unwrap_or_default();
|
|
|
|
html.push_str(&format!(
|
|
r##"<div class="department-item" data-id="{id}">
|
|
<div class="dept-header">
|
|
<span class="dept-name">{}</span>
|
|
<span class="dept-code">{}</span>
|
|
</div>
|
|
<p class="dept-description">{}</p>
|
|
<button class="btn-sm" hx-get="/api/ui/people?department={}" hx-target="#people-list">View Members</button>
|
|
</div>"##,
|
|
html_escape(&name),
|
|
html_escape(&code_str),
|
|
html_escape(&desc_str),
|
|
html_escape(&name)
|
|
));
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
Html(html)
|
|
}
|
|
_ => Html(
|
|
r##"<div class="empty-state">
|
|
<p>No departments yet</p>
|
|
</div>"##.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn handle_teams_list(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
people_teams::table
|
|
.filter(people_teams::bot_id.eq(bot_id))
|
|
.filter(people_teams::is_active.eq(true))
|
|
.order(people_teams::name.asc())
|
|
.select((
|
|
people_teams::id,
|
|
people_teams::name,
|
|
people_teams::description,
|
|
people_teams::color,
|
|
))
|
|
.load::<(Uuid, String, Option<String>, Option<String>)>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(teams) if !teams.is_empty() => {
|
|
let mut html = String::from(r##"<div class="teams-list">"##);
|
|
|
|
for (id, name, description, color) in teams {
|
|
let desc_str = description.unwrap_or_default();
|
|
let team_color = color.unwrap_or_else(|| "#3b82f6".to_string());
|
|
|
|
html.push_str(&format!(
|
|
r##"<div class="team-item" data-id="{id}" style="border-left: 4px solid {};">
|
|
<div class="team-header">
|
|
<span class="team-name">{}</span>
|
|
</div>
|
|
<p class="team-description">{}</p>
|
|
<button class="btn-sm" hx-get="/api/ui/people?team_id={id}" hx-target="#people-list">View Members</button>
|
|
</div>"##,
|
|
team_color,
|
|
html_escape(&name),
|
|
html_escape(&desc_str)
|
|
));
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
Html(html)
|
|
}
|
|
_ => Html(
|
|
r##"<div class="empty-state">
|
|
<p>No teams yet</p>
|
|
</div>"##.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn handle_time_off_list(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
people_time_off::table
|
|
.filter(people_time_off::bot_id.eq(bot_id))
|
|
.filter(people_time_off::status.eq("pending"))
|
|
.order(people_time_off::created_at.desc())
|
|
.limit(20)
|
|
.select((
|
|
people_time_off::id,
|
|
people_time_off::person_id,
|
|
people_time_off::time_off_type,
|
|
people_time_off::status,
|
|
people_time_off::start_date,
|
|
people_time_off::end_date,
|
|
people_time_off::reason,
|
|
))
|
|
.load::<(Uuid, Uuid, String, String, chrono::NaiveDate, chrono::NaiveDate, Option<String>)>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(requests) if !requests.is_empty() => {
|
|
let mut html = String::from(r##"<div class="time-off-list">"##);
|
|
|
|
for (id, _person_id, time_off_type, status, start_date, end_date, reason) in requests {
|
|
let reason_str = reason.unwrap_or_default();
|
|
let start_str = start_date.format("%b %d").to_string();
|
|
let end_str = end_date.format("%b %d, %Y").to_string();
|
|
|
|
html.push_str(&format!(
|
|
r##"<div class="time-off-item" data-id="{id}">
|
|
<div class="time-off-header">
|
|
<span class="time-off-type">{}</span>
|
|
<span class="time-off-status status-{}">{}</span>
|
|
</div>
|
|
<div class="time-off-dates">
|
|
<span>{} - {}</span>
|
|
</div>
|
|
<p class="time-off-reason">{}</p>
|
|
<div class="time-off-actions">
|
|
<button class="btn-sm btn-success" hx-put="/api/people/time-off/{id}/approve" hx-swap="none">Approve</button>
|
|
<button class="btn-sm btn-danger" hx-put="/api/people/time-off/{id}/reject" hx-swap="none">Reject</button>
|
|
</div>
|
|
</div>"##,
|
|
html_escape(&time_off_type),
|
|
status,
|
|
html_escape(&status),
|
|
start_str,
|
|
end_str,
|
|
html_escape(&reason_str)
|
|
));
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
Html(html)
|
|
}
|
|
_ => Html(
|
|
r##"<div class="empty-state">
|
|
<p>No pending time-off requests</p>
|
|
</div>"##.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn handle_people_search(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<SearchQuery>,
|
|
) -> Html<String> {
|
|
let q = query.q.clone().unwrap_or_default();
|
|
if q.is_empty() {
|
|
return Html(String::new());
|
|
}
|
|
|
|
let pool = state.conn.clone();
|
|
let search_term = format!("%{q}%");
|
|
|
|
let result: Option<Vec<PersonSearchRow>> = tokio::task::spawn_blocking(move || -> Option<Vec<PersonSearchRow>> {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.filter(
|
|
people_table::first_name.ilike(&search_term)
|
|
.or(people_table::last_name.ilike(&search_term))
|
|
.or(people_table::email.ilike(&search_term))
|
|
.or(people_table::job_title.ilike(&search_term))
|
|
)
|
|
.order(people_table::first_name.asc())
|
|
.limit(20)
|
|
.select((
|
|
people_table::id,
|
|
people_table::first_name,
|
|
people_table::last_name,
|
|
people_table::email,
|
|
people_table::job_title,
|
|
people_table::avatar_url,
|
|
))
|
|
.load::<PersonSearchRow>(&mut conn)
|
|
.ok()
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some(persons) if !persons.is_empty() => {
|
|
let mut html = String::from(r##"<div class="search-results">"##);
|
|
|
|
for row in persons {
|
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
|
let email_str: String = row.email.unwrap_or_default();
|
|
let title_str: String = row.job_title.unwrap_or_default();
|
|
let avatar: String = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
|
let id = row.id;
|
|
|
|
html.push_str(&format!(
|
|
r##"<div class="search-result-item" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
|
|
<img src="{}" class="avatar-sm" alt="" />
|
|
<div class="result-info">
|
|
<span class="result-name">{}</span>
|
|
<span class="result-title">{}</span>
|
|
<span class="result-email">{}</span>
|
|
</div>
|
|
</div>"##,
|
|
html_escape(&avatar),
|
|
html_escape(&full_name),
|
|
html_escape(&title_str),
|
|
html_escape(&email_str)
|
|
));
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
Html(html)
|
|
}
|
|
_ => Html(format!(
|
|
r##"<div class="search-results-empty">
|
|
<p>No results for "{}"</p>
|
|
</div>"##,
|
|
html_escape(&q)
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn handle_people_stats(State(state): State<Arc<AppState>>) -> Html<String> {
|
|
let pool = state.conn.clone();
|
|
|
|
let result = tokio::task::spawn_blocking(move || -> Option<(i64, i64, i64, i64, i64)> {
|
|
let mut conn = pool.get().ok()?;
|
|
let (bot_id, _) = get_default_bot(&mut conn);
|
|
|
|
let total: i64 = people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let active: i64 = people_table::table
|
|
.filter(people_table::bot_id.eq(bot_id))
|
|
.filter(people_table::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let departments: i64 = people_departments::table
|
|
.filter(people_departments::bot_id.eq(bot_id))
|
|
.filter(people_departments::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let teams: i64 = people_teams::table
|
|
.filter(people_teams::bot_id.eq(bot_id))
|
|
.filter(people_teams::is_active.eq(true))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let pending_time_off: i64 = people_time_off::table
|
|
.filter(people_time_off::bot_id.eq(bot_id))
|
|
.filter(people_time_off::status.eq("pending"))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
Some((total, active, departments, teams, pending_time_off))
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match result {
|
|
Some((total, active, departments, teams, pending_time_off)) => Html(format!(
|
|
r##"<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<span class="stat-value">{total}</span>
|
|
<span class="stat-label">Total People</span>
|
|
</div>
|
|
<div class="stat-card stat-success">
|
|
<span class="stat-value">{active}</span>
|
|
<span class="stat-label">Active</span>
|
|
</div>
|
|
<div class="stat-card stat-info">
|
|
<span class="stat-value">{departments}</span>
|
|
<span class="stat-label">Departments</span>
|
|
</div>
|
|
<div class="stat-card stat-primary">
|
|
<span class="stat-value">{teams}</span>
|
|
<span class="stat-label">Teams</span>
|
|
</div>
|
|
<div class="stat-card stat-warning">
|
|
<span class="stat-value">{pending_time_off}</span>
|
|
<span class="stat-label">Pending Time Off</span>
|
|
</div>
|
|
</div>"##
|
|
)),
|
|
None => Html(r##"<div class="stats-grid"><div class="stat-card"><span class="stat-value">-</span></div></div>"##.to_string()),
|
|
}
|
|
}
|
|
|
|
async fn handle_new_person_form() -> Html<String> {
|
|
Html(r##"<div class="modal-header">
|
|
<h3>Add New Person</h3>
|
|
<button class="btn-close" onclick="closeModal()">×</button>
|
|
</div>
|
|
<form class="person-form" hx-post="/api/people" hx-swap="none">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>First Name *</label>
|
|
<input type="text" name="first_name" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Last Name</label>
|
|
<input type="text" name="last_name" />
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Email</label>
|
|
<input type="email" name="email" />
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Phone</label>
|
|
<input type="tel" name="phone" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Mobile</label>
|
|
<input type="tel" name="mobile" />
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Job Title</label>
|
|
<input type="text" name="job_title" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Department</label>
|
|
<input type="text" name="department" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Office Location</label>
|
|
<input type="text" name="office_location" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Hire Date</label>
|
|
<input type="date" name="hire_date" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Bio</label>
|
|
<textarea name="bio" rows="3" placeholder="Short biography"></textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Add Person</button>
|
|
</div>
|
|
</form>"##.to_string())
|
|
}
|