diff --git a/src/Meezi.API/Controllers/HrController.cs b/src/Meezi.API/Controllers/HrController.cs index 8b16680..1b4cdc1 100644 --- a/src/Meezi.API/Controllers/HrController.cs +++ b/src/Meezi.API/Controllers/HrController.cs @@ -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>(true, data)); } + /// Create a new employee (waiter, cashier, chef, …). Owner/Manager only; + /// creating a Manager requires Owner. Optionally sets login credentials in one step. + [HttpPost("employees")] + public async Task 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(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(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(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(true, dto)); + } + [HttpGet("employees/{employeeId}")] public async Task GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { diff --git a/src/Meezi.API/Models/Hr/HrDtos.cs b/src/Meezi.API/Models/Hr/HrDtos.cs index a24131d..b14f733 100644 --- a/src/Meezi.API/Models/Hr/HrDtos.cs +++ b/src/Meezi.API/Models/Hr/HrDtos.cs @@ -62,3 +62,15 @@ public record TodayShiftDto(ShiftType ShiftType, string Label); /// Set or update username/password credentials for an employee. public record SetEmployeeCredentialsRequest(string Username, string Password); + +/// Create a new employee. Owner/Manager only; Manager role requires Owner. +/// Username+Password are optional and, when supplied, enable dashboard/POS login. +public record CreateEmployeeRequest( + string Name, + string Phone, + EmployeeRole Role, + string? BranchId = null, + decimal? BaseSalary = null, + string? NationalId = null, + string? Username = null, + string? Password = null); diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index ee8aad8..946465a 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -412,7 +412,8 @@ "leave": "الإجازة", "payroll": "الرواتب", "access": "صلاحيات الفروع", - "credentials": "بيانات الدخول" + "credentials": "بيانات الدخول", + "team": "الموظفون" }, "myAttendance": "حضوري", "clockIn": "تسجيل دخول", @@ -437,6 +438,33 @@ "saved": "تم حفظ بيانات الدخول.", "removed": "تم حذف بيانات الدخول.", "usernameTaken": "اسم المستخدم هذا مستخدم بالفعل." + }, + "addEmployee": "إضافة موظف", + "noEmployees": "لا يوجد موظفون بعد.", + "employeeCreated": "تمت إضافة الموظف", + "save": "حفظ", + "cancel": "إلغاء", + "fields": { + "name": "الاسم", + "phone": "الجوال", + "role": "الدور", + "branch": "الفرع", + "branchOptional": "اختياري", + "noBranch": "بدون فرع", + "baseSalary": "الراتب الأساسي (تومان)", + "optional": "اختياري", + "enableLogin": "إنشاء اسم مستخدم وكلمة مرور", + "username": "اسم المستخدم", + "password": "كلمة المرور", + "passwordHint": "8 أحرف على الأقل" + }, + "roles": { + "Owner": "المالك", + "Manager": "مدير", + "Cashier": "أمين الصندوق", + "Waiter": "نادل", + "Chef": "طاهٍ", + "Delivery": "موصّل" } }, "reviews": { diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index d8ccf59..9cc160b 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -431,7 +431,8 @@ "leave": "Leave", "payroll": "Payroll", "access": "Branch access", - "credentials": "Login credentials" + "credentials": "Login credentials", + "team": "Team" }, "myAttendance": "My attendance", "clockIn": "Clock in", @@ -456,6 +457,33 @@ "saved": "Credentials saved.", "removed": "Credentials removed.", "usernameTaken": "This username is already taken." + }, + "addEmployee": "Add employee", + "noEmployees": "No employees yet.", + "employeeCreated": "Employee added", + "save": "Save", + "cancel": "Cancel", + "fields": { + "name": "Name", + "phone": "Mobile", + "role": "Role", + "branch": "Branch", + "branchOptional": "optional", + "noBranch": "No branch", + "baseSalary": "Base salary (Toman)", + "optional": "optional", + "enableLogin": "Create username & password", + "username": "Username", + "password": "Password", + "passwordHint": "At least 8 characters" + }, + "roles": { + "Owner": "Owner", + "Manager": "Manager", + "Cashier": "Cashier", + "Waiter": "Waiter", + "Chef": "Chef", + "Delivery": "Delivery" } }, "reviews": { diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 782be08..a4fb674 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -431,7 +431,8 @@ "leave": "مرخصی", "payroll": "حقوق", "access": "دسترسی شعب", - "credentials": "رمز ورود" + "credentials": "رمز ورود", + "team": "کارکنان" }, "myAttendance": "حضور من", "clockIn": "ورود", @@ -456,6 +457,33 @@ "saved": "رمز ورود ذخیره شد.", "removed": "رمز ورود حذف شد.", "usernameTaken": "این نام کاربری قبلاً استفاده شده است." + }, + "addEmployee": "افزودن کارمند", + "noEmployees": "هنوز کارمندی ثبت نشده است.", + "employeeCreated": "کارمند اضافه شد", + "save": "ذخیره", + "cancel": "انصراف", + "fields": { + "name": "نام", + "phone": "موبایل", + "role": "نقش", + "branch": "شعبه", + "branchOptional": "اختیاری", + "noBranch": "بدون شعبه", + "baseSalary": "حقوق پایه (تومان)", + "optional": "اختیاری", + "enableLogin": "ایجاد نام کاربری و رمز ورود", + "username": "نام کاربری", + "password": "رمز عبور", + "passwordHint": "حداقل ۸ کاراکتر" + }, + "roles": { + "Owner": "مالک", + "Manager": "مدیر", + "Cashier": "صندوق‌دار", + "Waiter": "گارسون", + "Chef": "آشپز", + "Delivery": "پیک" } }, "reviews": { diff --git a/web/dashboard/src/components/hr/add-employee-form.tsx b/web/dashboard/src/components/hr/add-employee-form.tsx new file mode 100644 index 0000000..df7c57f --- /dev/null +++ b/web/dashboard/src/components/hr/add-employee-form.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { apiGet, apiPost } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { LabeledField } from "@/components/ui/labeled-field"; +import { notify } from "@/lib/notify"; +import { useApiError } from "@/lib/use-api-error"; + +/** Roles that can be created from the dashboard (Owner is created only at signup). */ +const ALL_ROLES = ["Manager", "Cashier", "Waiter", "Chef", "Delivery"] as const; +type Role = (typeof ALL_ROLES)[number]; + +type Branch = { id: string; name: string }; + +const selectClass = + "h-9 w-full rounded-lg border border-border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/30"; + +export function AddEmployeeForm({ + cafeId, + canAddManager, + onClose, +}: { + cafeId: string; + canAddManager: boolean; + onClose: () => void; +}) { + const t = useTranslations("hr"); + const apiError = useApiError(); + const queryClient = useQueryClient(); + + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [role, setRole] = useState("Waiter"); + const [branchId, setBranchId] = useState(""); + const [baseSalary, setBaseSalary] = useState(""); + const [withLogin, setWithLogin] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const { data: branches = [] } = useQuery({ + queryKey: ["branches", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/branches`), + enabled: !!cafeId, + }); + + const roles = canAddManager ? ALL_ROLES : ALL_ROLES.filter((r) => r !== "Manager"); + + const credsValid = !withLogin || (username.trim().length > 0 && password.length >= 8); + const canSubmit = name.trim().length > 0 && phone.trim().length > 0 && credsValid; + + const createMut = useMutation({ + mutationFn: () => + apiPost(`/api/cafes/${cafeId}/employees`, { + name: name.trim(), + phone: phone.trim(), + role, + branchId: branchId || null, + baseSalary: baseSalary ? Number(baseSalary) : null, + username: withLogin ? username.trim() : null, + password: withLogin ? password : null, + }), + onSuccess: () => { + notify.success(t("employeeCreated")); + queryClient.invalidateQueries({ queryKey: ["employees", cafeId] }); + onClose(); + }, + onError: (err) => notify.error(apiError(err)), + }); + + return ( +
+

