use crate::AppState; use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Row; use uuid::Uuid; pub fn router() -> Router { Router::new() .route("/", get(list_designations).post(create_designation)) .route("/{id}", get(get_designation).patch(update_designation).delete(delete_designation)) } #[derive(Deserialize)] struct ListQuery { q: Option, status: Option, department_id: Option, page: Option, per_page: Option, limit: Option, } #[derive(Serialize)] struct DesignationRow { id: Uuid, name: String, code: Option, department_id: Option, department_name: Option, description: Option, level: Option, can_manage_team: bool, can_approve: bool, is_active: bool, status: String, total_employees: i64, created_at: DateTime, updated_at: DateTime, } #[derive(Serialize)] struct ListResponse { designations: Vec, total: i64, page: i64, per_page: i64, } #[derive(Deserialize)] struct CreateDesignationPayload { name: String, code: Option, department_id: Option, description: Option, level: Option, can_manage_team: Option, can_approve: Option, is_active: Option, status: Option, } #[derive(Deserialize)] struct UpdateDesignationPayload { name: Option, code: Option, department_id: Option, description: Option, level: Option, can_manage_team: Option, can_approve: Option, is_active: Option, status: Option, } fn derive_is_active(status: &Option, is_active: Option, current: bool) -> bool { if let Some(active) = is_active { return active; } match status.as_deref().unwrap_or_default().to_ascii_uppercase().as_str() { "ACTIVE" => true, "INACTIVE" => false, _ => current, } } async fn list_designations( State(state): State, Query(params): Query, ) -> Result { let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.or(params.limit).unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; let search = params.q.unwrap_or_default().to_lowercase(); let status = params.status.unwrap_or_default().to_lowercase(); let rows = sqlx::query( r#" SELECT des.id, des.name, des.code, des.department_id, dep.name AS department_name, des.description, des.level, des.can_manage_team, des.can_approve, des.is_active, des.created_at, des.updated_at, COUNT(e.id) AS total_employees FROM designations des LEFT JOIN departments dep ON dep.id = des.department_id LEFT JOIN employees e ON e.designation_id = des.id WHERE ($1 = '' OR LOWER(des.name) LIKE '%' || $1 || '%' OR LOWER(COALESCE(des.code, '')) LIKE '%' || $1 || '%') AND ( $2 = '' OR ($2 = 'active' AND des.is_active = true) OR ($2 = 'inactive' AND des.is_active = false) ) AND ($3::uuid IS NULL OR des.department_id = $3) GROUP BY des.id, dep.name ORDER BY des.created_at DESC LIMIT $4 OFFSET $5 "#, ) .bind(&search) .bind(&status) .bind(params.department_id) .bind(per_page) .bind(offset) .fetch_all(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; let designations = rows .into_iter() .map(|row| DesignationRow { id: row.get("id"), name: row.get("name"), code: row.get("code"), department_id: row.get("department_id"), department_name: row.get("department_name"), description: row.get("description"), level: row.get("level"), can_manage_team: row.get("can_manage_team"), can_approve: row.get("can_approve"), is_active: row.get("is_active"), status: if row.get::("is_active") { "ACTIVE".to_string() } else { "INACTIVE".to_string() }, total_employees: row.get("total_employees"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), }) .collect(); let total: i64 = sqlx::query_scalar::<_, Option>( r#" SELECT COUNT(*) FROM designations des WHERE ($1 = '' OR LOWER(des.name) LIKE '%' || $1 || '%' OR LOWER(COALESCE(des.code, '')) LIKE '%' || $1 || '%') AND ( $2 = '' OR ($2 = 'active' AND des.is_active = true) OR ($2 = 'inactive' AND des.is_active = false) ) AND ($3::uuid IS NULL OR des.department_id = $3) "#, ) .bind(&search) .bind(&status) .bind(params.department_id) .fetch_one(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? .unwrap_or(0); Ok(Json(ListResponse { designations, total, page, per_page, })) } async fn get_designation( State(state): State, Path(id): Path, ) -> Result { let row = sqlx::query( r#" SELECT des.id, des.name, des.code, des.department_id, dep.name AS department_name, des.description, des.level, des.can_manage_team, des.can_approve, des.is_active, des.created_at, des.updated_at, COUNT(e.id) AS total_employees FROM designations des LEFT JOIN departments dep ON dep.id = des.department_id LEFT JOIN employees e ON e.designation_id = des.id WHERE des.id = $1 GROUP BY des.id, dep.name "#, ) .bind(id) .fetch_optional(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? .ok_or((StatusCode::NOT_FOUND, "Designation not found".to_string()))?; Ok(Json(DesignationRow { id: row.get("id"), name: row.get("name"), code: row.get("code"), department_id: row.get("department_id"), department_name: row.get("department_name"), description: row.get("description"), level: row.get("level"), can_manage_team: row.get("can_manage_team"), can_approve: row.get("can_approve"), is_active: row.get("is_active"), status: if row.get::("is_active") { "ACTIVE".to_string() } else { "INACTIVE".to_string() }, total_employees: row.get("total_employees"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), })) } async fn create_designation( State(state): State, Json(payload): Json, ) -> Result { let name = payload.name.trim(); if name.is_empty() { return Err((StatusCode::BAD_REQUEST, "Name is required".to_string())); } let is_active = derive_is_active(&payload.status, payload.is_active, true); let row = sqlx::query( r#" INSERT INTO designations ( name, code, department_id, description, level, can_manage_team, can_approve, is_active ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id "#, ) .bind(name) .bind(payload.code.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) .bind(payload.department_id) .bind(payload.description.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) .bind(payload.level.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())) .bind(payload.can_manage_team.unwrap_or(false)) .bind(payload.can_approve.unwrap_or(false)) .bind(is_active) .fetch_one(&state.pool) .await .map_err(|e| { let msg = e.to_string(); if msg.contains("designations_name_key") || msg.contains("idx_designations_code_unique") { (StatusCode::CONFLICT, "Designation name or code already exists".to_string()) } else { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) } })?; let id: Uuid = row.get("id"); let response = get_designation(State(state), Path(id)).await?; Ok((StatusCode::CREATED, response)) } async fn update_designation( State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { let current = sqlx::query( r#" SELECT name, code, department_id, description, level, can_manage_team, can_approve, is_active FROM designations WHERE id = $1 "#, ) .bind(id) .fetch_optional(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? .ok_or((StatusCode::NOT_FOUND, "Designation not found".to_string()))?; let name = payload .name .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .unwrap_or_else(|| current.get::("name")); let code = payload .code .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .or_else(|| current.get::, _>("code")); let department_id = payload .department_id .or_else(|| current.get::, _>("department_id")); let description = payload .description .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .or_else(|| current.get::, _>("description")); let level = payload .level .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .or_else(|| current.get::, _>("level")); let is_active = derive_is_active(&payload.status, payload.is_active, current.get("is_active")); let can_manage_team = payload .can_manage_team .unwrap_or_else(|| current.get("can_manage_team")); let can_approve = payload .can_approve .unwrap_or_else(|| current.get("can_approve")); sqlx::query( r#" UPDATE designations SET name = $1, code = $2, department_id = $3, description = $4, level = $5, can_manage_team = $6, can_approve = $7, is_active = $8, updated_at = NOW() WHERE id = $9 "#, ) .bind(name) .bind(code) .bind(department_id) .bind(description) .bind(level) .bind(can_manage_team) .bind(can_approve) .bind(is_active) .bind(id) .execute(&state.pool) .await .map_err(|e| { let msg = e.to_string(); if msg.contains("designations_name_key") || msg.contains("idx_designations_code_unique") { (StatusCode::CONFLICT, "Designation name or code already exists".to_string()) } else { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) } })?; get_designation(State(state), Path(id)).await } async fn delete_designation( State(state): State, Path(id): Path, ) -> Result { let result = sqlx::query("DELETE FROM designations WHERE id = $1") .bind(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, "Designation not found".to_string())); } Ok(StatusCode::NO_CONTENT) }