2026-03-27 21:25:31 +01:00
|
|
|
use crate::AppState;
|
|
|
|
|
use axum::{
|
|
|
|
|
extract::{Path, Query, State},
|
|
|
|
|
http::StatusCode,
|
|
|
|
|
response::IntoResponse,
|
2026-03-27 21:34:28 +01:00
|
|
|
routing::get,
|
2026-03-27 21:25:31 +01:00
|
|
|
Json, Router,
|
|
|
|
|
};
|
|
|
|
|
use contracts::auth_middleware::{AuthUser, require_admin};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use sqlx::types::Uuid;
|
|
|
|
|
use sqlx::Row;
|
|
|
|
|
|
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
|
|
|
Router::new()
|
|
|
|
|
.route("/", get(list_employees).post(create_employee))
|
|
|
|
|
.route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct ListQuery {
|
|
|
|
|
q: Option<String>,
|
|
|
|
|
status: Option<String>, // ACTIVE | INACTIVE | SUSPENDED
|
|
|
|
|
page: Option<i64>,
|
|
|
|
|
per_page: Option<i64>,
|
|
|
|
|
sort: Option<String>, // name_asc | name_desc | joined_asc | joined_desc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct EmployeeRow {
|
|
|
|
|
id: Uuid,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
name: String,
|
|
|
|
|
email: String,
|
|
|
|
|
phone: Option<String>,
|
|
|
|
|
employee_id: Option<String>,
|
|
|
|
|
department_name: Option<String>,
|
|
|
|
|
designation_name: Option<String>,
|
|
|
|
|
role_name: Option<String>,
|
|
|
|
|
role_key: Option<String>,
|
|
|
|
|
joining_date: String,
|
|
|
|
|
status: String,
|
|
|
|
|
updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct ListResponse {
|
|
|
|
|
employees: Vec<EmployeeRow>,
|
|
|
|
|
total: i64,
|
|
|
|
|
page: i64,
|
|
|
|
|
per_page: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn list_employees(
|
|
|
|
|
auth: AuthUser,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Query(q): Query<ListQuery>,
|
|
|
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
|
|
|
if let Err(_e) = require_admin(&auth) {
|
|
|
|
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
|
|
|
|
}
|
|
|
|
|
let page = q.page.unwrap_or(1).max(1);
|
|
|
|
|
let per_page = q.per_page.unwrap_or(20).clamp(1, 100);
|
|
|
|
|
let offset = (page - 1) * per_page;
|
|
|
|
|
let search = q.q.unwrap_or_default().to_lowercase();
|
|
|
|
|
let status = q.status.unwrap_or_default().to_uppercase();
|
|
|
|
|
let sort_sql = match q.sort.as_deref() {
|
|
|
|
|
Some("name_asc") => "u.full_name ASC",
|
|
|
|
|
Some("name_desc") => "u.full_name DESC",
|
|
|
|
|
Some("joined_asc") => "e.created_at ASC",
|
|
|
|
|
_ => "e.created_at DESC",
|
|
|
|
|
};
|
|
|
|
|
let sql = format!(r#"
|
|
|
|
|
SELECT
|
|
|
|
|
e.id,
|
|
|
|
|
e.user_id,
|
|
|
|
|
e.employee_code,
|
|
|
|
|
e.created_at AS joining_date,
|
|
|
|
|
u.full_name,
|
|
|
|
|
u.email,
|
|
|
|
|
u.phone,
|
|
|
|
|
u.status AS user_status,
|
|
|
|
|
r.name AS role_name,
|
|
|
|
|
r.key AS role_key,
|
|
|
|
|
d.name AS department_name,
|
|
|
|
|
des.name AS designation_name,
|
|
|
|
|
e.created_at AS updated_at
|
|
|
|
|
FROM employees e
|
|
|
|
|
JOIN users u ON u.id = e.user_id
|
|
|
|
|
LEFT JOIN roles r ON r.id = e.role_id
|
|
|
|
|
LEFT JOIN departments d ON d.id = e.department_id
|
|
|
|
|
LEFT JOIN designations des ON des.id = e.designation_id
|
|
|
|
|
WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%')
|
|
|
|
|
AND (
|
|
|
|
|
$2 = ''
|
|
|
|
|
OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE')
|
|
|
|
|
OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE')
|
|
|
|
|
OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED')
|
|
|
|
|
)
|
|
|
|
|
ORDER BY {sort}
|
|
|
|
|
LIMIT $3 OFFSET $4
|
|
|
|
|
"#, sort = sort_sql);
|
|
|
|
|
let rows = sqlx::query(&sql)
|
|
|
|
|
.bind(&search)
|
|
|
|
|
.bind(&status)
|
|
|
|
|
.bind(per_page)
|
|
|
|
|
.bind(offset)
|
|
|
|
|
.fetch_all(&state.pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let employees = rows.into_iter().map(|row| {
|
|
|
|
|
let joining: chrono::DateTime<chrono::Utc> = row.get::<chrono::DateTime<chrono::Utc>, _>("joining_date");
|
|
|
|
|
EmployeeRow {
|
|
|
|
|
id: row.get("id"),
|
|
|
|
|
user_id: row.get("user_id"),
|
|
|
|
|
name: row.get::<Option<String>, _>("full_name").unwrap_or_default(),
|
|
|
|
|
email: row.get("email"),
|
|
|
|
|
phone: row.get::<Option<String>, _>("phone"),
|
|
|
|
|
employee_id: row.get::<Option<String>, _>("employee_code"),
|
|
|
|
|
department_name: row.get::<Option<String>, _>("department_name"),
|
|
|
|
|
designation_name: row.get::<Option<String>, _>("designation_name"),
|
|
|
|
|
role_name: row.get::<Option<String>, _>("role_name"),
|
|
|
|
|
role_key: row.get::<Option<String>, _>("role_key"),
|
|
|
|
|
joining_date: joining.to_rfc3339(),
|
|
|
|
|
status: row.get::<Option<String>, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()),
|
|
|
|
|
updated_at: Some(row.get::<chrono::DateTime<chrono::Utc>, _>("updated_at")),
|
|
|
|
|
}
|
|
|
|
|
}).collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
let total: i64 = sqlx::query_scalar!(
|
|
|
|
|
r#"
|
|
|
|
|
SELECT COUNT(*)
|
|
|
|
|
FROM employees e
|
|
|
|
|
JOIN users u ON u.id = e.user_id
|
|
|
|
|
WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%' OR LOWER(COALESCE(e.employee_code,'')) LIKE '%' || $1 || '%')
|
|
|
|
|
AND (
|
|
|
|
|
$2 = ''
|
|
|
|
|
OR ($2 = 'ACTIVE' AND u.status = 'ACTIVE')
|
|
|
|
|
OR ($2 = 'INACTIVE' AND u.status = 'INACTIVE')
|
|
|
|
|
OR ($2 = 'SUSPENDED' AND u.status = 'SUSPENDED')
|
|
|
|
|
)
|
|
|
|
|
"#,
|
|
|
|
|
search,
|
|
|
|
|
status
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(&state.pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
Ok(Json(ListResponse { employees, total, page, per_page }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_employee(
|
|
|
|
|
auth: AuthUser,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
|
|
|
if let Err(_e) = require_admin(&auth) {
|
|
|
|
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
|
|
|
|
}
|
|
|
|
|
let sql = r#"
|
|
|
|
|
SELECT
|
|
|
|
|
e.id, e.user_id, e.employee_code,
|
|
|
|
|
e.created_at AS joining_date, e.created_at AS updated_at,
|
|
|
|
|
u.full_name, u.email, u.phone, u.status AS user_status,
|
|
|
|
|
r.name AS role_name, r.key AS role_key,
|
|
|
|
|
d.name AS department_name,
|
|
|
|
|
des.name AS designation_name
|
|
|
|
|
FROM employees e
|
|
|
|
|
JOIN users u ON u.id = e.user_id
|
|
|
|
|
LEFT JOIN roles r ON r.id = e.role_id
|
|
|
|
|
LEFT JOIN departments d ON d.id = e.department_id
|
|
|
|
|
LEFT JOIN designations des ON des.id = e.designation_id
|
|
|
|
|
WHERE e.id = $1
|
|
|
|
|
"#;
|
|
|
|
|
let row = sqlx::query(sql)
|
|
|
|
|
.bind(id)
|
|
|
|
|
.fetch_optional(&state.pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
|
|
|
|
.ok_or((StatusCode::NOT_FOUND, "Employee not found".to_string()))?;
|
|
|
|
|
let joining: chrono::DateTime<chrono::Utc> = row.get::<chrono::DateTime<chrono::Utc>, _>("joining_date");
|
|
|
|
|
Ok(Json(EmployeeRow {
|
|
|
|
|
id: row.get("id"),
|
|
|
|
|
user_id: row.get("user_id"),
|
|
|
|
|
name: row.get::<Option<String>, _>("full_name").unwrap_or_default(),
|
|
|
|
|
email: row.get("email"),
|
|
|
|
|
phone: row.get::<Option<String>, _>("phone"),
|
|
|
|
|
employee_id: row.get::<Option<String>, _>("employee_code"),
|
|
|
|
|
department_name: row.get::<Option<String>, _>("department_name"),
|
|
|
|
|
designation_name: row.get::<Option<String>, _>("designation_name"),
|
|
|
|
|
role_name: row.get::<Option<String>, _>("role_name"),
|
|
|
|
|
role_key: row.get::<Option<String>, _>("role_key"),
|
|
|
|
|
joining_date: joining.to_rfc3339(),
|
|
|
|
|
status: row.get::<Option<String>, _>("user_status").unwrap_or_else(|| "ACTIVE".to_string()),
|
|
|
|
|
updated_at: Some(row.get::<chrono::DateTime<chrono::Utc>, _>("updated_at")),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct CreateEmployeePayload {
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
role_id: Uuid,
|
|
|
|
|
department_id: Option<Uuid>,
|
|
|
|
|
designation_id: Option<Uuid>,
|
|
|
|
|
employee_code: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn create_employee(
|
|
|
|
|
auth: AuthUser,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Json(p): Json<CreateEmployeePayload>,
|
|
|
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
|
|
|
if let Err(_e) = require_admin(&auth) {
|
|
|
|
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
|
|
|
|
}
|
|
|
|
|
let row = sqlx::query!(
|
|
|
|
|
r#"
|
|
|
|
|
INSERT INTO employees (user_id, role_id, department_id, designation_id, employee_code)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
|
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
|
|
|
role_id = EXCLUDED.role_id,
|
|
|
|
|
department_id = EXCLUDED.department_id,
|
|
|
|
|
designation_id = EXCLUDED.designation_id,
|
|
|
|
|
employee_code = EXCLUDED.employee_code
|
|
|
|
|
RETURNING id
|
|
|
|
|
"#,
|
|
|
|
|
p.user_id, p.role_id, p.department_id, p.designation_id, p.employee_code
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(&state.pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
|
|
|
get_employee(auth, State(state), Path(row.id)).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct UpdateEmployeePayload {
|
|
|
|
|
role_id: Option<Uuid>,
|
|
|
|
|
department_id: Option<Uuid>,
|
|
|
|
|
designation_id: Option<Uuid>,
|
|
|
|
|
employee_code: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_employee(
|
|
|
|
|
auth: AuthUser,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
Json(p): Json<UpdateEmployeePayload>,
|
|
|
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
|
|
|
if let Err(_e) = require_admin(&auth) {
|
|
|
|
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
|
|
|
|
}
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
r#"
|
|
|
|
|
UPDATE employees
|
|
|
|
|
SET
|
|
|
|
|
role_id = COALESCE($1, role_id),
|
|
|
|
|
department_id = COALESCE($2, department_id),
|
|
|
|
|
designation_id = COALESCE($3, designation_id),
|
|
|
|
|
employee_code = COALESCE($4, employee_code)
|
|
|
|
|
WHERE id = $5
|
|
|
|
|
"#,
|
|
|
|
|
p.role_id, p.department_id, p.designation_id, p.employee_code, id
|
|
|
|
|
)
|
|
|
|
|
.execute(&state.pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
|
|
|
get_employee(auth, State(state), Path(id)).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn delete_employee(
|
|
|
|
|
auth: AuthUser,
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
|
|
|
if let Err(_e) = require_admin(&auth) {
|
|
|
|
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
|
|
|
|
}
|
|
|
|
|
let result = sqlx::query!("DELETE FROM employees WHERE id = $1", id)
|
|
|
|
|
.execute(&state.pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
|
|
|
|
if result.rows_affected() == 0 {
|
|
|
|
|
return Err((StatusCode::NOT_FOUND, "Employee not found".to_string()));
|
|
|
|
|
}
|
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
|
|
|
}
|