feat(employee): add Salary, TaxNumber, Description, ImageUrl + radio role picker
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 40s
CI / Web (React + Vite) (push) Successful in 38s
Docker API / Build + push API (push) Successful in 47s
Docker Web / Build + push Web (push) Successful in 29s
Docker API / Deploy API on stage (push) Successful in 16s
Docker Web / Deploy Web on stage (push) Successful in 11s

Domain Employee расширен 4 nullable-полями (по образу МойСклад):
- Salary numeric(18,2) — оклад в валюте организации
- TaxNumber varchar(20) — ИИН/ИНН
- Description varchar(2000) — комментарий HR'а
- ImageUrl varchar(500) — аватар (на будущее: загрузка через images endpoint
  как у товаров; пока поле для прямой ссылки)

Migration Phase4c_EmployeeExtraFields добавляет 4 nullable колонки
(существующие записи не ломаются). EF config + snapshot обновлены.

API EmployeesController: DTO/Input/Create/Update пробрасывают новые
поля сквозь.

Frontend EmployeesPage:
- Поля «Оклад» и «ИИН/ИНН» рядом, ниже — «Описание» textarea.
- Селект роли заменён на radio-список с описанием каждой роли
  (системные сначала, затем кастомные). Под радио — ссылка
  «Настроить права ролей →» на /settings/employee-roles. Это
  по образу МС — пользователь сразу видит за что отвечает каждая
  роль и куда идти если нужно подкрутить.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:48:27 +05:00
parent 080564f2b2
commit 77afcdccd0
7 changed files with 2130 additions and 6 deletions

View file

