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
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:
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Hr;
|
using Meezi.API.Models.Hr;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
@@ -46,6 +47,93 @@ public class HrController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
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}")]
|
[HttpGet("employees/{employeeId}")]
|
||||||
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,3 +62,15 @@ public record TodayShiftDto(ShiftType ShiftType, string Label);
|
|||||||
|
|
||||||
/// <summary>Set or update username/password credentials for an employee.</summary>
|
/// <summary>Set or update username/password credentials for an employee.</summary>
|
||||||
public record SetEmployeeCredentialsRequest(string Username, string Password);
|
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);
|
||||||
|
|||||||
@@ -412,7 +412,8 @@
|
|||||||
"leave": "الإجازة",
|
"leave": "الإجازة",
|
||||||
"payroll": "الرواتب",
|
"payroll": "الرواتب",
|
||||||
"access": "صلاحيات الفروع",
|
"access": "صلاحيات الفروع",
|
||||||
"credentials": "بيانات الدخول"
|
"credentials": "بيانات الدخول",
|
||||||
|
"team": "الموظفون"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضوري",
|
"myAttendance": "حضوري",
|
||||||
"clockIn": "تسجيل دخول",
|
"clockIn": "تسجيل دخول",
|
||||||
@@ -437,6 +438,33 @@
|
|||||||
"saved": "تم حفظ بيانات الدخول.",
|
"saved": "تم حفظ بيانات الدخول.",
|
||||||
"removed": "تم حذف بيانات الدخول.",
|
"removed": "تم حذف بيانات الدخول.",
|
||||||
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
|
"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": {
|
"reviews": {
|
||||||
|
|||||||
@@ -431,7 +431,8 @@
|
|||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"payroll": "Payroll",
|
"payroll": "Payroll",
|
||||||
"access": "Branch access",
|
"access": "Branch access",
|
||||||
"credentials": "Login credentials"
|
"credentials": "Login credentials",
|
||||||
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"myAttendance": "My attendance",
|
"myAttendance": "My attendance",
|
||||||
"clockIn": "Clock in",
|
"clockIn": "Clock in",
|
||||||
@@ -456,6 +457,33 @@
|
|||||||
"saved": "Credentials saved.",
|
"saved": "Credentials saved.",
|
||||||
"removed": "Credentials removed.",
|
"removed": "Credentials removed.",
|
||||||
"usernameTaken": "This username is already taken."
|
"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": {
|
"reviews": {
|
||||||
|
|||||||
@@ -431,7 +431,8 @@
|
|||||||
"leave": "مرخصی",
|
"leave": "مرخصی",
|
||||||
"payroll": "حقوق",
|
"payroll": "حقوق",
|
||||||
"access": "دسترسی شعب",
|
"access": "دسترسی شعب",
|
||||||
"credentials": "رمز ورود"
|
"credentials": "رمز ورود",
|
||||||
|
"team": "کارکنان"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضور من",
|
"myAttendance": "حضور من",
|
||||||
"clockIn": "ورود",
|
"clockIn": "ورود",
|
||||||
@@ -456,6 +457,33 @@
|
|||||||
"saved": "رمز ورود ذخیره شد.",
|
"saved": "رمز ورود ذخیره شد.",
|
||||||
"removed": "رمز ورود حذف شد.",
|
"removed": "رمز ورود حذف شد.",
|
||||||
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
|
"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": {
|
"reviews": {
|
||||||
|
|||||||
@@ -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<Role>("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<Branch[]>(`/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 (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<h3 className="mb-3 text-base font-semibold">{t("addEmployee")}</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<LabeledField label={t("fields.name")} htmlFor="emp-name">
|
||||||
|
<Input id="emp-name" value={name} onChange={(e) => setName(e.target.value)} dir="rtl" />
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("fields.phone")} htmlFor="emp-phone">
|
||||||
|
<Input id="emp-phone" value={phone} onChange={(e) => setPhone(e.target.value)} dir="ltr" placeholder="09xxxxxxxxx" />
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("fields.role")} htmlFor="emp-role">
|
||||||
|
<select id="emp-role" className={selectClass} value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{t(`roles.${r}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("fields.branch")} htmlFor="emp-branch" hint={t("fields.branchOptional")}>
|
||||||
|
<select id="emp-branch" className={selectClass} value={branchId} onChange={(e) => setBranchId(e.target.value)}>
|
||||||
|
<option value="">{t("fields.noBranch")}</option>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("fields.baseSalary")} htmlFor="emp-salary" hint={t("fields.optional")}>
|
||||||
|
<Input
|
||||||
|
id="emp-salary"
|
||||||
|
value={baseSalary}
|
||||||
|
onChange={(e) => setBaseSalary(e.target.value.replace(/[^\d]/g, ""))}
|
||||||
|
dir="ltr"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={withLogin} onChange={(e) => setWithLogin(e.target.checked)} className="size-4" />
|
||||||
|
{t("fields.enableLogin")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{withLogin && (
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<LabeledField label={t("fields.username")} htmlFor="emp-username">
|
||||||
|
<Input id="emp-username" value={username} onChange={(e) => setUsername(e.target.value)} dir="ltr" autoComplete="off" />
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("fields.password")} htmlFor="emp-password" hint={t("fields.passwordHint")}>
|
||||||
|
<Input id="emp-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} dir="ltr" autoComplete="new-password" />
|
||||||
|
</LabeledField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose} disabled={createMut.isPending}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => createMut.mutate()} disabled={!canSubmit || createMut.isPending}>
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
||||||
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
|
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
|
||||||
|
import { AddEmployeeForm } from "@/components/hr/add-employee-form";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,7 +50,7 @@ interface Salary {
|
|||||||
isPaid: boolean;
|
isPaid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
|
type Tab = "team" | "attendance" | "leave" | "payroll" | "access" | "credentials";
|
||||||
|
|
||||||
export function HrScreen() {
|
export function HrScreen() {
|
||||||
const t = useTranslations("hr");
|
const t = useTranslations("hr");
|
||||||
@@ -57,7 +59,8 @@ export function HrScreen() {
|
|||||||
const role = useAuthStore((s) => s.user?.role);
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
const canManageAccess = role === "Owner" || role === "Manager";
|
const canManageAccess = role === "Owner" || role === "Manager";
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<Tab>("attendance");
|
const [tab, setTab] = useState<Tab>("team");
|
||||||
|
const [addingEmployee, setAddingEmployee] = useState(false);
|
||||||
const [monthYear, setMonthYear] = useState(
|
const [monthYear, setMonthYear] = useState(
|
||||||
new Date().toISOString().slice(0, 7)
|
new Date().toISOString().slice(0, 7)
|
||||||
);
|
);
|
||||||
@@ -123,7 +126,7 @@ export function HrScreen() {
|
|||||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
{((["team", "attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
||||||
(key) => (key !== "access" && key !== "credentials") || canManageAccess
|
(key) => (key !== "access" && key !== "credentials") || canManageAccess
|
||||||
)).map((key) => (
|
)).map((key) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -137,6 +140,50 @@ export function HrScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "team" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{canManageAccess && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{addingEmployee ? null : (
|
||||||
|
<Button size="sm" onClick={() => setAddingEmployee(true)}>
|
||||||
|
<UserPlus className="me-1.5 size-4" />
|
||||||
|
{t("addEmployee")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addingEmployee && (
|
||||||
|
<AddEmployeeForm
|
||||||
|
cafeId={cafeId}
|
||||||
|
canAddManager={role === "Owner"}
|
||||||
|
onClose={() => setAddingEmployee(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{employees.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("noEmployees")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{employees.map((e) => (
|
||||||
|
<Card key={e.id}>
|
||||||
|
<CardContent className="space-y-1 pt-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-medium">{e.name}</p>
|
||||||
|
<Badge variant="outline">{t(`roles.${e.role}`)}</Badge>
|
||||||
|
</div>
|
||||||
|
<p dir="ltr" className="text-end font-mono text-xs text-muted-foreground">{e.phone}</p>
|
||||||
|
{e.baseSalary > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{formatCurrency(e.baseSalary)}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "attendance" && (
|
{tab === "attendance" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user