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
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:
parent
080564f2b2
commit
77afcdccd0
|
|
@ -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()))
|
||||||
|
|
|
||||||
|
|
@ -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!;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
2016
src/food-market.infrastructure/Persistence/Migrations/20260427030000_Phase4c_EmployeeExtraFields.Designer.cs
generated
Normal file
2016
src/food-market.infrastructure/Persistence/Migrations/20260427030000_Phase4c_EmployeeExtraFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue