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

View file

@ -19,6 +19,15 @@ public class Employee : TenantEntity
public string? Email { 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 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.Email).HasMaxLength(200);
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.HasMany(x => x.RetailPointAssignments).WithOne(a => a.Employee)
.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>("Email").HasMaxLength(200).HasColumnType("character varying(200)");
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<bool>("IsActive").HasColumnType("boolean");
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 { Button } from '@/components/Button'
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 type { PagedResult, RetailPoint } from '@/lib/types'
import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
@ -24,6 +24,10 @@ interface EmployeeDto {
position: string | null
email: string | null
phone: string | null
salary: number | null
taxNumber: string | null
description: string | null
imageUrl: string | null
roleId: string
roleName: string
isActive: boolean
@ -39,6 +43,10 @@ interface Form {
position: string
email: string
phone: string
salary: string
taxNumber: string
description: string
imageUrl: string
roleId: string
isActive: boolean
retailPointIds: string[]
@ -48,6 +56,7 @@ interface Form {
const blankForm = (): Form => ({
lastName: '', firstName: '', middleName: '', position: '',
email: '', phone: '',
salary: '', taxNumber: '', description: '', imageUrl: '',
roleId: '', isActive: true,
retailPointIds: [],
createAccount: true,
@ -89,6 +98,10 @@ export function EmployeesPage() {
const payload = {
lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || 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,
retailPointIds: form.retailPointIds,
createAccount: !form.id && form.createAccount,
@ -142,6 +155,8 @@ export function EmployeesPage() {
onRowClick={(r) => setForm({
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
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,
createAccount: false,
})}
@ -213,12 +228,42 @@ export function EmployeesPage() {
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field>
</div>
<Field label="Роль *">
<Select value={form.roleId} onChange={(e) => setForm({ ...form, roleId: e.target.value })}>
<option value=""></option>
{roles.data?.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</Select>
<div className="grid grid-cols-2 gap-3">
<Field label="Оклад">
<TextInput type="number" value={form.salary} onChange={(e) => setForm({ ...form, salary: e.target.value })} placeholder="—" />
</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 && (
<div>
<div className="text-sm font-medium mb-1.5">Кассы</div>