@ -27,6 +27,7 @@ public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<U
public record EmployeeDto( public record EmployeeDto(
Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName, Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone, string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName, Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt, bool IsActive, DateTime? FiredAt,
IReadOnlyList<Guid> RetailPointIds); IReadOnlyList<Guid> RetailPointIds);
@ -34,6 +35,7 @@ public record EmployeeDto(
public record EmployeeInput( public record EmployeeInput(
string LastName, string FirstName, string? MiddleName, string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone, string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, bool IsActive, Guid RoleId, bool IsActive,
IReadOnlyList<Guid>? RetailPointIds, IReadOnlyList<Guid>? RetailPointIds,
// CreateAccount=true → создаём User c email + temp password. // CreateAccount=true → создаём User c email + temp password.
@ -63,6 +65,7 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
.Select(e => new EmployeeDto( .Select(e => new EmployeeDto(
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName, e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
e.Position, e.Email, e.Phone, e.Position, e.Email, e.Phone,
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, e.IsActive, e.FiredAt,
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList())) e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
@ -90,6 +93,8 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
OrganizationId = orgId, OrganizationId = orgId,
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName, LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
Position = input.Position, Email = input.Email, Phone = input.Phone, Position = input.Position, Email = input.Email, Phone = input.Phone,
Salary = input.Salary, TaxNumber = input.TaxNumber,
Description = input.Description, ImageUrl = input.ImageUrl,
RoleId = input.RoleId, IsActive = input.IsActive, RoleId = input.RoleId, IsActive = input.IsActive,
}; };
@ -143,6 +148,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
e.Position = input.Position; e.Position = input.Position;
e.Email = input.Email; e.Email = input.Email;
e.Phone = input.Phone; e.Phone = input.Phone;
e.Salary = input.Salary;
e.TaxNumber = input.TaxNumber;
e.Description = input.Description;
e.ImageUrl = input.ImageUrl;
e.RoleId = input.RoleId; e.RoleId = input.RoleId;
var nowActive = input.IsActive; var nowActive = input.IsActive;
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow; if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
@ -179,6 +188,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
.Select(e => new EmployeeDto( .Select(e => new EmployeeDto(
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName, e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
e.Position, e.Email, e.Phone, e.Position, e.Email, e.Phone,
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, e.IsActive, e.FiredAt,
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList())) e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))

View file

@ -19,6 +19,15 @@ public class Employee : TenantEntity
public string? Email { get; set; } public string? Email { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
/// <summary>Оклад в валюте организации, опц.</summary>
public decimal? Salary { get; set; }
/// <summary>ИИН/ИНН (12-14 символов), опц.</summary>
public string? TaxNumber { get; set; }
/// <summary>Произвольное описание (комментарий HR'а).</summary>
public string? Description { get; set; }
/// <summary>Аватар сотрудника. URL до файла в общем images-стораж.</summary>
public string? ImageUrl { get; set; }
public Guid RoleId { get; set; } public Guid RoleId { get; set; }
public EmployeeRole Role { get; set; } = null!; public EmployeeRole Role { get; set; } = null!;

View file

@ -27,6 +27,10 @@ public static void ConfigureOrganizationsHr(this ModelBuilder b)
e.Property(x => x.Position).HasMaxLength(150); e.Property(x => x.Position).HasMaxLength(150);
e.Property(x => x.Email).HasMaxLength(200); e.Property(x => x.Email).HasMaxLength(200);
e.Property(x => x.Phone).HasMaxLength(50); e.Property(x => x.Phone).HasMaxLength(50);
e.Property(x => x.Salary).HasPrecision(18, 2);
e.Property(x => x.TaxNumber).HasMaxLength(20);
e.Property(x => x.Description).HasMaxLength(2000);
e.Property(x => x.ImageUrl).HasMaxLength(500);
e.HasOne(x => x.Role).WithMany().HasForeignKey(x => x.RoleId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Role).WithMany().HasForeignKey(x => x.RoleId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.RetailPointAssignments).WithOne(a => a.Employee) e.HasMany(x => x.RetailPointAssignments).WithOne(a => a.Employee)
.HasForeignKey(a => a.EmployeeId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(a => a.EmployeeId).OnDelete(DeleteBehavior.Cascade);

View file

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>employees: Salary numeric(18,2), TaxNumber varchar(20),
/// Description varchar(2000), ImageUrl varchar(500). Все nullable —
/// существующие записи не ломаются.</summary>
public partial class Phase4c_EmployeeExtraFields : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<decimal>(
name: "Salary", schema: "public", table: "employees",
type: "numeric(18,2)", precision: 18, scale: 2, nullable: true);
b.AddColumn<string>(
name: "TaxNumber", schema: "public", table: "employees",
type: "character varying(20)", maxLength: 20, nullable: true);
b.AddColumn<string>(
name: "Description", schema: "public", table: "employees",
type: "character varying(2000)", maxLength: 2000, nullable: true);
b.AddColumn<string>(
name: "ImageUrl", schema: "public", table: "employees",
type: "character varying(500)", maxLength: 500, nullable: true);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "ImageUrl", schema: "public", table: "employees");
b.DropColumn(name: "Description", schema: "public", table: "employees");
b.DropColumn(name: "TaxNumber", schema: "public", table: "employees");
b.DropColumn(name: "Salary", schema: "public", table: "employees");
}
}
}

View file

@ -1959,6 +1959,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("Position").HasMaxLength(150).HasColumnType("character varying(150)"); b.Property<string>("Position").HasMaxLength(150).HasColumnType("character varying(150)");
b.Property<string>("Email").HasMaxLength(200).HasColumnType("character varying(200)"); b.Property<string>("Email").HasMaxLength(200).HasColumnType("character varying(200)");
b.Property<string>("Phone").HasMaxLength(50).HasColumnType("character varying(50)"); b.Property<string>("Phone").HasMaxLength(50).HasColumnType("character varying(50)");
b.Property<decimal?>("Salary").HasPrecision(18, 2).HasColumnType("numeric(18,2)");
b.Property<string>("TaxNumber").HasMaxLength(20).HasColumnType("character varying(20)");
b.Property<string>("Description").HasMaxLength(2000).HasColumnType("character varying(2000)");
b.Property<string>("ImageUrl").HasMaxLength(500).HasColumnType("character varying(500)");
b.Property<Guid>("RoleId").HasColumnType("uuid"); b.Property<Guid>("RoleId").HasColumnType("uuid");
b.Property<bool>("IsActive").HasColumnType("boolean"); b.Property<bool>("IsActive").HasColumnType("boolean");
b.Property<DateTime?>("FiredAt").HasColumnType("timestamp with time zone"); b.Property<DateTime?>("FiredAt").HasColumnType("timestamp with time zone");

View file

@ -8,7 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint } from '@/lib/types' import type { PagedResult, RetailPoint } from '@/lib/types'
import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage' import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
@ -24,6 +24,10 @@ interface EmployeeDto {
position: string | null position: string | null
email: string | null email: string | null
phone: string | null phone: string | null
salary: number | null
taxNumber: string | null
description: string | null
imageUrl: string | null
roleId: string roleId: string
roleName: string roleName: string
isActive: boolean isActive: boolean
@ -39,6 +43,10 @@ interface Form {
position: string position: string
email: string email: string
phone: string phone: string
salary: string
taxNumber: string
description: string
imageUrl: string
roleId: string roleId: string
isActive: boolean isActive: boolean
retailPointIds: string[] retailPointIds: string[]
@ -48,6 +56,7 @@ interface Form {
const blankForm = (): Form => ({ const blankForm = (): Form => ({
lastName: '', firstName: '', middleName: '', position: '', lastName: '', firstName: '', middleName: '', position: '',
email: '', phone: '', email: '', phone: '',
salary: '', taxNumber: '', description: '', imageUrl: '',
roleId: '', isActive: true, roleId: '', isActive: true,
retailPointIds: [], retailPointIds: [],
createAccount: true, createAccount: true,
@ -89,6 +98,10 @@ export function EmployeesPage() {
const payload = { const payload = {
lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || null, lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || null,
position: form.position || null, email: form.email || null, phone: form.phone || null, position: form.position || null, email: form.email || null, phone: form.phone || null,
salary: form.salary ? Number(form.salary) : null,
taxNumber: form.taxNumber || null,
description: form.description || null,
imageUrl: form.imageUrl || null,
roleId: form.roleId, isActive: form.isActive, roleId: form.roleId, isActive: form.isActive,
retailPointIds: form.retailPointIds, retailPointIds: form.retailPointIds,
createAccount: !form.id && form.createAccount, createAccount: !form.id && form.createAccount,
@ -142,6 +155,8 @@ export function EmployeesPage() {
onRowClick={(r) => setForm({ onRowClick={(r) => setForm({
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '', id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '', position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
salary: r.salary != null ? String(r.salary) : '',
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds, roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false, createAccount: false,
})} })}
@ -213,12 +228,42 @@ export function EmployeesPage() {
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /> <TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field> </Field>
</div> </div>
<Field label="Роль *"> <div className="grid grid-cols-2 gap-3">
<Select value={form.roleId} onChange={(e) => setForm({ ...form, roleId: e.target.value })}> <Field label="Оклад">
<option value=""></option> <TextInput type="number" value={form.salary} onChange={(e) => setForm({ ...form, salary: e.target.value })} placeholder="—" />
{roles.data?.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</Select>
</Field> </Field>
<Field label="ИИН/ИНН">
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} placeholder="12-14 цифр" maxLength={20} />
</Field>
</div>
<Field label="Описание / комментарий">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<div>
<div className="text-sm font-medium mb-2">Роль *</div>
<div className="border border-slate-200 dark:border-slate-700 rounded-md divide-y divide-slate-100 dark:divide-slate-800 max-h-72 overflow-auto">
{/* Системные сначала, потом кастомные. */}
{roles.data?.slice().sort((a, b) => Number(b.isSystem) - Number(a.isSystem)).map((r) => (
<label key={r.id} className="flex items-start gap-2 p-2.5 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/40">
<input
type="radio"
name="role"
checked={form.roleId === r.id}
onChange={() => setForm({ ...form, roleId: r.id })}
className="mt-1"
/>
<span className="flex-1 min-w-0">
<span className="font-medium">{r.name}</span>
{r.isSystem && <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800">системная</span>}
{r.description && <div className="text-xs text-slate-500 mt-0.5">{r.description}</div>}
</span>
</label>
))}
</div>
<a href="/settings/employee-roles" className="text-xs text-[var(--color-brand)] hover:underline mt-1.5 inline-block">
Настроить права ролей
</a>
</div>
{isCashier && ( {isCashier && (
<div> <div>
<div className="text-sm font-medium mb-1.5">Кассы</div> <div className="text-sm font-medium mb-1.5">Кассы</div>