chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start - apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api - docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web) - .dockerignore / .gitignore / .gitattributes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,493 @@
|
||||
# Inventory (Vestimentație & Echipament) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Заменить свободные String-поля в `Benefit` на FK к новой модели `InventoryItem` со складским учётом, добавить страницу `/inventory` для CRUD и логику атомарного уменьшения остатков при выдаче.
|
||||
|
||||
**Architecture:** Новая Prisma модель + NestJS модуль (`inventory`) + React страница + перезапись `BenefitDrawer/BeneficiiTab` + `$transaction` для stock adjust в `BenefitService`.
|
||||
|
||||
**Tech Stack:** NestJS 10 + Prisma 5 + React 18 + Mantine v7 + TanStack Query v5
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Prisma migration — InventoryItem model
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/prisma/schema.prisma`
|
||||
- Create: `apps/api/prisma/migrations/20260512000000_add_inventory/migration.sql`
|
||||
|
||||
- [ ] Step 1: Добавить enum + model в schema.prisma после `Benefit`:
|
||||
|
||||
```prisma
|
||||
enum InventoryItemType {
|
||||
uniforma
|
||||
halat
|
||||
ciupici
|
||||
vesta
|
||||
aparat_telefon
|
||||
alte
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id String @id @default(uuid())
|
||||
sku String @unique
|
||||
name String
|
||||
type InventoryItemType
|
||||
size String?
|
||||
color String?
|
||||
pricePerUnit Decimal? @db.Decimal(10, 2)
|
||||
stockQty Int @default(0)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
uniformaBenefits Benefit[] @relation("BenefitUniforma")
|
||||
halatBenefits Benefit[] @relation("BenefitHalat")
|
||||
ciupiciBenefits Benefit[] @relation("BenefitCiupici")
|
||||
vestaBenefits Benefit[] @relation("BenefitVesta")
|
||||
aparatTelBenefits Benefit[] @relation("BenefitAparatTel")
|
||||
|
||||
@@index([type, active])
|
||||
@@map("inventory_items")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 2: Изменить `Benefit` — заменить `uniformaId String?` и т.д. на FK:
|
||||
|
||||
```prisma
|
||||
model Benefit {
|
||||
id String @id @default(uuid())
|
||||
employeeId String @unique
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
|
||||
uniformaId String?
|
||||
uniforma InventoryItem? @relation("BenefitUniforma", fields: [uniformaId], references: [id])
|
||||
halatId String?
|
||||
halat InventoryItem? @relation("BenefitHalat", fields: [halatId], references: [id])
|
||||
ciupiciId String?
|
||||
ciupici InventoryItem? @relation("BenefitCiupici", fields: [ciupiciId], references: [id])
|
||||
vestaId String?
|
||||
vesta InventoryItem? @relation("BenefitVesta", fields: [vestaId], references: [id])
|
||||
aparatTelefonId String?
|
||||
aparatTelefon InventoryItem? @relation("BenefitAparatTel", fields: [aparatTelefonId], references: [id])
|
||||
|
||||
ticheteMasa Boolean @default(false)
|
||||
valoareTichet Decimal? @db.Decimal(10, 2)
|
||||
alimentatiePersonal Boolean @default(false)
|
||||
abonamentTel Decimal? @db.Decimal(10, 2)
|
||||
cardCompanie String?
|
||||
automobilServiciu String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("benefits")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 3: Создать SQL миграцию вручную (т.к. `migrate dev` не работает в non-interactive shell):
|
||||
|
||||
```sql
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InventoryItemType" AS ENUM ('uniforma', 'halat', 'ciupici', 'vesta', 'aparat_telefon', 'alte');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "inventory_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sku" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "InventoryItemType" NOT NULL,
|
||||
"size" TEXT,
|
||||
"color" TEXT,
|
||||
"pricePerUnit" DECIMAL(10,2),
|
||||
"stockQty" INTEGER NOT NULL DEFAULT 0,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "inventory_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "inventory_items_sku_key" ON "inventory_items"("sku");
|
||||
CREATE INDEX "inventory_items_type_active_idx" ON "inventory_items"("type", "active");
|
||||
|
||||
-- AlterTable: обнуляем старые свободные текстовые ID и добавляем FK
|
||||
ALTER TABLE "benefits" ALTER COLUMN "uniformaId" DROP NOT NULL;
|
||||
UPDATE "benefits" SET "uniformaId" = NULL WHERE "uniformaId" IS NOT NULL AND "uniformaId" NOT IN (SELECT "id" FROM "inventory_items");
|
||||
UPDATE "benefits" SET "halatId" = NULL WHERE "halatId" IS NOT NULL AND "halatId" NOT IN (SELECT "id" FROM "inventory_items");
|
||||
UPDATE "benefits" SET "ciupiciId" = NULL WHERE "ciupiciId" IS NOT NULL AND "ciupiciId" NOT IN (SELECT "id" FROM "inventory_items");
|
||||
UPDATE "benefits" SET "vestaId" = NULL WHERE "vestaId" IS NOT NULL AND "vestaId" NOT IN (SELECT "id" FROM "inventory_items");
|
||||
UPDATE "benefits" SET "aparatTelefonId" = NULL WHERE "aparatTelefonId" IS NOT NULL AND "aparatTelefonId" NOT IN (SELECT "id" FROM "inventory_items");
|
||||
|
||||
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_uniformaId_fkey" FOREIGN KEY ("uniformaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_halatId_fkey" FOREIGN KEY ("halatId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_ciupiciId_fkey" FOREIGN KEY ("ciupiciId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_vestaId_fkey" FOREIGN KEY ("vestaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_aparatTelefonId_fkey" FOREIGN KEY ("aparatTelefonId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
```
|
||||
|
||||
- [ ] Step 4: Применить миграцию + регенерировать клиент:
|
||||
|
||||
```bash
|
||||
pnpm --filter api exec prisma migrate deploy
|
||||
pnpm --filter api exec prisma generate
|
||||
```
|
||||
|
||||
- [ ] Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260512000000_add_inventory/
|
||||
git commit -m "feat(inventory): add InventoryItem model + FK from Benefit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Inventory module (NestJS)
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/modules/inventory/inventory.module.ts`
|
||||
- Create: `apps/api/src/modules/inventory/inventory.controller.ts`
|
||||
- Create: `apps/api/src/modules/inventory/inventory.service.ts`
|
||||
- Create: `apps/api/src/modules/inventory/dto/create-inventory.dto.ts`
|
||||
- Create: `apps/api/src/modules/inventory/dto/update-inventory.dto.ts`
|
||||
- Create: `apps/api/src/modules/inventory/dto/list-query.dto.ts`
|
||||
- Create: `apps/api/src/modules/inventory/dto/adjust-stock.dto.ts`
|
||||
- Modify: `apps/api/src/app.module.ts` — импортировать `InventoryModule`
|
||||
|
||||
- [ ] Step 1: DTO с `class-validator`:
|
||||
|
||||
```typescript
|
||||
// create-inventory.dto.ts
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { InventoryItemType } from '@prisma/client';
|
||||
|
||||
export class CreateInventoryDto {
|
||||
@IsString() sku!: string;
|
||||
@IsString() name!: string;
|
||||
@IsEnum(InventoryItemType) type!: InventoryItemType;
|
||||
@IsOptional() @IsString() size?: string;
|
||||
@IsOptional() @IsString() color?: string;
|
||||
@IsOptional() @IsNumber() pricePerUnit?: number;
|
||||
@IsNumber() @Min(0) stockQty!: number;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// adjust-stock.dto.ts
|
||||
export class AdjustStockDto {
|
||||
@IsNumber() delta!: number; // +10, -5
|
||||
@IsString() reason!: string;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// list-query.dto.ts
|
||||
export class InventoryQueryDto {
|
||||
@IsOptional() @IsEnum(InventoryItemType) type?: InventoryItemType;
|
||||
@IsOptional() @Type(() => Boolean) @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() search?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() page?: number = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() limit?: number = 50;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 2: Service:
|
||||
|
||||
```typescript
|
||||
// inventory.service.ts
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {}
|
||||
|
||||
async list(q: InventoryQueryDto, userId: string, role: string) {
|
||||
const where: Prisma.InventoryItemWhereInput = {};
|
||||
if (q.type) where.type = q.type;
|
||||
if (q.active !== undefined) where.active = q.active;
|
||||
if (q.search) {
|
||||
where.OR = [
|
||||
{ sku: { contains: q.search, mode: 'insensitive' } },
|
||||
{ name: { contains: q.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
const limit = Math.min(q.limit ?? 50, 200);
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.inventoryItem.count({ where }),
|
||||
this.prisma.inventoryItem.findMany({ where, orderBy: { name: 'asc' }, skip: ((q.page ?? 1) - 1) * limit, take: limit }),
|
||||
]);
|
||||
await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: 'LIST' });
|
||||
return { total, page: q.page ?? 1, limit, items };
|
||||
}
|
||||
|
||||
async create(dto: CreateInventoryDto, userId: string, role: string) {
|
||||
const item = await this.prisma.inventoryItem.create({ data: dto });
|
||||
await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'InventoryItem', entityId: item.id });
|
||||
return item;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateInventoryDto, userId: string, role: string) {
|
||||
const item = await this.prisma.inventoryItem.update({ where: { id }, data: dto });
|
||||
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id });
|
||||
return item;
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string, role: string) {
|
||||
const used = await this.prisma.benefit.count({
|
||||
where: { OR: [
|
||||
{ uniformaId: id }, { halatId: id }, { ciupiciId: id }, { vestaId: id }, { aparatTelefonId: id },
|
||||
]},
|
||||
});
|
||||
if (used > 0) {
|
||||
// soft delete
|
||||
await this.prisma.inventoryItem.update({ where: { id }, data: { active: false } });
|
||||
await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id, field: 'active', newValue: 'false' });
|
||||
return { softDeleted: true };
|
||||
}
|
||||
await this.prisma.inventoryItem.delete({ where: { id } });
|
||||
await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'InventoryItem', entityId: id });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async adjustStock(id: string, dto: AdjustStockDto, userId: string, role: string) {
|
||||
const item = await this.prisma.inventoryItem.update({
|
||||
where: { id },
|
||||
data: { stockQty: { increment: dto.delta } },
|
||||
});
|
||||
if (item.stockQty < 0) throw new BadRequestException('Stoc negativ nu este permis');
|
||||
await this.audit.logChange({
|
||||
userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id,
|
||||
field: 'stockQty', newValue: String(item.stockQty), reason: dto.reason,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 3: Controller:
|
||||
|
||||
```typescript
|
||||
@Controller('inventory')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class InventoryController {
|
||||
constructor(private readonly svc: InventoryService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('hr_admin', 'hr_specialist')
|
||||
list(@Query() q: InventoryQueryDto, @Request() req: AuthReq) {
|
||||
return this.svc.list(q, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('hr_admin')
|
||||
create(@Body() dto: CreateInventoryDto, @Request() req: AuthReq) {
|
||||
return this.svc.create(dto, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('hr_admin')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateInventoryDto, @Request() req: AuthReq) {
|
||||
return this.svc.update(id, dto, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('hr_admin')
|
||||
remove(@Param('id') id: string, @Request() req: AuthReq) {
|
||||
return this.svc.remove(id, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Post(':id/adjust-stock')
|
||||
@Roles('hr_admin')
|
||||
adjust(@Param('id') id: string, @Body() dto: AdjustStockDto, @Request() req: AuthReq) {
|
||||
return this.svc.adjustStock(id, dto, req.user.id, req.user.role);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 4: Module + регистрация в `app.module.ts`. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Atomic stock adjust в BenefitService
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/modules/employees/sub-resources/benefit.service.ts` (или где сейчас upsert логика)
|
||||
|
||||
- [ ] Step 1: Переписать `upsert(employeeId, dto)`:
|
||||
|
||||
```typescript
|
||||
async upsert(employeeId: string, dto: UpsertBenefitDto, userId: string, role: string) {
|
||||
const old = await this.prisma.benefit.findUnique({ where: { employeeId } });
|
||||
|
||||
const fields: Array<keyof Pick<UpsertBenefitDto, 'uniformaId'|'halatId'|'ciupiciId'|'vestaId'|'aparatTelefonId'>> = [
|
||||
'uniformaId', 'halatId', 'ciupiciId', 'vestaId', 'aparatTelefonId',
|
||||
];
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
for (const f of fields) {
|
||||
const oldId = old?.[f] ?? null;
|
||||
const newId = dto[f] ?? null;
|
||||
if (oldId === newId) continue;
|
||||
if (oldId) await tx.inventoryItem.update({ where: { id: oldId }, data: { stockQty: { increment: 1 } } });
|
||||
if (newId) {
|
||||
const item = await tx.inventoryItem.update({ where: { id: newId }, data: { stockQty: { decrement: 1 } } });
|
||||
if (item.stockQty < 0) throw new BadRequestException(`Stoc epuizat pentru ${f}`);
|
||||
}
|
||||
}
|
||||
const benefit = await tx.benefit.upsert({
|
||||
where: { employeeId },
|
||||
create: { employeeId, ...dto },
|
||||
update: dto,
|
||||
});
|
||||
return benefit;
|
||||
}).then(async (b) => {
|
||||
await this.audit.logChange({ userId, userRole: role, action: old ? 'UPDATE' : 'CREATE', entity: 'Benefit', entityId: b.id });
|
||||
return b;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 2: Тест mental: dryRun со старым null+новым null — пропустить; oldA→newB — A++, B--.
|
||||
- [ ] Step 3: Commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Seed data + frontend types
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/prisma/seed.ts` — добавить ~15 предметов
|
||||
- Modify: `apps/web/src/api/types.ts` — `InventoryItem`, `InventoryItemType`, `Benefit` (с relation полями)
|
||||
|
||||
- [ ] Step 1: Seed (4 uniforme + 4 halate + 3 ciupici + 2 veste + 2 aparate, у каждого `stockQty: 50`):
|
||||
|
||||
```typescript
|
||||
const inventory = [
|
||||
{ sku: 'UN-CHIR-M-AL', name: 'Uniformă chirurgie M albastru', type: 'uniforma', size: 'M', color: 'albastru', stockQty: 50 },
|
||||
{ sku: 'UN-CHIR-L-AL', name: 'Uniformă chirurgie L albastru', type: 'uniforma', size: 'L', color: 'albastru', stockQty: 50 },
|
||||
// ... ещё 13
|
||||
];
|
||||
for (const i of inventory) {
|
||||
await prisma.inventoryItem.upsert({ where: { sku: i.sku }, create: i, update: {} });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 2: Frontend types:
|
||||
|
||||
```typescript
|
||||
export type InventoryItemType = 'uniforma' | 'halat' | 'ciupici' | 'vesta' | 'aparat_telefon' | 'alte';
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
type: InventoryItemType;
|
||||
size: string | null;
|
||||
color: string | null;
|
||||
pricePerUnit: string | null; // Decimal serialized
|
||||
stockQty: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedInventory { total: number; page: number; limit: number; items: InventoryItem[]; }
|
||||
|
||||
export interface Benefit {
|
||||
// ... existing
|
||||
uniformaId: string | null;
|
||||
uniforma: InventoryItem | null;
|
||||
halatId: string | null;
|
||||
halat: InventoryItem | null;
|
||||
ciupiciId: string | null;
|
||||
ciupici: InventoryItem | null;
|
||||
vestaId: string | null;
|
||||
vesta: InventoryItem | null;
|
||||
aparatTelefonId: string | null;
|
||||
aparatTelefon: InventoryItem | null;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Step 3: Run seed: `pnpm --filter api prisma:seed`. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: InventoryPage + Drawer
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/inventory/InventoryPage.tsx`
|
||||
- Create: `apps/web/src/pages/inventory/InventoryDrawer.tsx`
|
||||
- Create: `apps/web/src/pages/inventory/StockAdjustModal.tsx`
|
||||
|
||||
- [ ] Step 1: `InventoryPage` — таблица + filter bar (search, type select, active switch) + кнопка «Adaugă articol». Stoc=0 → red row, <5 → amber.
|
||||
- [ ] Step 2: `InventoryDrawer` — react-hook-form + Zod, поля SKU/Denumire/Tip/Mărime/Culoare/Preț/Stoc inițial/Active. POST/PATCH через axios.
|
||||
- [ ] Step 3: `StockAdjustModal` — `<NumberInput delta>` + `<Textarea reason>`, POST `/inventory/:id/adjust-stock`.
|
||||
- [ ] Step 4: Commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire в App.tsx + nav
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
|
||||
- [ ] Step 1: Импорт `IconBox` + `InventoryPage`.
|
||||
- [ ] Step 2: Добавить в `NAV_ITEMS`:
|
||||
```ts
|
||||
{ labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox size={20} stroke={1.6} color="#008286" />, roles: ['hr_admin', 'hr_specialist'] }
|
||||
```
|
||||
- [ ] Step 3: Route: `<Route path="/inventory" element={<InventoryPage />} />`
|
||||
- [ ] Step 4: i18n key `nav.inventory: "Inventar"`.
|
||||
- [ ] Step 5: Commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Перезаписать BenefitDrawer + BeneficiiTab
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/employees/drawers/BenefitDrawer.tsx`
|
||||
- Modify: `apps/web/src/pages/employees/tabs/BeneficiiTab.tsx`
|
||||
|
||||
- [ ] Step 1: В `BenefitDrawer` заменить 5 `<TextInput>` на `<Select searchable>`:
|
||||
|
||||
```typescript
|
||||
const { data: uniforme } = useQuery({
|
||||
queryKey: ['inventory', 'uniforma'],
|
||||
queryFn: () => apiClient.get('/inventory', { params: { type: 'uniforma', active: true, limit: 200 } }).then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
<Controller name="uniformaId" control={control} render={({ field }) => (
|
||||
<Select
|
||||
label="Uniformă"
|
||||
data={(uniforme?.items ?? []).map((it: InventoryItem) => ({
|
||||
value: it.id,
|
||||
label: `${it.sku} — ${it.name}${it.size ? ` (${it.size})` : ''} · stoc: ${it.stockQty}`,
|
||||
disabled: it.stockQty === 0 && it.id !== field.value,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
{...field}
|
||||
/>
|
||||
)} />
|
||||
```
|
||||
|
||||
Повторить для halat / ciupici / vesta / aparatTelefon.
|
||||
|
||||
- [ ] Step 2: В `BeneficiiTab` заменить `BField label="Uniformă (ID)" value={benefit.uniformaId}` на:
|
||||
|
||||
```tsx
|
||||
<BField label="Uniformă" value={benefit.uniforma ? `${benefit.uniforma.sku} — ${benefit.uniforma.name}` : null} />
|
||||
```
|
||||
|
||||
- [ ] Step 3: На бэке в `benefit.service.ts` добавить `include: { uniforma: true, halat: true, ciupici: true, vesta: true, aparatTelefon: true }` во все find-методы.
|
||||
|
||||
- [ ] Step 4: Commit.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist (после написания)
|
||||
|
||||
- [ ] Все ID полей в Benefit стали FK
|
||||
- [ ] Atomic stock в `$transaction`, не race condition
|
||||
- [ ] Soft delete если `InventoryItem` используется
|
||||
- [ ] Seed создаёт ~15 предметов
|
||||
- [ ] Drawer disable=true при stockQty=0 (кроме уже выбранного)
|
||||
- [ ] Audit log для CRUD + adjust-stock
|
||||
- [ ] Все эндпоинты под `/api/v1/`
|
||||
@@ -0,0 +1,249 @@
|
||||
# Design: Contracts UI, Seed Data, Anexa Template Editor
|
||||
|
||||
**Date:** 2026-05-08
|
||||
**Scope:** Three independent features added on top of the existing HRM Medpark monorepo (NestJS API + React/Vite/Mantine web).
|
||||
|
||||
---
|
||||
|
||||
## 1. Contracts UI
|
||||
|
||||
### Goal
|
||||
A standalone **«Contracte»** section in the main navigation showing all employment contracts across all employees, with expiry tracking for determinata contracts.
|
||||
|
||||
### What already exists
|
||||
- `contracts.controller.ts` and `contracts.service.ts` in `apps/api/src/modules/employees/sub-resources/`
|
||||
- `ContractDrawer.tsx` and `ContracteTab.tsx` in the web app (employee-scoped)
|
||||
- Prisma model `EmploymentContract` with all fields from ТЗ §3.2
|
||||
|
||||
### New frontend: `/contracts` page
|
||||
|
||||
**List view (`ContractsPage.tsx`):**
|
||||
- Table columns: Nr. CIM, Angajat (link to employee), Funcția, Secția, Tip perioadă, Data angajării, Data terminării, Salarizare, Status
|
||||
- Status badge: `activ` (teal) / `expirat` (red) / `expiră în curând` (amber — ≤30 days for determinata)
|
||||
- Filters (top bar): Departament (select), Tip perioadă (determinata / nedeterminata / replasare_temporara), Status (activ / expirat / în_curând), search by employee name
|
||||
- Click any row → opens `ContractFormDrawer` (reuses existing `ContractDrawer.tsx` logic, extended)
|
||||
- "Adaugă contract" button → same drawer with empty form + employee search field
|
||||
|
||||
**Contract form drawer (extended):**
|
||||
- Fields: `nr_cim`, `categorie` (principal/secundar), `data_semnarii`, `data_angajarii`, `data_demisiei`
|
||||
- `perioada` radio: determinata / nedeterminata / replasare_temporara
|
||||
- If `determinata` → show `data_terminarii` date field
|
||||
- `functia_organigrama`, `cod_functie`, `functia_clasificator` (CORM)
|
||||
- `tip_cim` (de_baza / cumul), `sectia_id` (department select), `regim_munca`
|
||||
- `tip_salarizare` radio: fix / pe_ore / in_acord
|
||||
- **fix** → `suma`, `valuta`, `grafic_id` (WorkSchedule select), `ore_saptaminal`, `zile_concediu`
|
||||
- **pe_ore** → `tarif_ora`, `zile_concediu`
|
||||
- **in_acord** → `grafic_id`, `ore_saptaminal`, `zile_concediu`, `suma_fix`, `valuta`
|
||||
- `clauza_aditionala` (optional): `suma` + `valuta`
|
||||
- `categorii_servicii` table: up to 6 rows, each: categorie_id, tip_remunerare (tarif/procent), suma_net or procent
|
||||
|
||||
**New API endpoint needed:**
|
||||
- `GET /api/v1/contracts` — paginated list of ALL contracts across employees, with filters: `departmentId`, `perioada`, `status` (active/expired/expiring), `search`
|
||||
- Reuses existing per-employee endpoints for create/update/delete
|
||||
|
||||
**Business rule display:**
|
||||
- If employee has multiple contracts, show vacation days as MAX across contracts (read-only badge on drawer header)
|
||||
|
||||
### Navigation
|
||||
Add `{ labelKey: 'nav.contracts', path: '/contracts', icon: IconFileDescription }` to `NAV_ITEMS` in `App.tsx`. Roles: `hr_admin`, `hr_specialist`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Seed Data
|
||||
|
||||
### Goal
|
||||
Populate reference tables that are currently empty, blocking form dropdowns.
|
||||
|
||||
### WorkSchedule presets (4 records)
|
||||
| name | description | hoursPerDay | daysOn | daysOff |
|
||||
|------|-------------|-------------|--------|---------|
|
||||
| Standard 5/2 | Luni–Vineri, 8h | 8 | 5 | 2 |
|
||||
| Tură 7/7 | 12h, zi/noapte alternant | 12 | 7 | 7 |
|
||||
| Ambulatoriu 5/2 | 12h zi | 12 | 5 | 2 |
|
||||
| Gardă 24/72 | 24h gardă, 72h repaus | 24 | 1 | 3 |
|
||||
|
||||
### DisabilityGrade (4 records)
|
||||
| code | name |
|
||||
|------|------|
|
||||
| NONE | Fără dizabilitate |
|
||||
| GR_I | Dizabilitate severă (gr. I) |
|
||||
| GR_II | Dizabilitate accentuată (gr. II) |
|
||||
| GR_III | Dizabilitate medie (gr. III) |
|
||||
|
||||
### TaxExemption — IRS Moldova (4 records)
|
||||
| code | name | annualAmount |
|
||||
|------|------|-------------|
|
||||
| SP | Scutire personală | 27000 |
|
||||
| SPM | Scutire personală majoră | 34500 |
|
||||
| SS | Scutire pentru soț/soție | 27000 |
|
||||
| SI | Scutire pentru persoane întreținute (copil) | 9000 |
|
||||
|
||||
### Departments tree (placeholder structure)
|
||||
```
|
||||
Medpark International Hospital (root)
|
||||
├── Nursing
|
||||
│ ├── Nursing Anestezie
|
||||
│ ├── Nursing Terapie
|
||||
│ ├── Nursing ATI
|
||||
│ └── Nursing Chirurgie
|
||||
├── Medicină
|
||||
│ ├── Chirurgie
|
||||
│ ├── Terapie
|
||||
│ └── Anestezie-Reanimare
|
||||
└── Administrativ
|
||||
├── Resurse Umane
|
||||
├── Calitate
|
||||
└── Financiar-Contabilitate
|
||||
```
|
||||
These are placeholder names — HR admin will edit the tree in the Departments UI.
|
||||
|
||||
### Delivery
|
||||
All seed data added to `apps/api/prisma/seed.ts`, idempotent (upsert by code/name). Run via `pnpm db:seed`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Anexa Template Editor
|
||||
|
||||
### Goal
|
||||
An `hr_admin`-only section where staff can edit the text and structure of Anexa 3, 4, 4B, and 6 DOCX templates directly in the browser (rich-text, Word-like), with a live preview populated by test employee data.
|
||||
|
||||
### Data model — new Prisma models
|
||||
|
||||
```prisma
|
||||
model AnexaTemplate {
|
||||
id String @id @default(uuid())
|
||||
type AnexaType @unique // ANEXA_3 | ANEXA_4 | ANEXA_4B | ANEXA_6
|
||||
name String
|
||||
contentJson Json // TipTap JSON document
|
||||
updatedById String
|
||||
updatedAt DateTime @updatedAt
|
||||
versions AnexaTemplateVersion[]
|
||||
}
|
||||
|
||||
model AnexaTemplateVersion {
|
||||
id String @id @default(uuid())
|
||||
templateId String
|
||||
template AnexaTemplate @relation(fields: [templateId], references: [id])
|
||||
contentJson Json
|
||||
savedById String
|
||||
savedAt DateTime @default(now())
|
||||
label String? // optional human label e.g. "Versiunea mai 2026"
|
||||
}
|
||||
|
||||
enum AnexaType {
|
||||
ANEXA_3
|
||||
ANEXA_4
|
||||
ANEXA_4B
|
||||
ANEXA_6
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoints (new NestJS module: `AnexaTemplatesModule`)
|
||||
|
||||
| Method | Path | Role | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/api/v1/admin/anexa-templates` | hr_admin | List all 4 templates (metadata only) |
|
||||
| GET | `/api/v1/admin/anexa-templates/:type` | hr_admin | Full template with contentJson |
|
||||
| PUT | `/api/v1/admin/anexa-templates/:type` | hr_admin | Save template + auto-create version |
|
||||
| GET | `/api/v1/admin/anexa-templates/:type/versions` | hr_admin | List version history |
|
||||
| POST | `/api/v1/admin/anexa-templates/:type/restore/:versionId` | hr_admin | Restore a version |
|
||||
| GET | `/api/v1/admin/anexa-templates/preview-employee` | hr_admin | Get a test employee for preview |
|
||||
|
||||
### Frontend pages
|
||||
|
||||
**`/admin/templates` — template list:**
|
||||
- 4 cards, one per Anexa, showing name + last updated + updatedBy
|
||||
- Click → `/admin/templates/:type`
|
||||
|
||||
**`/admin/templates/:type` — two-panel editor:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [Teal topbar] Anexa 3 — Fișa de solicitare [↺ Reset] [💾 Salvează] │
|
||||
├────────────────────────────────┬────────────────────────────┤
|
||||
│ [Formatting toolbar] │ │
|
||||
│ B I U | ≡ ⊞Tabel | +Angajat +Companie +Data │ │
|
||||
├────────────────────────────────┤ │
|
||||
│ │ 👁 Preview │
|
||||
│ LEFT: TipTap editor │ [Schimbă angajatul test] │
|
||||
│ with variable chips │ │
|
||||
│ │ RIGHT: HTML render of │
|
||||
│ teal chip = employee data │ same doc with real values │
|
||||
│ blue chip = document data │ substituted in │
|
||||
│ orange chip = loop row vars │ │
|
||||
└────────────────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
**Variable chip system:**
|
||||
- Custom TipTap Node extension `VariableChip`: non-editable inline atom, rendered as colored pill
|
||||
- Stored in JSON as `{ type: "variableChip", attrs: { key: "employee.lastName", label: "Numele" } }`
|
||||
- Color coding: `employee.*` → teal, `document.*` → indigo, `row.*` → orange (loop vars inside table rows)
|
||||
- Sidebar panel (collapsible): grouped list of all available variables with "Insert" button
|
||||
|
||||
**Variable namespace (initial set):**
|
||||
|
||||
| Key | Label | Context |
|
||||
|-----|-------|---------|
|
||||
| `company.name` | Denumirea unității | document |
|
||||
| `company.idno` | IDNO | document |
|
||||
| `company.address` | Adresa | document |
|
||||
| `document.date` | Data solicitării | document |
|
||||
| `document.number` | Numărul documentului | document |
|
||||
| `employee.lastName` | Numele | employee |
|
||||
| `employee.firstName` | Prenumele | employee |
|
||||
| `employee.idnp` | IDNP | employee |
|
||||
| `employee.birthYear` | Anul nașterii | employee |
|
||||
| `employee.occupation` | Ocupația (CORM) | employee |
|
||||
| `employee.department` | Secția | employee |
|
||||
| `row.index` | Nr. crt. | loop row |
|
||||
| `row.seatNumber` | Nr. loc de muncă | loop row |
|
||||
| `row.employeeName` | Numele angajatului | loop row |
|
||||
| `row.riskFactors` | Factorii de risc | loop row |
|
||||
| `radiation.externalMsv` | Doza externă (mSv) | radiation |
|
||||
| `radiation.internalMsv` | Doza internă (mSv) | radiation |
|
||||
|
||||
**Preview panel:**
|
||||
- A second read-only TipTap instance with a `VariableChipPreview` node extension — identical to `VariableChip` but renders the resolved value (e.g. "Ionescu") instead of the chip pill
|
||||
- The editor document is synced from the left panel's JSON on every change (`editor.on('update', syncPreview)`)
|
||||
- Test employee data is fetched once via `GET /admin/anexa-templates/preview-employee` and stored in React state; variable keys are resolved against this object at render time inside the node extension
|
||||
- "Schimbă angajatul test" → opens employee search modal, replaces preview data, re-renders
|
||||
- Preview updates in real time as user types (no debounce needed — TipTap JSON sync is cheap)
|
||||
|
||||
**Version history:**
|
||||
- "Versiuni" button in topbar → right panel slides to show version list
|
||||
- Each version: timestamp, saved by, optional label, "Restaurează" button
|
||||
|
||||
### DOCX generation integration
|
||||
|
||||
The existing `document-generator.service.ts` currently builds DOCX programmatically. After this feature, it will:
|
||||
1. Load `AnexaTemplate.contentJson` for the requested type
|
||||
2. Walk TipTap JSON AST (recursive)
|
||||
3. Replace `variableChip` nodes with resolved string values
|
||||
4. Convert nodes → `docx` library objects:
|
||||
- `paragraph` → `new Paragraph()`
|
||||
- `table` → `new Table()` with `TableRow`/`TableCell`
|
||||
- `bold`, `italic`, `underline` marks → `TextRun` options
|
||||
- `heading` → `new Paragraph({ heading: HeadingLevel.HEADING_X })`
|
||||
5. Pass to `Packer.toBuffer()` → upload to MinIO
|
||||
|
||||
The converter lives in `apps/api/src/modules/medical/services/tiptap-to-docx.ts`.
|
||||
|
||||
### Seed templates
|
||||
On first run, `seed.ts` seeds each `AnexaTemplate` with a minimal valid TipTap JSON that matches the current programmatic output of `document-generator.service.ts`. This ensures zero regression on existing DOCX generation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Out of scope for this iteration
|
||||
|
||||
- PDF export (DOCX only, printed and signed by hand per ТЗ §9)
|
||||
- Multi-language templates (Romanian only per ТЗ)
|
||||
- Contract DOCX generation (not in ТЗ §3.2 requirements)
|
||||
- Loop block editor (table rows with `row.*` variables are added manually as table rows; the `row.*` chips simply mark which cells repeat — the actual loop logic stays in `tiptap-to-docx.ts`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation order
|
||||
|
||||
1. **Seed data** — no dependencies, unblocks dropdowns immediately
|
||||
2. **Contracts UI** — GET /contracts endpoint + ContractsPage + extended drawer
|
||||
3. **Anexa editor** — Prisma migration → API module → TipTap editor → tiptap-to-docx converter → seed templates
|
||||
@@ -0,0 +1,152 @@
|
||||
# Design: Inventory (Vestimentație & Echipament)
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Scope:** Полноценный модуль склада инвентаря для HR Medpark — заменяет свободные текстовые ID в `Benefit` на FK к таблице `InventoryItem`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
В разделе «Beneficii» сотрудника поля «Uniformă / Halat / Ciupici / Vestă / Aparat telefon» сейчас хранятся как свободный текст (`uniformaId String?`). По ТЗ (Rubrici necesare, B121-B131 — «info de la depozit») это должна быть выдача со склада с учётом остатков.
|
||||
|
||||
Модуль `Inventory`:
|
||||
- Реестр товаров склада (SKU, наименование, тип, размер, цвет, цена, остаток).
|
||||
- CRUD для `hr_admin`, read для `hr_specialist`.
|
||||
- В `BenefitDrawer` — `<Select>` с поиском по складу вместо `<TextInput>`.
|
||||
- При выдаче `Benefit` уменьшается `stockQty` (атомарно в транзакции).
|
||||
- При снятии выдачи — возвращается на склад.
|
||||
|
||||
## 2. Out of scope (для первой итерации)
|
||||
|
||||
- Учёт серий/партий с истекающим сроком годности
|
||||
- Списание (write-off) с причиной — пока только повышение/понижение `stockQty` через CRUD
|
||||
- Резервирование за сотрудником без выдачи
|
||||
- Импорт остатков из Excel
|
||||
- История движений (audit идёт через общий `AuditLog`, специальной таблицы движений нет)
|
||||
|
||||
## 3. Data model
|
||||
|
||||
```prisma
|
||||
enum InventoryItemType {
|
||||
uniforma
|
||||
halat
|
||||
ciupici
|
||||
vesta
|
||||
aparat_telefon
|
||||
alte
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id String @id @default(uuid())
|
||||
sku String @unique // артикул
|
||||
name String // "Uniformă chirurgie M albastru"
|
||||
type InventoryItemType
|
||||
size String? // "M", "42"
|
||||
color String?
|
||||
pricePerUnit Decimal? @db.Decimal(10, 2)
|
||||
stockQty Int @default(0)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Обратные связи на Benefit (по типам)
|
||||
uniformaBenefits Benefit[] @relation("BenefitUniforma")
|
||||
halatBenefits Benefit[] @relation("BenefitHalat")
|
||||
ciupiciBenefits Benefit[] @relation("BenefitCiupici")
|
||||
vestaBenefits Benefit[] @relation("BenefitVesta")
|
||||
aparatTelBenefits Benefit[] @relation("BenefitAparatTel")
|
||||
|
||||
@@index([type, active])
|
||||
@@map("inventory_items")
|
||||
}
|
||||
|
||||
// Изменения в Benefit:
|
||||
// - uniformaId String? → FK к InventoryItem (relation "BenefitUniforma")
|
||||
// - halatId, ciupiciId, vestaId, aparatTelefonId — аналогично
|
||||
```
|
||||
|
||||
### Миграция данных
|
||||
|
||||
Существующие свободные String значения в `Benefit.uniformaId/...` теряют связь — поле обнуляется (NULL). Сообщить HR-у в release notes, что прошлые «текстовые» значения нужно перевыбрать из Inventory.
|
||||
|
||||
## 4. API endpoints
|
||||
|
||||
Модуль `apps/api/src/modules/inventory/`:
|
||||
|
||||
| Method | Path | Role | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/api/v1/inventory` | hr_admin, hr_specialist | List + filter (type, search, активные) + pagination |
|
||||
| GET | `/api/v1/inventory/:id` | hr_admin, hr_specialist | Один товар |
|
||||
| POST | `/api/v1/inventory` | hr_admin | Создать |
|
||||
| PATCH | `/api/v1/inventory/:id` | hr_admin | Обновить |
|
||||
| DELETE | `/api/v1/inventory/:id` | hr_admin | Soft delete (`active = false` если есть выдачи, hard delete если их нет) |
|
||||
| POST | `/api/v1/inventory/:id/adjust-stock` | hr_admin | `{ delta: number, reason: string }` — приход / списание |
|
||||
|
||||
Read-методы — `audit.logRead({ entity: 'InventoryItem' })`. Write — `logChange`.
|
||||
|
||||
## 5. Frontend
|
||||
|
||||
### `/inventory` — список
|
||||
- Таблица: SKU / Denumire / Tip / Mărime / Culoare / Stoc / Preț / Acțiuni
|
||||
- Filter bar: `<Select tip>`, `<Switch active only>`, search (SKU+name)
|
||||
- Кнопка «Adaugă articol» → drawer
|
||||
- Highlight: `stockQty === 0` → red row, `stockQty < 5` → amber
|
||||
- Click row → drawer для редактирования
|
||||
|
||||
### Drawer (CRUD)
|
||||
- Поля: SKU, Denumire, Tip (select 6), Mărime, Culoare, Preț unitar (MDL), Stoc inițial, Active toggle
|
||||
- Валидация: SKU unique (handled на бэке), name required
|
||||
|
||||
### `/inventory/:id` — Stock adjust modal
|
||||
- Открывается из строки таблицы или drawer
|
||||
- Inputs: delta (число с +/-), reason (textarea)
|
||||
- Audit log пишется обязательно
|
||||
|
||||
### Изменения в `BenefitDrawer.tsx`
|
||||
- Заменить 5 `<TextInput>` (uniformaId, halatId, ciupiciId, vestaId, aparatTelefonId) на `<Select searchable>` с фильтром по `type`:
|
||||
```ts
|
||||
const { data: uniforme } = useQuery(['inventory', 'uniforma'],
|
||||
() => apiClient.get('/inventory', { params: { type: 'uniforma', active: true } }));
|
||||
```
|
||||
- Подпись опции: `${item.sku} — ${item.name} (${item.size}, stoc: ${item.stockQty})`
|
||||
- Disable опции с `stockQty === 0` (за исключением уже выбранной у текущего сотрудника)
|
||||
|
||||
### Изменения в `BeneficiiTab.tsx`
|
||||
- Резолвить `benefit.uniforma?.name` (через `include` на бэке) вместо ID
|
||||
- Показ: «Uniformă: Uniformă chirurgie M albastru (SKU-001)»
|
||||
|
||||
### Nav
|
||||
- В `App.tsx` → `NAV_ITEMS`: `{ labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox />, roles: ['hr_admin', 'hr_specialist'] }`
|
||||
|
||||
## 6. Бизнес-логика выдачи (atomic stock adjustment)
|
||||
|
||||
В `BenefitService.upsert(employeeId, dto)`:
|
||||
1. Загрузить текущий `Benefit` (если есть) → diff `oldUniformaId vs newUniformaId`
|
||||
2. В транзакции `prisma.$transaction`:
|
||||
- Если `oldUniformaId` сменился: `stockQty++` для старого
|
||||
- Если `newUniformaId` указан: проверить `stockQty > 0`, иначе `BadRequest('Stoc epuizat')`. Затем `stockQty--`
|
||||
3. Upsert Benefit
|
||||
4. Audit log
|
||||
|
||||
Аналогично для всех 5 полей.
|
||||
|
||||
## 7. Seed data
|
||||
|
||||
В `apps/api/prisma/seed.ts`:
|
||||
- 4 модели Uniforme (M/L/XL chirurgie, M/L/XL ATI)
|
||||
- 4 модели Halate
|
||||
- 3 размера Ciupici (38-42, 43-46)
|
||||
- 2 модели Veste (S, M)
|
||||
- 2 модели Aparate telefon (Samsung A15, iPhone SE)
|
||||
|
||||
Каждая со `stockQty: 50`.
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
1. **Prisma migration** — модель `InventoryItem`, FK в `Benefit`, seed
|
||||
2. **API module** — controller + service + DTOs
|
||||
3. **Frontend types** + `/inventory` page + drawer
|
||||
4. **BenefitDrawer rewrite** — селекты вместо TextInput
|
||||
5. **BeneficiiTab** — показ резолвленных имён
|
||||
6. **Atomic stock logic** в BenefitService
|
||||
7. **Stock adjust** modal + endpoint
|
||||
Reference in New Issue
Block a user