feat(hr): add employees from the dashboard
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m40s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 1m32s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 9m24s

Previously the only Employee records were the Owner (created at café signup) and
one Manager per branch — there was no way to add a waiter/cashier/chef. Adds it.

Backend:
- POST /api/cafes/{cafeId}/employees (HrController). Owner/Manager only; creating a
  Manager requires Owner; Owner cannot be created here. Validates name/phone/role,
  enforces one-employee-per-phone, validates branch belongs to the café, and can
  optionally set username/password login in the same step (same hashing + uniqueness
  as the credentials endpoint). Returns EmployeeSummaryDto.

Dashboard:
- New "Team" tab on the HR screen (now the default): employee roster (name, role,
  phone, base salary) + an "Add employee" button (owner/manager) opening an inline
  form — name, phone, role, optional branch, optional base salary, optional login.
- Role labels + all form strings in fa/en/ar.

86 API tests pass; dashboard tsc + build clean.
This commit is contained in:
soroush.asadi
2026-06-02 23:28:36 +03:30
parent f1756b491e
commit db0c3a4a02
7 changed files with 377 additions and 6 deletions
+88
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
@@ -46,6 +47,93 @@ public class HrController : CafeApiControllerBase
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
}
/// <summary>Create a new employee (waiter, cashier, chef, …). Owner/Manager only;
/// creating a Manager requires Owner. Optionally sets login credentials in one step.</summary>
[HttpPost("employees")]
public async Task<IActionResult> CreateEmployee(
string cafeId,
[FromBody] CreateEmployeeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
IActionResult Invalid(string message, string field) =>
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
var name = request.Name?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
return Invalid("Name is required.", "Name");
var phone = request.Phone?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(phone))
return Invalid("Phone is required.", "Phone");
if (!Enum.IsDefined(typeof(EmployeeRole), request.Role))
return Invalid("Invalid role.", "Role");
// An Owner is created only at café registration, never via this endpoint.
if (request.Role == EmployeeRole.Owner)
return Invalid("Cannot create an owner here.", "Role");
// Only an Owner may add a Manager.
if (request.Role == EmployeeRole.Manager && EnsureOwner(tenant) is { } ownerOnly)
return ownerOnly;
// One employee per phone within a café.
var phoneTaken = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Phone == phone, ct);
if (phoneTaken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("PHONE_TAKEN", "An employee with this phone already exists.", "Phone")));
string? branchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId.Trim();
if (branchId is not null)
{
var branchOk = await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (!branchOk) return Invalid("Invalid branch.", "BranchId");
}
var employee = new Employee
{
Id = $"emp_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
BranchId = branchId,
Name = name,
Phone = phone,
Role = request.Role,
BaseSalary = request.BaseSalary ?? 0m,
NationalId = string.IsNullOrWhiteSpace(request.NationalId) ? null : request.NationalId.Trim(),
CreatedAt = DateTime.UtcNow,
};
// Optional: enable password login in the same step.
var wantsCreds = !string.IsNullOrWhiteSpace(request.Username) || !string.IsNullOrWhiteSpace(request.Password);
if (wantsCreds)
{
var username = (request.Username ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(username))
return Invalid("Username is required when setting a password.", "Username");
if ((request.Password ?? string.Empty).Length < 8)
return Invalid("Password must be at least 8 characters.", "Password");
var usernameTaken = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null
&& e.Username != null && e.Username.ToLower() == username, ct);
if (usernameTaken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.", "Username")));
employee.Username = username;
employee.PasswordHash = PasswordHasher.Hash(request.Password!);
}
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
var dto = new EmployeeSummaryDto(employee.Id, employee.Name, employee.Phone, employee.Role, employee.BaseSalary);
return Ok(new ApiResponse<EmployeeSummaryDto>(true, dto));
}
[HttpGet("employees/{employeeId}")]
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
+12
View File
@@ -62,3 +62,15 @@ public record TodayShiftDto(ShiftType ShiftType, string Label);
/// <summary>Set or update username/password credentials for an employee.</summary>
public record SetEmployeeCredentialsRequest(string Username, string Password);
/// <summary>Create a new employee. Owner/Manager only; Manager role requires Owner.
/// Username+Password are optional and, when supplied, enable dashboard/POS login.</summary>
public record CreateEmployeeRequest(
string Name,
string Phone,
EmployeeRole Role,
string? BranchId = null,
decimal? BaseSalary = null,
string? NationalId = null,
string? Username = null,
string? Password = null);