{t("addEmployee")}

+
+ + setName(e.target.value)} dir="rtl" /> + + + setPhone(e.target.value)} dir="ltr" placeholder="09xxxxxxxxx" /> + + + + + + + + + setBaseSalary(e.target.value.replace(/[^\d]/g, ""))} + dir="ltr" + inputMode="numeric" + /> + +
+ + + + {withLogin && ( +
+ + setUsername(e.target.value)} dir="ltr" autoComplete="off" /> + + + setPassword(e.target.value)} dir="ltr" autoComplete="new-password" /> + +
+ )} + +
+ + +
+
+ ); +} diff --git a/web/dashboard/src/components/hr/hr-screen.tsx b/web/dashboard/src/components/hr/hr-screen.tsx index 56f6e47..96d66e4 100644 --- a/web/dashboard/src/components/hr/hr-screen.tsx +++ b/web/dashboard/src/components/hr/hr-screen.tsx @@ -13,6 +13,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { BranchAccessPanel } from "@/components/hr/branch-access-panel"; import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel"; +import { AddEmployeeForm } from "@/components/hr/add-employee-form"; +import { UserPlus } from "lucide-react"; interface Employee { id: string; @@ -48,7 +50,7 @@ interface Salary { isPaid: boolean; } -type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials"; +type Tab = "team" | "attendance" | "leave" | "payroll" | "access" | "credentials"; export function HrScreen() { const t = useTranslations("hr"); @@ -57,7 +59,8 @@ export function HrScreen() { const role = useAuthStore((s) => s.user?.role); const canManageAccess = role === "Owner" || role === "Manager"; const queryClient = useQueryClient(); - const [tab, setTab] = useState("attendance"); + const [tab, setTab] = useState("team"); + const [addingEmployee, setAddingEmployee] = useState(false); const [monthYear, setMonthYear] = useState( new Date().toISOString().slice(0, 7) ); @@ -123,7 +126,7 @@ export function HrScreen() {

{t("title")}

- {((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter( + {((["team", "attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter( (key) => (key !== "access" && key !== "credentials") || canManageAccess )).map((key) => ( + )} +
+ )} + + {addingEmployee && ( + setAddingEmployee(false)} + /> + )} + + {employees.length === 0 ? ( +

{t("noEmployees")}

+ ) : ( +
+ {employees.map((e) => ( + + +
+

{e.name}

+ {t(`roles.${e.role}`)} +
+

{e.phone}

+ {e.baseSalary > 0 && ( +

{formatCurrency(e.baseSalary)}

+ )} +
+
+ ))} +
+ )} + + )} + {tab === "attendance" && (