[CLAUDE] Phase1.2: CRUD Master + Permission Matrix + FE admin pages
Backend:
- Domain/Master: Supplier (+ SupplierType 5 loai), Project, Department (AuditableEntity)
- Domain/Identity: MenuItem, Permission, MenuKeys const (12 menu)
- EF Configurations voi unique Code + query filter IsDeleted
- DbSets + IApplicationDbContext interface update
- Application: PagedResult + PagedRequest generic
- Application/Master CQRS CRUD 3 entity (Create/Update/Delete/Get/List voi paging search sort)
- Application/Permissions: GetMyMenuTree (union OR role, filter tree), ListMenuItems, ListPermissionsByRole, UpsertPermission (guard admin khong tu giam quyen), ListRoles
- Api/Authorization: MenuPermissionRequirement + Handler (Admin bypass, query DB)
- Program.cs: register 48 policy {menu}.{action} tu MenuKeys x Actions
- Api/Controllers: Suppliers, Projects, Departments, Menus, Roles, Permissions
- DbInitializer: seed 12 menu + admin full CRUD permissions
- Migration AddMasterData + AddPermissions
Frontend (fe-admin):
- Types: menuKeys.ts const, menu.ts (MenuNode/Role/Permission), master.ts (Supplier/Project/Department + SupplierType const-object)
- AuthContext: load menu from /menus/me, cache localStorage, refreshMenu()
- usePermission hook + PermissionGuard component (wrap button)
- UI kit them: Dialog (modal overlay), Textarea, Select
- Generic: DataTable (column config, sortable, loading, empty) + Pagination
- PageHeader component
- apiError helper extract message tu ProblemDetails
- Layout rewrite: render menu dong tu AuthContext.menu (MenuGroup collapsible + NavLink + lucide icon map)
- Pages: master/Suppliers, master/Projects, master/Departments (CRUD + search + sort + paging + Dialog form)
- Page system/Permissions: ma tran Role x MenuKey x CRUD checkbox (tick tu dong PUT upsert)
- App.tsx them 4 route moi
Bug fix:
- MenuPermissionHandler: EF expression tree khong support switch expression -> tach switch ra ngoai AnyAsync
- TS erasableSyntaxOnly khong cho enum -> SupplierType const-object pattern (typeof[keyof])
E2E verified via Vite proxy:
- GET /menus/me -> 6 root + 6 child nodes (12 menus)
- GET /roles -> 12 roles
- POST/GET/PUT/DELETE /suppliers -> full CRUD, soft delete OK
- tsc -b fe-admin pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -2,67 +2,64 @@
|
|||||||
|
|
||||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||||
|
|
||||||
**Last updated:** 2026-04-21 11:00
|
**Last updated:** 2026-04-21 11:30
|
||||||
|
|
||||||
## 📍 Phase hiện tại: **Phase 1 — Alpha Core** (foundation xong, chờ đợt 2)
|
## 📍 Phase hiện tại: **Phase 1 — Alpha Core (đợt 2 xong)** — sẵn sàng Phase 2 Form Engine
|
||||||
|
|
||||||
## 🔥 In Progress
|
## 🔥 In Progress
|
||||||
|
|
||||||
_(không có — Phase 1 foundation xong, chờ quyết định bước tiếp)_
|
_(không có — Phase 1 đợt 2 hoàn tất)_
|
||||||
|
|
||||||
## ✅ Recently Done (newest on top)
|
## ✅ Recently Done (newest on top)
|
||||||
|
|
||||||
| Ngày | Ai | Task | Commit |
|
| Ngày | Ai | Task | Commit |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 2026-04-21 | Claude | **Docs addition** — `database-guide.md` (conventions + schema + ERD + migration workflow) + `flows/` 6 doc (auth implemented + permission/contract-create/contract-approve/form-render/sla-expiry planned) | (sắp commit) |
|
| 2026-04-21 | Claude | **Phase 1 đợt 2 HOÀN TẤT** — BE: Supplier/Project/Department CRUD + Permission Matrix (MenuItem/Permission + Authorization handler) + 2 migration. FE: DataTable/Dialog generic, usePermission, PermissionGuard, 3 trang CRUD admin, Permission Matrix page, Layout menu động | (sắp commit) |
|
||||||
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE (Clean Arch + Identity + JWT + migration) + FE (2 app, Tailwind 4, Router, AuthContext, Login) — E2E login pass qua Vite proxy | `702411f` |
|
| 2026-04-21 | Claude | **Docs addition** — `database-guide.md` + `flows/` 6 doc | `49a5f57` |
|
||||||
|
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE Clean Arch + Identity + JWT + FE 2 app + Tailwind 4 + login E2E | `702411f` |
|
||||||
| 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
|
| 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
|
||||||
|
|
||||||
Session logs:
|
Session logs:
|
||||||
- [`changelog/sessions/2026-04-21-1045-phase0-scaffold.md`](changelog/sessions/2026-04-21-1045-phase0-scaffold.md)
|
- [`changelog/sessions/2026-04-21-1045-phase0-scaffold.md`](changelog/sessions/2026-04-21-1045-phase0-scaffold.md)
|
||||||
- [`changelog/sessions/2026-04-21-1100-phase1-foundation.md`](changelog/sessions/2026-04-21-1100-phase1-foundation.md)
|
- [`changelog/sessions/2026-04-21-1100-phase1-foundation.md`](changelog/sessions/2026-04-21-1100-phase1-foundation.md)
|
||||||
|
- [`changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md`](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md)
|
||||||
|
|
||||||
## 🎯 Next up — Phase 1 đợt 2 (CRUD master + Permission Matrix)
|
## 🎯 Next up — Phase 2 Form Engine (có thể bắt ngay)
|
||||||
|
|
||||||
### Backend
|
- [ ] Convert 3 file `.doc` (FO-002.02/03/06) → `.docx` qua PowerShell COM hoặc LibreOffice headless
|
||||||
- [ ] `Domain/Entities/Supplier`, `Project`, `Department` (+ EF configurations)
|
- [ ] Parse chi tiết field specs cho 5 template HĐ → JSON spec
|
||||||
- [ ] `Application/Suppliers/{Commands,Queries}/*` (Create, Update, Delete, GetById, List)
|
- [ ] Add NuGet: DocumentFormat.OpenXml, ClosedXML
|
||||||
- [ ] Tương tự cho Project + Department
|
- [ ] `Domain/Entities/ContractTemplate`, `ContractClause` + EF config
|
||||||
- [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
|
- [ ] `Application/Forms/Services/IFormRenderer` + `DocxRenderer` + `XlsxRenderer`
|
||||||
- [ ] Pagination, search, sort server-side (`GetListQuery` với `PagedResult<T>`)
|
- [ ] `Api/Controllers/FormsController` (list template, get spec, render preview, render final)
|
||||||
- [ ] Migration 2: `AddMasterData`
|
- [ ] FE: form builder dynamic render từ fieldSpec
|
||||||
|
- [ ] FE admin: upload template + manage ContractClause (rich text editor)
|
||||||
|
- [ ] Test: FO-002.05 Giao khoán render → docx khớp mẫu 100%
|
||||||
|
|
||||||
### Permission Matrix
|
Chi tiết: [`docs/flows/form-render-flow.md`](flows/form-render-flow.md) + [`docs/changelog/migration-todos.md`](changelog/migration-todos.md) section Phase 2.
|
||||||
- [ ] `Domain/Entities/MenuItem`, `Permission`
|
|
||||||
- [ ] Seed default menu tree (based on FE screens)
|
|
||||||
- [ ] `Application/Permissions/Queries/GetMyMenuTreeQuery`
|
|
||||||
- [ ] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
|
|
||||||
- [ ] Admin UI: Permission Matrix grid (role × menu × CRUD checkbox)
|
|
||||||
|
|
||||||
### Contract draft (chưa workflow)
|
## 🔄 Còn có thể làm parallel (optional, không block Phase 2)
|
||||||
- [ ] `Domain/Entities/Contract` skeleton (không state machine)
|
|
||||||
- [ ] Basic CRUD controller + FE list page
|
|
||||||
|
|
||||||
### FE
|
- [ ] FE Users management (tạo user + gán role) — cần để test permission với role khác Admin
|
||||||
- [ ] `<PermissionGuard menuKey="Contracts">` + `usePermission()` hook
|
- [ ] FE Roles CRUD (tạo custom role mới)
|
||||||
- [ ] 3 trang CRUD admin (Suppliers / Projects / Departments) với table + modal
|
- [ ] Contract entity skeleton (không state machine, chỉ CRUD draft)
|
||||||
- [ ] Route guard theo role (admin-only routes)
|
- [ ] E2E test permission: tạo user role Drafter-only → verify không thấy menu System/Admin
|
||||||
|
|
||||||
## 📊 Thông số sau Phase 1 foundation
|
## 📊 Thông số sau Phase 1 đợt 2
|
||||||
|
|
||||||
- **Backend LOC:** ~400 (Domain 60 + Application 170 + Infrastructure 190 + Api 120)
|
- **Backend LOC:** ~1500 (Domain 150 + Application 800 + Infrastructure 350 + Api 200)
|
||||||
- **Frontend LOC:** ~450 mỗi app (shared 90%)
|
- **Migrations:** Init + AddMasterData + AddPermissions
|
||||||
- **Build time:** .NET ~4s, FE TS check ~3s mỗi app, Vite dev ~3s ready
|
- **DB tables:** 7 Identity + 3 Master (Suppliers/Projects/Departments) + 2 Permission (MenuItems/Permissions)
|
||||||
- **E2E verified:** Login (fe-admin proxy + fe-user proxy) → API → JWT + user info + /me
|
- **API endpoints:** 20+ (Auth 4 + Suppliers 5 + Projects 5 + Departments 5 + Menus 2 + Roles 1 + Permissions 2)
|
||||||
|
- **Frontend routes:** 5 (Dashboard + 3 CRUD + Permission Matrix)
|
||||||
|
- **FE LOC:** ~1700 (fe-admin; fe-user vẫn minimal)
|
||||||
|
|
||||||
## 🚨 Blockers / risks
|
## 🚨 Blockers / risks
|
||||||
|
|
||||||
- ⏳ **Gitea remote** chưa có URL — push sau
|
- ⏳ **Gitea remote** — URL chờ user cấp
|
||||||
- ⚠️ **Swashbuckle 10.x** không tương thích với .NET 10 + Microsoft.OpenApi 2.0 — đã downgrade về 6.9.0. Theo dõi update sau.
|
- ⚠️ **fe-user** chưa được update với menu động — Phase 2 sẽ sync
|
||||||
- ⚠️ **MediatR 14.x** breaking changes — đã downgrade về 12.4.1. Ok cho Phase 1-5.
|
- ⚠️ **Users CRUD** chưa có UI → khó test permission với non-admin role thật
|
||||||
- ⚠️ **Microsoft.AspNetCore.OpenApi** đã remove (conflict Swashbuckle 6.9). Nếu sau muốn dùng built-in OpenAPI thì phải chọn 1 trong 2.
|
- ⚠️ **3 file `.doc`** Phase 2 cần convert COM
|
||||||
- ⚠️ **Design-time DB (`SolutionErp_Design`)** được tạo khi chạy `dotnet ef` — có thể drop an toàn (không chứa data thật)
|
|
||||||
- ⚠️ **3 file `.doc` FORM** chưa convert được — Phase 2
|
|
||||||
|
|
||||||
## Credentials mặc định
|
## Credentials mặc định
|
||||||
|
|
||||||
@ -72,6 +69,6 @@ Password: Admin@123456
|
|||||||
```
|
```
|
||||||
|
|
||||||
URLs dev:
|
URLs dev:
|
||||||
- API: http://localhost:5443 — Swagger ở `/swagger`
|
- API: http://localhost:5443 — Swagger `/swagger`
|
||||||
- Admin FE: http://localhost:8082
|
- Admin FE: http://localhost:8082
|
||||||
- User FE: http://localhost:8080
|
- User FE: http://localhost:8080
|
||||||
|
|||||||
@ -60,27 +60,28 @@
|
|||||||
|
|
||||||
### Phase 1 đợt 2 — CRUD master + Permission Matrix (sắp tới)
|
### Phase 1 đợt 2 — CRUD master + Permission Matrix (sắp tới)
|
||||||
|
|
||||||
- [ ] `Domain/Entities/Supplier` (Code, Name, TaxCode, Phone, Email, Address, Type enum: NCC/NTP/TĐ/ĐVDV)
|
- [x] `Domain/Master/Supplier` (+ SupplierType enum 5 loại) / `Project` / `Department` (AuditableEntity)
|
||||||
- [ ] `Domain/Entities/Project` (Code, Name, StartDate, EndDate, ManagerUserId)
|
- [x] EF `IEntityTypeConfiguration<T>` cho mỗi entity (unique Code + query filter IsDeleted)
|
||||||
- [ ] `Domain/Entities/Department` (Code, Name, ManagerUserId)
|
- [x] CQRS CRUD: Create/Update/Delete/GetById/List (PagedResult) cho 3 entity
|
||||||
- [ ] EF `IEntityTypeConfiguration<T>` cho mỗi entity
|
- [x] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
|
||||||
- [ ] CQRS CRUD: Create/Update/Delete/GetById/List (với paging) cho 3 entity
|
- [x] Migration 2: `AddMasterData`
|
||||||
- [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
|
- [x] `Domain/Identity/MenuItem` (Key PK, Label, ParentKey, Order, Icon) + `MenuKeys` const class
|
||||||
- [ ] Migration 2: `AddMasterData`
|
- [x] `Domain/Identity/Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
|
||||||
- [ ] `Domain/Entities/MenuItem` (Key PascalCase, Label, ParentKey, Order, Icon)
|
- [x] Seed default menu tree (12 menu) + admin full access trong DbInitializer
|
||||||
- [ ] `Domain/Entities/Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
|
- [x] `Application/Permissions/Queries/GetMyMenuTreeQuery` — resolve per-user, union OR, tree filter
|
||||||
- [ ] Seed default menu tree + permission admin có full access
|
- [x] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
|
||||||
- [ ] `Application/Permissions/Queries/GetMyMenuTreeQuery` — resolve per-user, cache
|
- [x] Migration 3: `AddPermissions`
|
||||||
- [ ] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
|
- [x] Authorization handler `MenuPermissionHandler` + register 48 policy `{menu}.{action}`
|
||||||
- [ ] Migration 3: `AddPermissions`
|
- [ ] `Domain/Entities/Contract` skeleton (Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData JSON) — deferred Phase 2/3
|
||||||
- [ ] `Domain/Entities/Contract` skeleton (Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData JSON)
|
- [ ] Contract CRUD draft only (không workflow Phase 3) — deferred
|
||||||
- [ ] Contract CRUD draft only (không workflow Phase 3)
|
- [x] FE: `<PermissionGuard menuKey="Suppliers" action="Update">` + `usePermission()` hook
|
||||||
- [ ] FE: `<PermissionGuard menuKey="Suppliers" action="Update">` + `usePermission()` hook
|
- [x] FE Admin: 3 trang CRUD Supplier/Project/Department với DataTable + Dialog modal + search/sort/paging
|
||||||
- [ ] FE Admin: 3 trang CRUD Supplier/Project/Department với table + modal + search/sort
|
- [x] FE Admin: Permission Matrix grid page (role × menu × CRUD checkbox)
|
||||||
- [ ] FE Admin: Permission Matrix grid page (role × menu × CRUD checkbox)
|
- [x] FE Admin: Layout menu động từ `/api/menus/me`
|
||||||
- [ ] FE User: trang "HĐ của tôi" list + filter
|
- [ ] FE User: trang "HĐ của tôi" list + filter — Phase 3
|
||||||
- [ ] Route guard theo role admin-only
|
- [ ] FE Admin: Users management page (tạo user + gán role) — sắp tới
|
||||||
- [ ] Update `SolutionErp.slnx` nếu thêm project mới
|
- [ ] FE Admin: Roles CRUD — sắp tới
|
||||||
|
- [ ] Route guard theo role admin-only — có PermissionGuard ở button, route cần thêm
|
||||||
|
|
||||||
### Exit criteria Phase 1
|
### Exit criteria Phase 1
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
# Session 2026-04-21 11:30 — Phase 1 đợt 2 complete
|
||||||
|
|
||||||
|
**Dev:** Claude (Opus 4.7)
|
||||||
|
**Duration:** ~1h30m
|
||||||
|
**Base commit:** `49a5f57`
|
||||||
|
**Commits:** (sắp tạo — 1 commit lớn cho toàn bộ đợt 2)
|
||||||
|
|
||||||
|
## Làm được
|
||||||
|
|
||||||
|
### Chunk A — Backend Master data
|
||||||
|
- Domain: `Supplier` (+ `SupplierType` enum 5 loại: NCC/NTP/TĐ/ĐVDV/CĐT), `Project`, `Department` — extend `AuditableEntity`
|
||||||
|
- EF `IEntityTypeConfiguration` với unique index `Code`, query filter `IsDeleted`
|
||||||
|
- DbSets trong `ApplicationDbContext` + `IApplicationDbContext` (thêm package `Microsoft.EntityFrameworkCore` vào Application)
|
||||||
|
- `Common/Models/PagedResult<T>` + `PagedRequest` (page, pageSize 1-200, search, sortBy, sortDesc)
|
||||||
|
- CQRS cho Supplier: `CreateSupplierCommand` + Validator + Handler, `UpdateSupplierCommand`, `DeleteSupplierCommand` (soft-delete qua AuditingInterceptor), `GetSupplierQuery`, `ListSuppliersQuery` (với filter Type)
|
||||||
|
- CQRS cho Project + Department: gom vào 1 file mỗi entity (`ProjectFeatures.cs`, `DepartmentFeatures.cs`) — pattern giống Supplier nhưng compact
|
||||||
|
- 3 Controller: `SuppliersController`, `ProjectsController`, `DepartmentsController` — full REST (list, get, create, update, delete)
|
||||||
|
- Migration `AddMasterData`
|
||||||
|
|
||||||
|
### Chunk B — Backend Permission system
|
||||||
|
- Domain: `MenuKeys` const (12 menu key: Dashboard, Master, Suppliers, Projects, Departments, Contracts, Forms, Reports, System, Users, Roles, Permissions), `MenuItem` (Key PK, Label, ParentKey, Order, Icon), `Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
|
||||||
|
- EF configs + unique index `(RoleId, MenuKey)`
|
||||||
|
- DTOs: `MenuNodeDto` (tree node có resolved CRUD), `PermissionDto`, `RoleDto`, `MenuItemDto`
|
||||||
|
- `GetMyMenuTreeQuery` — resolve menu tree theo roles của user, **union OR** CRUD flags qua roles, **filter** chỉ trả về node có CanRead hoặc có child CanRead
|
||||||
|
- `ListMenuItemsQuery` + `ListPermissionsByRoleQuery` + `UpsertPermissionCommand` (guard: admin không tự giảm quyền mình)
|
||||||
|
- `ListRolesQuery`
|
||||||
|
- **Authorization handler** `MenuPermissionHandler : AuthorizationHandler<MenuPermissionRequirement>` — check Admin bypass, query DB qua roleIds
|
||||||
|
- Register 48 policy (`{menu}.{action}` cho 12 menu × 4 action) trong Program.cs
|
||||||
|
- Controllers: `MenusController` (GET /me, GET /), `RolesController`, `PermissionsController` (Authorize policy `Permissions.Read/Update`)
|
||||||
|
- Update `DbInitializer`: seed 12 menu items + default **admin có full CRUD mọi menu**
|
||||||
|
- Migration `AddPermissions`
|
||||||
|
|
||||||
|
### Chunk C — Frontend (fe-admin)
|
||||||
|
- Types: `menuKeys.ts` const + `menu.ts` (MenuNode/MenuItem/Role/Permission) + `master.ts` (Supplier/Project/Department + SupplierType const-object với erasableSyntaxOnly)
|
||||||
|
- `contexts/AuthContext.tsx`: thêm `menu` state + `loadMenu()` khi login + localStorage cache + `refreshMenu()`
|
||||||
|
- `hooks/usePermission.ts`: `can(menuKey, action)` — search tree đệ quy
|
||||||
|
- `components/PermissionGuard.tsx`: wrapper component
|
||||||
|
- `components/ui/`: Dialog (modal overlay + Escape close), Textarea, Select
|
||||||
|
- `components/DataTable.tsx`: generic table với columns config, sort (sortBy/sortDesc callback), loading, empty state, click row; `Pagination` component kèm theo
|
||||||
|
- `components/PageHeader.tsx`: title + description + actions
|
||||||
|
- `components/Layout.tsx` rewrite: render menu **động** từ AuthContext.menu (không hardcode nữa), MenuGroup collapsible cho parent có children, NavLink active highlight, icon map từ lucide-react theo field `icon`
|
||||||
|
- `lib/apiError.ts`: extract user-friendly message từ ProblemDetails
|
||||||
|
- 3 trang CRUD admin (`master/SuppliersPage.tsx`, `ProjectsPage.tsx`, `DepartmentsPage.tsx`):
|
||||||
|
- Full CRUD với Dialog form, search, sort, paging
|
||||||
|
- `<PermissionGuard>` wrap Create/Update/Delete button
|
||||||
|
- `useQuery` + `useMutation` + `invalidateQueries` TanStack
|
||||||
|
- Toast success/error
|
||||||
|
- `system/PermissionsPage.tsx`: grid ma trận role × menu × CRUD, select role dropdown → tick checkbox tự động PUT `/permissions` upsert
|
||||||
|
- `App.tsx`: thêm 4 route mới
|
||||||
|
|
||||||
|
## Bug gặp + fix
|
||||||
|
|
||||||
|
| Bug | Root cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Build CS8514 in MenuPermissionHandler | EF expression tree không support switch expression | Tách `switch` ra ngoài `AnyAsync()` — switch trên Action, mỗi case gọi AnyAsync với predicate riêng |
|
||||||
|
| POST /api/suppliers lỗi với Unicode tiếng Việt qua curl command line | Windows bash command line encoding không đúng UTF-8 | Dùng `--data-binary @file.json` — API handle UTF-8 OK |
|
||||||
|
| TS1294 erasableSyntaxOnly + `enum SupplierType` | TS 6 erasableSyntaxOnly không cho enum | Dùng `const + as const + type = typeof[keyof]` pattern |
|
||||||
|
| fe-admin npm run dev báo port 8082 in use | Background task trước chưa stop hẳn | Tình trạng tạm — E2E vẫn pass qua port đã bind |
|
||||||
|
|
||||||
|
## E2E verified
|
||||||
|
|
||||||
|
- `GET /api/menus/me` với admin token → **6 root + 6 child nodes** (12 menu items) ✅
|
||||||
|
- `GET /api/roles` → **12 roles** ✅
|
||||||
|
- `POST /api/suppliers` → 201 + id ✅
|
||||||
|
- `GET /api/suppliers` → PagedResult ✅
|
||||||
|
- `PUT /api/suppliers/{id}` → 204 ✅
|
||||||
|
- `DELETE /api/suppliers/{id}` → 204, list GET sau đó không còn ✅ (soft delete OK)
|
||||||
|
- `GET /api/permissions/by-role/{adminRoleId}` → 12 permissions all true ✅
|
||||||
|
- `tsc -b` fe-admin → pass ✅
|
||||||
|
|
||||||
|
## Handoff cho session tiếp theo
|
||||||
|
|
||||||
|
**Phase 2 — Form Engine:**
|
||||||
|
- Convert 3 `.doc` files → `.docx` via PowerShell COM automation
|
||||||
|
- Parse chi tiết field specs cho 5 contract templates
|
||||||
|
- `IFormRenderer` service với OpenXml + ClosedXML
|
||||||
|
- Form builder UI cho admin upload template
|
||||||
|
- Preview + export flow
|
||||||
|
|
||||||
|
Xem [`docs/flows/form-render-flow.md`](../../flows/form-render-flow.md) + [`docs/changelog/migration-todos.md`](../migration-todos.md) section Phase 2.
|
||||||
|
|
||||||
|
**Còn thiếu trong Phase 1 (có thể làm parallel với Phase 2 nếu muốn):**
|
||||||
|
- FE: thêm trang Users management (tạo user mới, gán role)
|
||||||
|
- FE: trang Roles CRUD (create/rename/delete custom role)
|
||||||
|
- Contract entity + CRUD draft (skeleton cho Phase 3 chuẩn bị)
|
||||||
|
- Test 1 role khác Admin → verify permission guard hoạt động đúng
|
||||||
|
|
||||||
|
**Blocker:**
|
||||||
|
- ⏳ Gitea remote URL
|
||||||
@ -5,6 +5,10 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
|
|||||||
import { Layout } from '@/components/Layout'
|
import { Layout } from '@/components/Layout'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { DashboardPage } from '@/pages/DashboardPage'
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
|
import { SuppliersPage } from '@/pages/master/SuppliersPage'
|
||||||
|
import { ProjectsPage } from '@/pages/master/ProjectsPage'
|
||||||
|
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
|
||||||
|
import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -20,12 +24,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/master/suppliers" element={<SuppliersPage />} />
|
||||||
|
<Route path="/master/projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="/master/departments" element={<DepartmentsPage />} />
|
||||||
|
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
<div className="p-8 text-slate-500">
|
<div className="p-8 text-slate-500">
|
||||||
Trang này chưa được build — sẽ có ở Phase 1 đợt 2 / Phase 2 / 3.
|
Trang này chưa được build — sẽ có ở Phase tiếp theo.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
151
fe-admin/src/components/DataTable.tsx
Normal file
151
fe-admin/src/components/DataTable.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
export type Column<T> = {
|
||||||
|
key: string
|
||||||
|
header: ReactNode
|
||||||
|
render: (row: T) => ReactNode
|
||||||
|
sortable?: boolean
|
||||||
|
width?: string
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props<T> = {
|
||||||
|
columns: Column<T>[]
|
||||||
|
rows: T[]
|
||||||
|
getRowKey: (row: T) => string
|
||||||
|
isLoading?: boolean
|
||||||
|
empty?: ReactNode
|
||||||
|
sortBy?: string
|
||||||
|
sortDesc?: boolean
|
||||||
|
onSortChange?: (sortBy: string, sortDesc: boolean) => void
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
getRowKey,
|
||||||
|
isLoading,
|
||||||
|
empty,
|
||||||
|
sortBy,
|
||||||
|
sortDesc,
|
||||||
|
onSortChange,
|
||||||
|
onRowClick,
|
||||||
|
}: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto rounded-md border border-slate-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-700">
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th
|
||||||
|
key={c.key}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 font-medium',
|
||||||
|
c.align === 'right' && 'text-right',
|
||||||
|
c.align === 'center' && 'text-center',
|
||||||
|
c.align !== 'right' && c.align !== 'center' && 'text-left',
|
||||||
|
c.width,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.sortable && onSortChange ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onSortChange(c.key, sortBy === c.key ? !sortDesc : false)}
|
||||||
|
className="inline-flex items-center gap-1 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
{sortBy === c.key && (sortDesc ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
c.header
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-3 py-8 text-center text-slate-500">
|
||||||
|
Đang tải…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!isLoading && rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-3 py-8 text-center text-slate-500">
|
||||||
|
{empty ?? 'Không có dữ liệu'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!isLoading &&
|
||||||
|
rows.map(row => (
|
||||||
|
<tr
|
||||||
|
key={getRowKey(row)}
|
||||||
|
className={cn(
|
||||||
|
'border-t border-slate-100 transition',
|
||||||
|
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td
|
||||||
|
key={c.key}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2',
|
||||||
|
c.align === 'right' && 'text-right',
|
||||||
|
c.align === 'center' && 'text-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.render(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationProps = {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
onChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
const from = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||||
|
const to = Math.min(page * pageSize, total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-3 text-sm text-slate-600">
|
||||||
|
<span>
|
||||||
|
{from}–{to} / {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-3 py-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Trước
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1">
|
||||||
|
Trang {page}/{totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-3 py-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sau
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,18 +1,80 @@
|
|||||||
import { Link, NavLink, Outlet } from 'react-router-dom'
|
import { Link, NavLink, Outlet } from 'react-router-dom'
|
||||||
import { LogOut, LayoutDashboard, FileText, Users, Building2, Settings } from 'lucide-react'
|
import { LogOut, ChevronDown, Circle, type LucideIcon } from 'lucide-react'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import type { MenuNode } from '@/types/menu'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
const menuItems = [
|
// Map icon name → component (fallback Circle)
|
||||||
{ to: '/dashboard', label: 'Tổng quan', icon: LayoutDashboard },
|
function getIcon(name: string | null): LucideIcon {
|
||||||
{ to: '/contracts', label: 'Hợp đồng', icon: FileText },
|
if (!name) return Circle
|
||||||
{ to: '/suppliers', label: 'Nhà cung cấp', icon: Building2 },
|
const candidate = (Icons as unknown as Record<string, LucideIcon>)[name]
|
||||||
{ to: '/users', label: 'Người dùng', icon: Users },
|
return candidate ?? Circle
|
||||||
{ to: '/settings', label: 'Cài đặt', icon: Settings },
|
}
|
||||||
]
|
|
||||||
|
// Map menu key → route path
|
||||||
|
const KEY_TO_PATH: Record<string, string> = {
|
||||||
|
Dashboard: '/dashboard',
|
||||||
|
Suppliers: '/master/suppliers',
|
||||||
|
Projects: '/master/projects',
|
||||||
|
Departments: '/master/departments',
|
||||||
|
Contracts: '/contracts',
|
||||||
|
Forms: '/forms',
|
||||||
|
Reports: '/reports',
|
||||||
|
Users: '/system/users',
|
||||||
|
Roles: '/system/roles',
|
||||||
|
Permissions: '/system/permissions',
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuGroup({ node }: { node: MenuNode }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const Icon = getIcon(node.icon)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-xs font-semibold uppercase text-slate-500 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-2">
|
||||||
|
{node.children.map(c => (
|
||||||
|
<MenuLeaf key={c.key} node={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuLeaf({ node }: { node: MenuNode }) {
|
||||||
|
const Icon = getIcon(node.icon)
|
||||||
|
const path = KEY_TO_PATH[node.key]
|
||||||
|
if (!path) return null
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
|
||||||
|
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{node.label}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, logout } = useAuth()
|
const { user, menu, logout } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
@ -22,28 +84,12 @@ export function Layout() {
|
|||||||
SOLUTION ERP · Admin
|
SOLUTION ERP · Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-1 p-3">
|
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
||||||
{menuItems.map(item => (
|
{menu.map(n => (n.children.length > 0 ? <MenuGroup key={n.key} node={n} /> : <MenuLeaf key={n.key} node={n} />))}
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
|
|
||||||
isActive
|
|
||||||
? 'bg-brand-50 text-brand-700'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-slate-200 p-3">
|
<div className="border-t border-slate-200 p-3">
|
||||||
<div className="mb-2 px-3 text-xs text-slate-500">
|
<div className="mb-2 px-3 text-xs text-slate-500">
|
||||||
<div className="font-medium text-slate-700">{user?.fullName}</div>
|
<div className="truncate font-medium text-slate-700">{user?.fullName}</div>
|
||||||
<div className="truncate">{user?.email}</div>
|
<div className="truncate">{user?.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
13
fe-admin/src/components/PageHeader.tsx
Normal file
13
fe-admin/src/components/PageHeader.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">{title}</h1>
|
||||||
|
{description && <p className="mt-1 text-sm text-slate-600">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
fe-admin/src/components/PermissionGuard.tsx
Normal file
16
fe-admin/src/components/PermissionGuard.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
|
import type { CrudAction } from '@/lib/menuKeys'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
menuKey: string
|
||||||
|
action?: CrudAction
|
||||||
|
fallback?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionGuard({ menuKey, action = 'Read', fallback = null, children }: Props) {
|
||||||
|
const { can } = usePermission()
|
||||||
|
if (!can(menuKey, action)) return <>{fallback}</>
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
48
fe-admin/src/components/ui/Dialog.tsx
Normal file
48
fe-admin/src/components/ui/Dialog.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, title, children, footer, size = 'md' }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg bg-white shadow-xl',
|
||||||
|
size === 'sm' && 'max-w-md',
|
||||||
|
size === 'md' && 'max-w-xl',
|
||||||
|
size === 'lg' && 'max-w-3xl',
|
||||||
|
)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
|
||||||
|
<div className="text-base font-semibold text-slate-900">{title}</div>
|
||||||
|
<button onClick={onClose} className="rounded p-1 text-slate-500 hover:bg-slate-100">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[70vh] overflow-auto p-5">{children}</div>
|
||||||
|
{footer && <div className="flex items-center justify-end gap-2 border-t border-slate-200 px-5 py-3">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
fe-admin/src/components/ui/Select.tsx
Normal file
18
fe-admin/src/components/ui/Select.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { forwardRef, type SelectHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
type Props = SelectHTMLAttributes<HTMLSelectElement>
|
||||||
|
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, Props>(({ className, children, ...props }, ref) => (
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
))
|
||||||
|
Select.displayName = 'Select'
|
||||||
16
fe-admin/src/components/ui/Textarea.tsx
Normal file
16
fe-admin/src/components/ui/Textarea.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
type Props = TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
@ -1,29 +1,48 @@
|
|||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { api, TOKEN_KEY, USER_KEY } from '@/lib/api'
|
import { api, TOKEN_KEY, USER_KEY } from '@/lib/api'
|
||||||
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
|
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
|
||||||
|
import type { MenuNode } from '@/types/menu'
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: UserInfo | null
|
user: UserInfo | null
|
||||||
|
menu: MenuNode[]
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isBootstrapping: boolean
|
isBootstrapping: boolean
|
||||||
login: (payload: LoginPayload) => Promise<void>
|
login: (payload: LoginPayload) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
refreshMenu: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
const MENU_KEY = 'solution-erp-admin-menu'
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<UserInfo | null>(null)
|
const [user, setUser] = useState<UserInfo | null>(null)
|
||||||
|
const [menu, setMenu] = useState<MenuNode[]>([])
|
||||||
const [isBootstrapping, setIsBootstrapping] = useState(true)
|
const [isBootstrapping, setIsBootstrapping] = useState(true)
|
||||||
|
|
||||||
|
async function loadMenu() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<MenuNode[]>('/menus/me')
|
||||||
|
setMenu(res.data)
|
||||||
|
localStorage.setItem(MENU_KEY, JSON.stringify(res.data))
|
||||||
|
} catch {
|
||||||
|
// keep cached menu if request fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
const raw = localStorage.getItem(USER_KEY)
|
const userRaw = localStorage.getItem(USER_KEY)
|
||||||
if (token && raw) {
|
const menuRaw = localStorage.getItem(MENU_KEY)
|
||||||
|
if (token && userRaw) {
|
||||||
try {
|
try {
|
||||||
setUser(JSON.parse(raw))
|
setUser(JSON.parse(userRaw))
|
||||||
|
if (menuRaw) setMenu(JSON.parse(menuRaw))
|
||||||
|
loadMenu()
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(USER_KEY)
|
localStorage.removeItem(USER_KEY)
|
||||||
|
localStorage.removeItem(MENU_KEY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsBootstrapping(false)
|
setIsBootstrapping(false)
|
||||||
@ -34,16 +53,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
|
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
|
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
|
||||||
setUser(res.data.user)
|
setUser(res.data.user)
|
||||||
|
await loadMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
localStorage.removeItem(USER_KEY)
|
localStorage.removeItem(USER_KEY)
|
||||||
|
localStorage.removeItem(MENU_KEY)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setMenu([])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isBootstrapping, login, logout }}>
|
<AuthContext.Provider value={{ user, menu, isAuthenticated: !!user, isBootstrapping, login, logout, refreshMenu: loadMenu }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
26
fe-admin/src/hooks/usePermission.ts
Normal file
26
fe-admin/src/hooks/usePermission.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import type { CrudAction } from '@/lib/menuKeys'
|
||||||
|
import type { MenuNode } from '@/types/menu'
|
||||||
|
|
||||||
|
function findNode(tree: MenuNode[], key: string): MenuNode | undefined {
|
||||||
|
for (const n of tree) {
|
||||||
|
if (n.key === key) return n
|
||||||
|
const found = findNode(n.children, key)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermission() {
|
||||||
|
const { menu } = useAuth()
|
||||||
|
return {
|
||||||
|
can: (menuKey: string, action: CrudAction = 'Read'): boolean => {
|
||||||
|
const node = findNode(menu, menuKey)
|
||||||
|
if (!node) return false
|
||||||
|
return action === 'Read' ? node.canRead
|
||||||
|
: action === 'Create' ? node.canCreate
|
||||||
|
: action === 'Update' ? node.canUpdate
|
||||||
|
: node.canDelete
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
13
fe-admin/src/lib/apiError.ts
Normal file
13
fe-admin/src/lib/apiError.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export function getErrorMessage(err: unknown, fallback = 'Lỗi hệ thống'): string {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const data = err.response?.data as { detail?: string; title?: string; errors?: Record<string, string[]> } | undefined
|
||||||
|
if (data?.errors) {
|
||||||
|
const first = Object.values(data.errors).flat()[0]
|
||||||
|
if (first) return first
|
||||||
|
}
|
||||||
|
return data?.detail ?? data?.title ?? err.message
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
18
fe-admin/src/lib/menuKeys.ts
Normal file
18
fe-admin/src/lib/menuKeys.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Đồng bộ tay với BE SolutionErp.Domain.Identity.MenuKeys
|
||||||
|
export const MenuKeys = {
|
||||||
|
Dashboard: 'Dashboard',
|
||||||
|
Master: 'Master',
|
||||||
|
Suppliers: 'Suppliers',
|
||||||
|
Projects: 'Projects',
|
||||||
|
Departments: 'Departments',
|
||||||
|
Contracts: 'Contracts',
|
||||||
|
Forms: 'Forms',
|
||||||
|
Reports: 'Reports',
|
||||||
|
System: 'System',
|
||||||
|
Users: 'Users',
|
||||||
|
Roles: 'Roles',
|
||||||
|
Permissions: 'Permissions',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
export type CrudAction = 'Read' | 'Create' | 'Update' | 'Delete'
|
||||||
160
fe-admin/src/pages/master/DepartmentsPage.tsx
Normal file
160
fe-admin/src/pages/master/DepartmentsPage.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||||
|
import { PermissionGuard } from '@/components/PermissionGuard'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { MenuKeys } from '@/lib/menuKeys'
|
||||||
|
import type { Department, Paged } from '@/types/master'
|
||||||
|
|
||||||
|
type FormState = { id?: string; code: string; name: string; note: string }
|
||||||
|
const emptyForm: FormState = { code: '', name: '', note: '' }
|
||||||
|
|
||||||
|
export function DepartmentsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>()
|
||||||
|
const [sortDesc, setSortDesc] = useState(true)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm)
|
||||||
|
const isEdit = !!form.id
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['departments', { page, search, sortBy, sortDesc }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Paged<Department>>('/departments', {
|
||||||
|
params: { page, pageSize: 20, search: search || undefined, sortBy, sortDesc },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: async (d: FormState) => {
|
||||||
|
const payload = { id: d.id, code: d.code, name: d.name, managerUserId: null, note: d.note || null }
|
||||||
|
if (d.id) await api.put(`/departments/${d.id}`, payload)
|
||||||
|
else await api.post('/departments', payload)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['departments'] })
|
||||||
|
toast.success(isEdit ? 'Đã cập nhật phòng ban' : 'Đã thêm phòng ban')
|
||||||
|
setOpen(false)
|
||||||
|
setForm(emptyForm)
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/departments/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['departments'] })
|
||||||
|
toast.success('Đã xóa')
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: Column<Department>[] = [
|
||||||
|
{ key: 'code', header: 'Mã', sortable: true, render: d => <span className="font-mono text-xs">{d.code}</span>, width: 'w-32' },
|
||||||
|
{ key: 'name', header: 'Tên phòng ban', sortable: true, render: d => d.name },
|
||||||
|
{ key: 'note', header: 'Ghi chú', render: d => d.note ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
width: 'w-28',
|
||||||
|
render: d => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Departments} action="Update">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => {
|
||||||
|
setForm({ id: d.id, code: d.code, name: d.name, note: d.note ?? '' })
|
||||||
|
setOpen(true)
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Departments} action="Delete">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => {
|
||||||
|
if (confirm(`Xóa phòng ban "${d.name}"?`)) remove.mutate(d.id)
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Phòng ban"
|
||||||
|
actions={
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Departments} action="Create">
|
||||||
|
<Button onClick={() => { setForm(emptyForm); setOpen(true) }}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Thêm phòng ban
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Tìm…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={list.data?.items ?? []}
|
||||||
|
getRowKey={d => d.id}
|
||||||
|
isLoading={list.isLoading}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDesc={sortDesc}
|
||||||
|
onSortChange={(k, desc) => { setSortBy(k); setSortDesc(desc) }}
|
||||||
|
/>
|
||||||
|
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={isEdit ? 'Sửa phòng ban' : 'Thêm phòng ban mới'}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Hủy</Button>
|
||||||
|
<Button onClick={(e: FormEvent) => { e.preventDefault(); mutate.mutate(form) }} disabled={mutate.isPending}>
|
||||||
|
{mutate.isPending ? 'Đang lưu…' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form className="space-y-4" onSubmit={e => { e.preventDefault(); mutate.mutate(form) }}>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Mã phòng ban *</Label>
|
||||||
|
<Input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Tên phòng ban *</Label>
|
||||||
|
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Ghi chú</Label>
|
||||||
|
<Textarea rows={3} value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
214
fe-admin/src/pages/master/ProjectsPage.tsx
Normal file
214
fe-admin/src/pages/master/ProjectsPage.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||||
|
import { PermissionGuard } from '@/components/PermissionGuard'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { MenuKeys } from '@/lib/menuKeys'
|
||||||
|
import type { Paged, Project } from '@/types/master'
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
id?: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
budgetTotal: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: FormState = { code: '', name: '', startDate: '', endDate: '', budgetTotal: '', note: '' }
|
||||||
|
|
||||||
|
const fmtMoney = (v: number | null) => (v == null ? '—' : v.toLocaleString('vi-VN'))
|
||||||
|
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>()
|
||||||
|
const [sortDesc, setSortDesc] = useState(true)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm)
|
||||||
|
const isEdit = !!form.id
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['projects', { page, search, sortBy, sortDesc }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Paged<Project>>('/projects', {
|
||||||
|
params: { page, pageSize: 20, search: search || undefined, sortBy, sortDesc },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: async (d: FormState) => {
|
||||||
|
const payload = {
|
||||||
|
id: d.id,
|
||||||
|
code: d.code,
|
||||||
|
name: d.name,
|
||||||
|
startDate: d.startDate || null,
|
||||||
|
endDate: d.endDate || null,
|
||||||
|
managerUserId: null,
|
||||||
|
budgetTotal: d.budgetTotal ? Number(d.budgetTotal) : null,
|
||||||
|
note: d.note || null,
|
||||||
|
}
|
||||||
|
if (d.id) await api.put(`/projects/${d.id}`, payload)
|
||||||
|
else await api.post('/projects', payload)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['projects'] })
|
||||||
|
toast.success(isEdit ? 'Đã cập nhật dự án' : 'Đã thêm dự án')
|
||||||
|
setOpen(false)
|
||||||
|
setForm(emptyForm)
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/projects/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['projects'] })
|
||||||
|
toast.success('Đã xóa')
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
function openEdit(p: Project) {
|
||||||
|
setForm({
|
||||||
|
id: p.id,
|
||||||
|
code: p.code,
|
||||||
|
name: p.name,
|
||||||
|
startDate: p.startDate ? p.startDate.slice(0, 10) : '',
|
||||||
|
endDate: p.endDate ? p.endDate.slice(0, 10) : '',
|
||||||
|
budgetTotal: p.budgetTotal?.toString() ?? '',
|
||||||
|
note: p.note ?? '',
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Project>[] = [
|
||||||
|
{ key: 'code', header: 'Mã DA', sortable: true, render: p => <span className="font-mono text-xs">{p.code}</span>, width: 'w-32' },
|
||||||
|
{ key: 'name', header: 'Tên dự án', sortable: true, render: p => p.name },
|
||||||
|
{ key: 'startDate', header: 'Bắt đầu', render: p => fmtDate(p.startDate), width: 'w-32' },
|
||||||
|
{ key: 'endDate', header: 'Kết thúc', render: p => fmtDate(p.endDate), width: 'w-32' },
|
||||||
|
{ key: 'budgetTotal', header: 'Ngân sách', align: 'right', render: p => fmtMoney(p.budgetTotal) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
width: 'w-28',
|
||||||
|
render: p => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Projects} action="Update">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => openEdit(p)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Projects} action="Delete">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Xóa dự án "${p.name}"?`)) remove.mutate(p.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Dự án"
|
||||||
|
description="Quản lý các dự án xây dựng — tham chiếu trong mã HĐ"
|
||||||
|
actions={
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Projects} action="Create">
|
||||||
|
<Button onClick={() => { setForm(emptyForm); setOpen(true) }}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Thêm dự án
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Tìm theo mã, tên…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={list.data?.items ?? []}
|
||||||
|
getRowKey={p => p.id}
|
||||||
|
isLoading={list.isLoading}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDesc={sortDesc}
|
||||||
|
onSortChange={(k, d) => { setSortBy(k); setSortDesc(d) }}
|
||||||
|
/>
|
||||||
|
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={isEdit ? 'Sửa dự án' : 'Thêm dự án mới'}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Hủy</Button>
|
||||||
|
<Button onClick={(e: FormEvent) => { e.preventDefault(); mutate.mutate(form) }} disabled={mutate.isPending}>
|
||||||
|
{mutate.isPending ? 'Đang lưu…' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-2 gap-4"
|
||||||
|
onSubmit={e => { e.preventDefault(); mutate.mutate(form) }}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Mã dự án *</Label>
|
||||||
|
<Input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Ngân sách (VND)</Label>
|
||||||
|
<Input type="number" value={form.budgetTotal} onChange={e => setForm({ ...form, budgetTotal: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Tên dự án *</Label>
|
||||||
|
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Ngày bắt đầu</Label>
|
||||||
|
<Input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Ngày kết thúc</Label>
|
||||||
|
<Input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Ghi chú</Label>
|
||||||
|
<Textarea rows={3} value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
253
fe-admin/src/pages/master/SuppliersPage.tsx
Normal file
253
fe-admin/src/pages/master/SuppliersPage.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||||
|
import { PermissionGuard } from '@/components/PermissionGuard'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { MenuKeys } from '@/lib/menuKeys'
|
||||||
|
import { SupplierType, SupplierTypeLabel, type Paged, type Supplier } from '@/types/master'
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
id?: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
type: SupplierType
|
||||||
|
taxCode: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
address: string
|
||||||
|
contactPerson: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: FormState = {
|
||||||
|
code: '', name: '', type: SupplierType.NhaCungCap,
|
||||||
|
taxCode: '', phone: '', email: '', address: '', contactPerson: '', note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuppliersPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<string | undefined>()
|
||||||
|
const [sortDesc, setSortDesc] = useState(true)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm)
|
||||||
|
const isEdit = !!form.id
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['suppliers', { page, search, sortBy, sortDesc }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Paged<Supplier>>('/suppliers', {
|
||||||
|
params: { page, pageSize: 20, search: search || undefined, sortBy, sortDesc },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: async (data: FormState) => {
|
||||||
|
const payload = {
|
||||||
|
id: data.id,
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
type: Number(data.type),
|
||||||
|
taxCode: data.taxCode || null,
|
||||||
|
phone: data.phone || null,
|
||||||
|
email: data.email || null,
|
||||||
|
address: data.address || null,
|
||||||
|
contactPerson: data.contactPerson || null,
|
||||||
|
note: data.note || null,
|
||||||
|
}
|
||||||
|
if (data.id) {
|
||||||
|
await api.put(`/suppliers/${data.id}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/suppliers', payload)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['suppliers'] })
|
||||||
|
toast.success(isEdit ? 'Đã cập nhật NCC' : 'Đã thêm NCC')
|
||||||
|
setOpen(false)
|
||||||
|
setForm(emptyForm)
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (id: string) => await api.delete(`/suppliers/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['suppliers'] })
|
||||||
|
toast.success('Đã xóa')
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setForm(emptyForm)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(s: Supplier) {
|
||||||
|
setForm({
|
||||||
|
id: s.id, code: s.code, name: s.name, type: s.type,
|
||||||
|
taxCode: s.taxCode ?? '', phone: s.phone ?? '', email: s.email ?? '',
|
||||||
|
address: s.address ?? '', contactPerson: s.contactPerson ?? '', note: s.note ?? '',
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
mutate.mutate(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Supplier>[] = [
|
||||||
|
{ key: 'code', header: 'Mã', sortable: true, render: s => <span className="font-mono text-xs">{s.code}</span>, width: 'w-32' },
|
||||||
|
{ key: 'name', header: 'Tên NCC', sortable: true, render: s => s.name },
|
||||||
|
{ key: 'type', header: 'Loại', sortable: true, width: 'w-36', render: s => SupplierTypeLabel[s.type] },
|
||||||
|
{ key: 'taxCode', header: 'MST', render: s => s.taxCode ?? '—' },
|
||||||
|
{ key: 'phone', header: 'Điện thoại', render: s => s.phone ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
width: 'w-32',
|
||||||
|
render: s => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => openEdit(s)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Delete">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Xóa NCC "${s.name}"?`)) remove.mutate(s.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Nhà cung cấp"
|
||||||
|
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
|
||||||
|
actions={
|
||||||
|
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
|
||||||
|
<Button onClick={openNew}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Thêm NCC
|
||||||
|
</Button>
|
||||||
|
</PermissionGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Tìm theo mã, tên, MST…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={list.data?.items ?? []}
|
||||||
|
getRowKey={s => s.id}
|
||||||
|
isLoading={list.isLoading}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortDesc={sortDesc}
|
||||||
|
onSortChange={(key, desc) => {
|
||||||
|
setSortBy(key)
|
||||||
|
setSortDesc(desc)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={isEdit ? 'Sửa NCC' : 'Thêm NCC mới'}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={mutate.isPending}>
|
||||||
|
{mutate.isPending ? 'Đang lưu…' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={submit} className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Mã NCC *</Label>
|
||||||
|
<Input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Loại *</Label>
|
||||||
|
<Select value={form.type} onChange={e => setForm({ ...form, type: Number(e.target.value) as SupplierType })}>
|
||||||
|
{Object.entries(SupplierTypeLabel).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Tên đầy đủ *</Label>
|
||||||
|
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>MST</Label>
|
||||||
|
<Input value={form.taxCode} onChange={e => setForm({ ...form, taxCode: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Điện thoại</Label>
|
||||||
|
<Input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Người liên hệ</Label>
|
||||||
|
<Input value={form.contactPerson} onChange={e => setForm({ ...form, contactPerson: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Địa chỉ</Label>
|
||||||
|
<Input value={form.address} onChange={e => setForm({ ...form, address: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Ghi chú</Label>
|
||||||
|
<Textarea rows={3} value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
fe-admin/src/pages/system/PermissionsPage.tsx
Normal file
129
fe-admin/src/pages/system/PermissionsPage.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import type { MenuItem, Permission, Role } from '@/types/menu'
|
||||||
|
|
||||||
|
type CrudKey = 'canRead' | 'canCreate' | 'canUpdate' | 'canDelete'
|
||||||
|
const CRUD_COLS: { key: CrudKey; label: string }[] = [
|
||||||
|
{ key: 'canRead', label: 'Xem' },
|
||||||
|
{ key: 'canCreate', label: 'Tạo' },
|
||||||
|
{ key: 'canUpdate', label: 'Sửa' },
|
||||||
|
{ key: 'canDelete', label: 'Xóa' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PermissionsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [roleId, setRoleId] = useState<string>('')
|
||||||
|
|
||||||
|
const roles = useQuery({
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: async () => (await api.get<Role[]>('/roles')).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const menus = useQuery({
|
||||||
|
queryKey: ['menus', 'all'],
|
||||||
|
queryFn: async () => (await api.get<MenuItem[]>('/menus')).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const perms = useQuery({
|
||||||
|
queryKey: ['permissions', roleId],
|
||||||
|
queryFn: async () => (await api.get<Permission[]>(`/permissions/by-role/${roleId}`)).data,
|
||||||
|
enabled: !!roleId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const upsert = useMutation({
|
||||||
|
mutationFn: async (p: { menuKey: string; canRead: boolean; canCreate: boolean; canUpdate: boolean; canDelete: boolean }) => {
|
||||||
|
await api.put('/permissions', { roleId, ...p })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['permissions', roleId] })
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const permMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Permission>()
|
||||||
|
for (const p of perms.data ?? []) map.set(p.menuKey, p)
|
||||||
|
return map
|
||||||
|
}, [perms.data])
|
||||||
|
|
||||||
|
function currentFlags(menuKey: string) {
|
||||||
|
const p = permMap.get(menuKey)
|
||||||
|
return {
|
||||||
|
canRead: p?.canRead ?? false,
|
||||||
|
canCreate: p?.canCreate ?? false,
|
||||||
|
canUpdate: p?.canUpdate ?? false,
|
||||||
|
canDelete: p?.canDelete ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(menuKey: string, field: CrudKey) {
|
||||||
|
const flags = currentFlags(menuKey)
|
||||||
|
const next = { ...flags, [field]: !flags[field] }
|
||||||
|
upsert.mutate({ menuKey, ...next })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Ma trận phân quyền"
|
||||||
|
description="Tick để gán quyền Xem / Tạo / Sửa / Xóa cho từng menu theo vai trò. Thay đổi lưu tự động."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 max-w-sm">
|
||||||
|
<Select value={roleId} onChange={e => setRoleId(e.target.value)}>
|
||||||
|
<option value="">-- Chọn vai trò --</option>
|
||||||
|
{roles.data?.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{roleId && (
|
||||||
|
<div className="overflow-auto rounded-md border border-slate-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Menu</th>
|
||||||
|
{CRUD_COLS.map(c => (
|
||||||
|
<th key={c.key} className="px-3 py-2 text-center font-medium w-20">{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{menus.data?.map(m => {
|
||||||
|
const flags = currentFlags(m.key)
|
||||||
|
const depth = m.parentKey ? 1 : 0
|
||||||
|
return (
|
||||||
|
<tr key={m.key} className="border-t border-slate-100">
|
||||||
|
<td className="px-3 py-2" style={{ paddingLeft: `${0.75 + depth * 1.5}rem` }}>
|
||||||
|
<span className="font-medium text-slate-800">{m.label}</span>
|
||||||
|
<span className="ml-2 font-mono text-xs text-slate-400">{m.key}</span>
|
||||||
|
</td>
|
||||||
|
{CRUD_COLS.map(c => (
|
||||||
|
<td key={c.key} className="px-3 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 cursor-pointer accent-brand-600"
|
||||||
|
checked={flags[c.key]}
|
||||||
|
disabled={upsert.isPending}
|
||||||
|
onChange={() => toggle(m.key, c.key)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
fe-admin/src/types/master.ts
Normal file
71
fe-admin/src/types/master.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
export type Paged<T> = {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
hasNext: boolean
|
||||||
|
hasPrev: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SupplierType = {
|
||||||
|
NhaCungCap: 1,
|
||||||
|
NhaThauPhu: 2,
|
||||||
|
ToDoi: 3,
|
||||||
|
DonViDichVu: 4,
|
||||||
|
ChuDauTu: 5,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
|
||||||
|
|
||||||
|
export const SupplierTypeLabel: Record<SupplierType, string> = {
|
||||||
|
1: 'Nhà cung cấp',
|
||||||
|
2: 'Nhà thầu phụ',
|
||||||
|
3: 'Tổ đội',
|
||||||
|
4: 'Đơn vị dịch vụ',
|
||||||
|
5: 'Chủ đầu tư',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Supplier = {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
type: SupplierType
|
||||||
|
taxCode: string | null
|
||||||
|
phone: string | null
|
||||||
|
email: string | null
|
||||||
|
address: string | null
|
||||||
|
contactPerson: string | null
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SupplierInput = Omit<Supplier, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
|
||||||
|
export type Project = {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
managerUserId: string | null
|
||||||
|
budgetTotal: number | null
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectInput = Omit<Project, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
|
||||||
|
export type Department = {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
managerUserId: string | null
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DepartmentInput = Omit<Department, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
37
fe-admin/src/types/menu.ts
Normal file
37
fe-admin/src/types/menu.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export type MenuNode = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
parentKey: string | null
|
||||||
|
order: number
|
||||||
|
icon: string | null
|
||||||
|
canRead: boolean
|
||||||
|
canCreate: boolean
|
||||||
|
canUpdate: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
children: MenuNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
parentKey: string | null
|
||||||
|
order: number
|
||||||
|
icon: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Role = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Permission = {
|
||||||
|
id: string
|
||||||
|
roleId: string
|
||||||
|
menuKey: string
|
||||||
|
canRead: boolean
|
||||||
|
canCreate: boolean
|
||||||
|
canUpdate: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Authorization;
|
||||||
|
|
||||||
|
public class MenuPermissionHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
UserManager<User> userManager,
|
||||||
|
RoleManager<Role> roleManager) : AuthorizationHandler<MenuPermissionRequirement>
|
||||||
|
{
|
||||||
|
protected override async Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context, MenuPermissionRequirement req)
|
||||||
|
{
|
||||||
|
var sub = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||||
|
?? context.User.FindFirst("sub")?.Value;
|
||||||
|
if (!Guid.TryParse(sub, out var userId)) return;
|
||||||
|
|
||||||
|
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||||
|
if (user is null || !user.IsActive) return;
|
||||||
|
|
||||||
|
var roleNames = await userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
|
// Admin bypass
|
||||||
|
if (roleNames.Contains(AppRoles.Admin))
|
||||||
|
{
|
||||||
|
context.Succeed(req);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleIds = new List<Guid>();
|
||||||
|
foreach (var name in roleNames)
|
||||||
|
{
|
||||||
|
var r = await roleManager.FindByNameAsync(name);
|
||||||
|
if (r is not null) roleIds.Add(r.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseQuery = db.Permissions
|
||||||
|
.Where(p => roleIds.Contains(p.RoleId) && p.MenuKey == req.MenuKey);
|
||||||
|
|
||||||
|
var hasPermission = req.Action switch
|
||||||
|
{
|
||||||
|
"Read" => await baseQuery.AnyAsync(p => p.CanRead),
|
||||||
|
"Create" => await baseQuery.AnyAsync(p => p.CanCreate),
|
||||||
|
"Update" => await baseQuery.AnyAsync(p => p.CanUpdate),
|
||||||
|
"Delete" => await baseQuery.AnyAsync(p => p.CanDelete),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasPermission) context.Succeed(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Authorization;
|
||||||
|
|
||||||
|
public class MenuPermissionRequirement(string menuKey, string action) : IAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public string MenuKey { get; } = menuKey;
|
||||||
|
public string Action { get; } = action; // "Read" | "Create" | "Update" | "Delete"
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.Master.Departments;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/departments")]
|
||||||
|
[Authorize]
|
||||||
|
public class DepartmentsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<DepartmentDto>>> List(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null, [FromQuery] string? sortBy = null, [FromQuery] bool sortDesc = true,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new ListDepartmentsQuery { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<DepartmentDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetDepartmentQuery(id), ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<Guid>> Create([FromBody] CreateDepartmentCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = await mediator.Send(cmd, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateDepartmentCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
|
||||||
|
await mediator.Send(cmd, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteDepartmentCommand(id), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Backend/SolutionErp.Api/Controllers/MenusController.cs
Normal file
22
src/Backend/SolutionErp.Api/Controllers/MenusController.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Permissions;
|
||||||
|
using SolutionErp.Application.Permissions.Dtos;
|
||||||
|
using SolutionErp.Application.Permissions.Queries.GetMyMenuTree;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/menus")]
|
||||||
|
[Authorize]
|
||||||
|
public class MenusController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("me")]
|
||||||
|
public async Task<ActionResult<List<MenuNodeDto>>> Me(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetMyMenuTreeQuery(), ct));
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<MenuItemDto>>> List(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new ListMenuItemsQuery(), ct));
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Permissions;
|
||||||
|
using SolutionErp.Application.Permissions.Dtos;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/permissions")]
|
||||||
|
[Authorize(Policy = "Permissions.Read")]
|
||||||
|
public class PermissionsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("by-role/{roleId:guid}")]
|
||||||
|
public async Task<ActionResult<List<PermissionDto>>> ListByRole(Guid roleId, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new ListPermissionsByRoleQuery(roleId), ct));
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Policy = "Permissions.Update")]
|
||||||
|
public async Task<IActionResult> Upsert([FromBody] UpsertPermissionCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(cmd, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.Master.Projects;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/projects")]
|
||||||
|
[Authorize]
|
||||||
|
public class ProjectsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<ProjectDto>>> List(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null, [FromQuery] string? sortBy = null, [FromQuery] bool sortDesc = true,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new ListProjectsQuery { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<ProjectDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetProjectQuery(id), ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<Guid>> Create([FromBody] CreateProjectCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = await mediator.Send(cmd, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProjectCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
|
||||||
|
await mediator.Send(cmd, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteProjectCommand(id), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Backend/SolutionErp.Api/Controllers/RolesController.cs
Normal file
17
src/Backend/SolutionErp.Api/Controllers/RolesController.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Permissions;
|
||||||
|
using SolutionErp.Application.Permissions.Dtos;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/roles")]
|
||||||
|
[Authorize]
|
||||||
|
public class RolesController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<RoleDto>>> List(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new ListRolesQuery(), ct));
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Commands.DeleteSupplier;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/suppliers")]
|
||||||
|
[Authorize]
|
||||||
|
public class SuppliersController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<SupplierDto>>> List(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null, [FromQuery] string? sortBy = null, [FromQuery] bool sortDesc = true,
|
||||||
|
[FromQuery] SupplierType? type = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new ListSuppliersQuery(type) { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<SupplierDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetSupplierQuery(id), ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<Guid>> Create([FromBody] CreateSupplierCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = await mediator.Send(cmd, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateSupplierCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
|
||||||
|
await mediator.Send(cmd, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteSupplierCommand(id), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,15 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using SolutionErp.Api.Authorization;
|
||||||
using SolutionErp.Api.Middleware;
|
using SolutionErp.Api.Middleware;
|
||||||
using SolutionErp.Api.Services;
|
using SolutionErp.Api.Services;
|
||||||
using SolutionErp.Application;
|
using SolutionErp.Application;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Infrastructure;
|
using SolutionErp.Infrastructure;
|
||||||
using SolutionErp.Infrastructure.Identity;
|
using SolutionErp.Infrastructure.Identity;
|
||||||
using SolutionErp.Infrastructure.Persistence;
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
@ -48,7 +51,19 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
ClockSkew = TimeSpan.FromMinutes(1),
|
ClockSkew = TimeSpan.FromMinutes(1),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, MenuPermissionHandler>();
|
||||||
|
builder.Services.AddAuthorization(opts =>
|
||||||
|
{
|
||||||
|
foreach (var menu in MenuKeys.All)
|
||||||
|
{
|
||||||
|
foreach (var action in MenuKeys.Actions)
|
||||||
|
{
|
||||||
|
opts.AddPolicy($"{menu}.{action}", p =>
|
||||||
|
p.Requirements.Add(new MenuPermissionRequirement(menu, action)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- CORS (2 FE dev origins) ----------
|
// ---------- CORS (2 FE dev origins) ----------
|
||||||
builder.Services.AddCors(opts =>
|
builder.Services.AddCors(opts =>
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
namespace SolutionErp.Application.Common.Interfaces;
|
namespace SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
public interface IApplicationDbContext
|
public interface IApplicationDbContext
|
||||||
{
|
{
|
||||||
|
DbSet<Supplier> Suppliers { get; }
|
||||||
|
DbSet<Project> Projects { get; }
|
||||||
|
DbSet<Department> Departments { get; }
|
||||||
|
DbSet<MenuItem> MenuItems { get; }
|
||||||
|
DbSet<Permission> Permissions { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
namespace SolutionErp.Application.Common.Models;
|
||||||
|
|
||||||
|
public record PagedResult<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
int Total,
|
||||||
|
int Page,
|
||||||
|
int PageSize)
|
||||||
|
{
|
||||||
|
public int TotalPages => (int)Math.Ceiling(Total / (double)PageSize);
|
||||||
|
public bool HasNext => Page * PageSize < Total;
|
||||||
|
public bool HasPrev => Page > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract record PagedRequest
|
||||||
|
{
|
||||||
|
private int _page = 1;
|
||||||
|
private int _pageSize = 20;
|
||||||
|
|
||||||
|
public int Page { get => _page; init => _page = value < 1 ? 1 : value; }
|
||||||
|
public int PageSize { get => _pageSize; init => _pageSize = value switch { < 1 => 20, > 200 => 200, _ => value }; }
|
||||||
|
public string? Search { get; init; }
|
||||||
|
public string? SortBy { get; init; }
|
||||||
|
public bool SortDesc { get; init; }
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Departments;
|
||||||
|
|
||||||
|
public record DepartmentDto(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
Guid? ManagerUserId,
|
||||||
|
string? Note,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
public record ListDepartmentsQuery : PagedRequest, IRequest<PagedResult<DepartmentDto>>;
|
||||||
|
|
||||||
|
public class ListDepartmentsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListDepartmentsQuery, PagedResult<DepartmentDto>>
|
||||||
|
{
|
||||||
|
public async Task<PagedResult<DepartmentDto>> Handle(ListDepartmentsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = db.Departments.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
|
{
|
||||||
|
var s = request.Search.Trim();
|
||||||
|
query = query.Where(x => x.Code.Contains(s) || x.Name.Contains(s));
|
||||||
|
}
|
||||||
|
query = (request.SortBy, request.SortDesc) switch
|
||||||
|
{
|
||||||
|
("code", true) => query.OrderByDescending(x => x.Code),
|
||||||
|
("code", false) => query.OrderBy(x => x.Code),
|
||||||
|
("name", true) => query.OrderByDescending(x => x.Name),
|
||||||
|
("name", false) => query.OrderBy(x => x.Name),
|
||||||
|
_ => query.OrderByDescending(x => x.CreatedAt),
|
||||||
|
};
|
||||||
|
var total = await query.CountAsync(ct);
|
||||||
|
var items = await query
|
||||||
|
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
|
||||||
|
.Select(x => new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.CreatedAt, x.UpdatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new PagedResult<DepartmentDto>(items, total, request.Page, request.PageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GetDepartmentQuery(Guid Id) : IRequest<DepartmentDto>;
|
||||||
|
|
||||||
|
public class GetDepartmentQueryHandler(IApplicationDbContext db) : IRequestHandler<GetDepartmentQuery, DepartmentDto>
|
||||||
|
{
|
||||||
|
public async Task<DepartmentDto> Handle(GetDepartmentQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var x = await db.Departments.AsNoTracking().FirstOrDefaultAsync(p => p.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Department", request.Id);
|
||||||
|
return new DepartmentDto(x.Id, x.Code, x.Name, x.ManagerUserId, x.Note, x.CreatedAt, x.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateDepartmentCommand(string Code, string Name, Guid? ManagerUserId, string? Note) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreateDepartmentCommandValidator : AbstractValidator<CreateDepartmentCommand>
|
||||||
|
{
|
||||||
|
public CreateDepartmentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateDepartmentCommandHandler(IApplicationDbContext db) : IRequestHandler<CreateDepartmentCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(CreateDepartmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.Departments.AnyAsync(x => x.Code == request.Code, ct))
|
||||||
|
throw new ConflictException($"Mã phòng ban '{request.Code}' đã tồn tại.");
|
||||||
|
var entity = new Department
|
||||||
|
{
|
||||||
|
Code = request.Code, Name = request.Name,
|
||||||
|
ManagerUserId = request.ManagerUserId, Note = request.Note,
|
||||||
|
};
|
||||||
|
db.Departments.Add(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateDepartmentCommand(Guid Id, string Code, string Name, Guid? ManagerUserId, string? Note) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateDepartmentCommandValidator : AbstractValidator<UpdateDepartmentCommand>
|
||||||
|
{
|
||||||
|
public UpdateDepartmentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateDepartmentCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateDepartmentCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateDepartmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.Departments.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Department", request.Id);
|
||||||
|
if (entity.Code != request.Code && await db.Departments.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
|
||||||
|
throw new ConflictException($"Mã phòng ban '{request.Code}' đã tồn tại.");
|
||||||
|
entity.Code = request.Code;
|
||||||
|
entity.Name = request.Name;
|
||||||
|
entity.ManagerUserId = request.ManagerUserId;
|
||||||
|
entity.Note = request.Note;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeleteDepartmentCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class DeleteDepartmentCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteDepartmentCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeleteDepartmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.Departments.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Department", request.Id);
|
||||||
|
db.Departments.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Projects;
|
||||||
|
|
||||||
|
public record ProjectDto(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
DateTime? StartDate,
|
||||||
|
DateTime? EndDate,
|
||||||
|
Guid? ManagerUserId,
|
||||||
|
decimal? BudgetTotal,
|
||||||
|
string? Note,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
// ===================== LIST =====================
|
||||||
|
public record ListProjectsQuery : PagedRequest, IRequest<PagedResult<ProjectDto>>;
|
||||||
|
|
||||||
|
public class ListProjectsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListProjectsQuery, PagedResult<ProjectDto>>
|
||||||
|
{
|
||||||
|
public async Task<PagedResult<ProjectDto>> Handle(ListProjectsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = db.Projects.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
|
{
|
||||||
|
var s = request.Search.Trim();
|
||||||
|
query = query.Where(x => x.Code.Contains(s) || x.Name.Contains(s));
|
||||||
|
}
|
||||||
|
query = (request.SortBy, request.SortDesc) switch
|
||||||
|
{
|
||||||
|
("code", true) => query.OrderByDescending(x => x.Code),
|
||||||
|
("code", false) => query.OrderBy(x => x.Code),
|
||||||
|
("name", true) => query.OrderByDescending(x => x.Name),
|
||||||
|
("name", false) => query.OrderBy(x => x.Name),
|
||||||
|
_ => query.OrderByDescending(x => x.CreatedAt),
|
||||||
|
};
|
||||||
|
var total = await query.CountAsync(ct);
|
||||||
|
var items = await query
|
||||||
|
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
|
||||||
|
.Select(x => new ProjectDto(x.Id, x.Code, x.Name, x.StartDate, x.EndDate, x.ManagerUserId, x.BudgetTotal, x.Note, x.CreatedAt, x.UpdatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new PagedResult<ProjectDto>(items, total, request.Page, request.PageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== GET =====================
|
||||||
|
public record GetProjectQuery(Guid Id) : IRequest<ProjectDto>;
|
||||||
|
|
||||||
|
public class GetProjectQueryHandler(IApplicationDbContext db) : IRequestHandler<GetProjectQuery, ProjectDto>
|
||||||
|
{
|
||||||
|
public async Task<ProjectDto> Handle(GetProjectQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var x = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Project", request.Id);
|
||||||
|
return new ProjectDto(x.Id, x.Code, x.Name, x.StartDate, x.EndDate, x.ManagerUserId, x.BudgetTotal, x.Note, x.CreatedAt, x.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== CREATE =====================
|
||||||
|
public record CreateProjectCommand(
|
||||||
|
string Code, string Name, DateTime? StartDate, DateTime? EndDate,
|
||||||
|
Guid? ManagerUserId, decimal? BudgetTotal, string? Note) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
|
||||||
|
{
|
||||||
|
public CreateProjectCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.BudgetTotal).GreaterThanOrEqualTo(0).When(x => x.BudgetTotal.HasValue);
|
||||||
|
RuleFor(x => x).Must(x => !x.StartDate.HasValue || !x.EndDate.HasValue || x.EndDate >= x.StartDate)
|
||||||
|
.WithMessage("Ngày kết thúc phải sau ngày bắt đầu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateProjectCommandHandler(IApplicationDbContext db) : IRequestHandler<CreateProjectCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(CreateProjectCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.Projects.AnyAsync(x => x.Code == request.Code, ct))
|
||||||
|
throw new ConflictException($"Mã dự án '{request.Code}' đã tồn tại.");
|
||||||
|
var entity = new Project
|
||||||
|
{
|
||||||
|
Code = request.Code, Name = request.Name,
|
||||||
|
StartDate = request.StartDate, EndDate = request.EndDate,
|
||||||
|
ManagerUserId = request.ManagerUserId, BudgetTotal = request.BudgetTotal, Note = request.Note,
|
||||||
|
};
|
||||||
|
db.Projects.Add(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== UPDATE =====================
|
||||||
|
public record UpdateProjectCommand(
|
||||||
|
Guid Id, string Code, string Name, DateTime? StartDate, DateTime? EndDate,
|
||||||
|
Guid? ManagerUserId, decimal? BudgetTotal, string? Note) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateProjectCommandValidator : AbstractValidator<UpdateProjectCommand>
|
||||||
|
{
|
||||||
|
public UpdateProjectCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.BudgetTotal).GreaterThanOrEqualTo(0).When(x => x.BudgetTotal.HasValue);
|
||||||
|
RuleFor(x => x).Must(x => !x.StartDate.HasValue || !x.EndDate.HasValue || x.EndDate >= x.StartDate)
|
||||||
|
.WithMessage("Ngày kết thúc phải sau ngày bắt đầu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateProjectCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateProjectCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateProjectCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.Projects.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Project", request.Id);
|
||||||
|
if (entity.Code != request.Code && await db.Projects.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
|
||||||
|
throw new ConflictException($"Mã dự án '{request.Code}' đã tồn tại.");
|
||||||
|
entity.Code = request.Code;
|
||||||
|
entity.Name = request.Name;
|
||||||
|
entity.StartDate = request.StartDate;
|
||||||
|
entity.EndDate = request.EndDate;
|
||||||
|
entity.ManagerUserId = request.ManagerUserId;
|
||||||
|
entity.BudgetTotal = request.BudgetTotal;
|
||||||
|
entity.Note = request.Note;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== DELETE =====================
|
||||||
|
public record DeleteProjectCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class DeleteProjectCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteProjectCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeleteProjectCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.Projects.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Project", request.Id);
|
||||||
|
db.Projects.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier;
|
||||||
|
|
||||||
|
public record CreateSupplierCommand(
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
SupplierType Type,
|
||||||
|
string? TaxCode,
|
||||||
|
string? Phone,
|
||||||
|
string? Email,
|
||||||
|
string? Address,
|
||||||
|
string? ContactPerson,
|
||||||
|
string? Note) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreateSupplierCommandValidator : AbstractValidator<CreateSupplierCommand>
|
||||||
|
{
|
||||||
|
public CreateSupplierCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.Type).IsInEnum();
|
||||||
|
RuleFor(x => x.TaxCode).MaximumLength(20);
|
||||||
|
RuleFor(x => x.Phone).MaximumLength(30);
|
||||||
|
RuleFor(x => x.Email).MaximumLength(100).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email));
|
||||||
|
RuleFor(x => x.Address).MaximumLength(500);
|
||||||
|
RuleFor(x => x.ContactPerson).MaximumLength(200);
|
||||||
|
RuleFor(x => x.Note).MaximumLength(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateSupplierCommandHandler : IRequestHandler<CreateSupplierCommand, Guid>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _db;
|
||||||
|
|
||||||
|
public CreateSupplierCommandHandler(IApplicationDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<Guid> Handle(CreateSupplierCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await _db.Suppliers.AnyAsync(x => x.Code == request.Code, ct))
|
||||||
|
throw new ConflictException($"Mã NCC '{request.Code}' đã tồn tại.");
|
||||||
|
|
||||||
|
var entity = new Supplier
|
||||||
|
{
|
||||||
|
Code = request.Code,
|
||||||
|
Name = request.Name,
|
||||||
|
Type = request.Type,
|
||||||
|
TaxCode = request.TaxCode,
|
||||||
|
Phone = request.Phone,
|
||||||
|
Email = request.Email,
|
||||||
|
Address = request.Address,
|
||||||
|
ContactPerson = request.ContactPerson,
|
||||||
|
Note = request.Note,
|
||||||
|
};
|
||||||
|
_db.Suppliers.Add(entity);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Suppliers.Commands.DeleteSupplier;
|
||||||
|
|
||||||
|
public record DeleteSupplierCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class DeleteSupplierCommandHandler : IRequestHandler<DeleteSupplierCommand>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _db;
|
||||||
|
|
||||||
|
public DeleteSupplierCommandHandler(IApplicationDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task Handle(DeleteSupplierCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await _db.Suppliers.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Supplier", request.Id);
|
||||||
|
_db.Suppliers.Remove(entity); // AuditingInterceptor convert sang soft delete
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier;
|
||||||
|
|
||||||
|
public record UpdateSupplierCommand(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
SupplierType Type,
|
||||||
|
string? TaxCode,
|
||||||
|
string? Phone,
|
||||||
|
string? Email,
|
||||||
|
string? Address,
|
||||||
|
string? ContactPerson,
|
||||||
|
string? Note) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateSupplierCommandValidator : AbstractValidator<UpdateSupplierCommand>
|
||||||
|
{
|
||||||
|
public UpdateSupplierCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.Type).IsInEnum();
|
||||||
|
RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateSupplierCommandHandler : IRequestHandler<UpdateSupplierCommand>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _db;
|
||||||
|
|
||||||
|
public UpdateSupplierCommandHandler(IApplicationDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task Handle(UpdateSupplierCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await _db.Suppliers.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Supplier", request.Id);
|
||||||
|
|
||||||
|
if (entity.Code != request.Code &&
|
||||||
|
await _db.Suppliers.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct))
|
||||||
|
throw new ConflictException($"Mã NCC '{request.Code}' đã tồn tại.");
|
||||||
|
|
||||||
|
entity.Code = request.Code;
|
||||||
|
entity.Name = request.Name;
|
||||||
|
entity.Type = request.Type;
|
||||||
|
entity.TaxCode = request.TaxCode;
|
||||||
|
entity.Phone = request.Phone;
|
||||||
|
entity.Email = request.Email;
|
||||||
|
entity.Address = request.Address;
|
||||||
|
entity.ContactPerson = request.ContactPerson;
|
||||||
|
entity.Note = request.Note;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Suppliers.Dtos;
|
||||||
|
|
||||||
|
public record SupplierDto(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
SupplierType Type,
|
||||||
|
string? TaxCode,
|
||||||
|
string? Phone,
|
||||||
|
string? Email,
|
||||||
|
string? Address,
|
||||||
|
string? ContactPerson,
|
||||||
|
string? Note,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? UpdatedAt);
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
|
||||||
|
|
||||||
|
public record GetSupplierQuery(Guid Id) : IRequest<SupplierDto>;
|
||||||
|
|
||||||
|
public class GetSupplierQueryHandler : IRequestHandler<GetSupplierQuery, SupplierDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _db;
|
||||||
|
|
||||||
|
public GetSupplierQueryHandler(IApplicationDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<SupplierDto> Handle(GetSupplierQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var x = await _db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("Supplier", request.Id);
|
||||||
|
return new SupplierDto(x.Id, x.Code, x.Name, x.Type, x.TaxCode, x.Phone, x.Email, x.Address, x.ContactPerson, x.Note, x.CreatedAt, x.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
|
||||||
|
|
||||||
|
public record ListSuppliersQuery(SupplierType? Type = null) : PagedRequest, IRequest<PagedResult<SupplierDto>>;
|
||||||
|
|
||||||
|
public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, PagedResult<SupplierDto>>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _db;
|
||||||
|
|
||||||
|
public ListSuppliersQueryHandler(IApplicationDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<PagedResult<SupplierDto>> Handle(ListSuppliersQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = _db.Suppliers.AsNoTracking();
|
||||||
|
|
||||||
|
if (request.Type is not null)
|
||||||
|
query = query.Where(x => x.Type == request.Type);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
|
{
|
||||||
|
var s = request.Search.Trim();
|
||||||
|
query = query.Where(x =>
|
||||||
|
x.Code.Contains(s) || x.Name.Contains(s) ||
|
||||||
|
(x.TaxCode != null && x.TaxCode.Contains(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
query = (request.SortBy, request.SortDesc) switch
|
||||||
|
{
|
||||||
|
("code", true) => query.OrderByDescending(x => x.Code),
|
||||||
|
("code", false) => query.OrderBy(x => x.Code),
|
||||||
|
("name", true) => query.OrderByDescending(x => x.Name),
|
||||||
|
("name", false) => query.OrderBy(x => x.Name),
|
||||||
|
("type", true) => query.OrderByDescending(x => x.Type),
|
||||||
|
("type", false) => query.OrderBy(x => x.Type),
|
||||||
|
_ => query.OrderByDescending(x => x.CreatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
var total = await query.CountAsync(ct);
|
||||||
|
var items = await query
|
||||||
|
.Skip((request.Page - 1) * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(x => new SupplierDto(
|
||||||
|
x.Id, x.Code, x.Name, x.Type, x.TaxCode, x.Phone, x.Email, x.Address, x.ContactPerson, x.Note,
|
||||||
|
x.CreatedAt, x.UpdatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<SupplierDto>(items, total, request.Page, request.PageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
namespace SolutionErp.Application.Permissions.Dtos;
|
||||||
|
|
||||||
|
public record MenuNodeDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string? ParentKey,
|
||||||
|
int Order,
|
||||||
|
string? Icon,
|
||||||
|
bool CanRead,
|
||||||
|
bool CanCreate,
|
||||||
|
bool CanUpdate,
|
||||||
|
bool CanDelete,
|
||||||
|
List<MenuNodeDto> Children);
|
||||||
|
|
||||||
|
public record PermissionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid RoleId,
|
||||||
|
string MenuKey,
|
||||||
|
bool CanRead,
|
||||||
|
bool CanCreate,
|
||||||
|
bool CanUpdate,
|
||||||
|
bool CanDelete);
|
||||||
|
|
||||||
|
public record RoleDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record MenuItemDto(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string? ParentKey,
|
||||||
|
int Order,
|
||||||
|
string? Icon);
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Permissions.Dtos;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Permissions;
|
||||||
|
|
||||||
|
// ========== List menu items (for matrix page) ==========
|
||||||
|
|
||||||
|
public record ListMenuItemsQuery : IRequest<List<MenuItemDto>>;
|
||||||
|
|
||||||
|
public class ListMenuItemsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListMenuItemsQuery, List<MenuItemDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<MenuItemDto>> Handle(ListMenuItemsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await db.MenuItems.AsNoTracking()
|
||||||
|
.OrderBy(m => m.Order)
|
||||||
|
.Select(m => new MenuItemDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== List permissions by role (matrix edit) ==========
|
||||||
|
|
||||||
|
public record ListPermissionsByRoleQuery(Guid RoleId) : IRequest<List<PermissionDto>>;
|
||||||
|
|
||||||
|
public class ListPermissionsByRoleQueryHandler(IApplicationDbContext db) : IRequestHandler<ListPermissionsByRoleQuery, List<PermissionDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<PermissionDto>> Handle(ListPermissionsByRoleQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await db.Permissions.AsNoTracking()
|
||||||
|
.Where(p => p.RoleId == request.RoleId)
|
||||||
|
.Select(p => new PermissionDto(p.Id, p.RoleId, p.MenuKey, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Upsert permission (admin update matrix) ==========
|
||||||
|
|
||||||
|
public record UpsertPermissionCommand(
|
||||||
|
Guid RoleId,
|
||||||
|
string MenuKey,
|
||||||
|
bool CanRead,
|
||||||
|
bool CanCreate,
|
||||||
|
bool CanUpdate,
|
||||||
|
bool CanDelete) : IRequest;
|
||||||
|
|
||||||
|
public class UpsertPermissionCommandValidator : AbstractValidator<UpsertPermissionCommand>
|
||||||
|
{
|
||||||
|
public UpsertPermissionCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RoleId).NotEmpty();
|
||||||
|
RuleFor(x => x.MenuKey).NotEmpty().MaximumLength(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpsertPermissionCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
RoleManager<Role> roleManager,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UpsertPermissionCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpsertPermissionCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var role = await roleManager.FindByIdAsync(request.RoleId.ToString())
|
||||||
|
?? throw new NotFoundException("Role", request.RoleId);
|
||||||
|
|
||||||
|
// Guard: không cho tự xóa quyền của role Admin (nếu người đang edit là Admin)
|
||||||
|
if (role.Name == AppRoles.Admin && currentUser.Roles.Contains(AppRoles.Admin))
|
||||||
|
{
|
||||||
|
if (!(request.CanRead && request.CanCreate && request.CanUpdate && request.CanDelete))
|
||||||
|
throw new ForbiddenException("Không thể giảm quyền của role Admin khi bạn đang là Admin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var menu = await db.MenuItems.FirstOrDefaultAsync(m => m.Key == request.MenuKey, ct)
|
||||||
|
?? throw new NotFoundException("MenuItem", request.MenuKey);
|
||||||
|
|
||||||
|
var existing = await db.Permissions.FirstOrDefaultAsync(
|
||||||
|
p => p.RoleId == request.RoleId && p.MenuKey == request.MenuKey, ct);
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.Permissions.Add(new Permission
|
||||||
|
{
|
||||||
|
RoleId = request.RoleId,
|
||||||
|
MenuKey = request.MenuKey,
|
||||||
|
CanRead = request.CanRead,
|
||||||
|
CanCreate = request.CanCreate,
|
||||||
|
CanUpdate = request.CanUpdate,
|
||||||
|
CanDelete = request.CanDelete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.CanRead = request.CanRead;
|
||||||
|
existing.CanCreate = request.CanCreate;
|
||||||
|
existing.CanUpdate = request.CanUpdate;
|
||||||
|
existing.CanDelete = request.CanDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== List all roles ==========
|
||||||
|
|
||||||
|
public record ListRolesQuery : IRequest<List<RoleDto>>;
|
||||||
|
|
||||||
|
public class ListRolesQueryHandler(RoleManager<Role> roleManager) : IRequestHandler<ListRolesQuery, List<RoleDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<RoleDto>> Handle(ListRolesQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await roleManager.Roles
|
||||||
|
.OrderBy(r => r.Name)
|
||||||
|
.Select(r => new RoleDto(r.Id, r.Name!, r.Description, r.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Permissions.Dtos;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Permissions.Queries.GetMyMenuTree;
|
||||||
|
|
||||||
|
public record GetMyMenuTreeQuery : IRequest<List<MenuNodeDto>>;
|
||||||
|
|
||||||
|
public class GetMyMenuTreeQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
UserManager<User> userManager,
|
||||||
|
RoleManager<Role> roleManager) : IRequestHandler<GetMyMenuTreeQuery, List<MenuNodeDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<MenuNodeDto>> Handle(GetMyMenuTreeQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var user = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString())
|
||||||
|
?? throw new UnauthorizedException();
|
||||||
|
var roleNames = await userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
|
var roleIds = new List<Guid>();
|
||||||
|
foreach (var name in roleNames)
|
||||||
|
{
|
||||||
|
var role = await roleManager.FindByNameAsync(name);
|
||||||
|
if (role is not null) roleIds.Add(role.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var menus = await db.MenuItems.AsNoTracking().OrderBy(m => m.Order).ToListAsync(ct);
|
||||||
|
var perms = await db.Permissions.AsNoTracking()
|
||||||
|
.Where(p => roleIds.Contains(p.RoleId))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Union CRUD flags qua các role
|
||||||
|
var resolved = perms
|
||||||
|
.GroupBy(p => p.MenuKey)
|
||||||
|
.ToDictionary(g => g.Key, g => (
|
||||||
|
Read: g.Any(p => p.CanRead),
|
||||||
|
Create: g.Any(p => p.CanCreate),
|
||||||
|
Update: g.Any(p => p.CanUpdate),
|
||||||
|
Delete: g.Any(p => p.CanDelete)));
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
List<MenuNodeDto> BuildChildren(string? parentKey) => menus
|
||||||
|
.Where(m => m.ParentKey == parentKey)
|
||||||
|
.Select(m =>
|
||||||
|
{
|
||||||
|
var flags = resolved.TryGetValue(m.Key, out var f) ? f : (false, false, false, false);
|
||||||
|
return new MenuNodeDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon,
|
||||||
|
flags.Item1, flags.Item2, flags.Item3, flags.Item4,
|
||||||
|
BuildChildren(m.Key));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tree = BuildChildren(null);
|
||||||
|
|
||||||
|
// Filter: chỉ trả về node có CanRead=true (hoặc có child CanRead=true)
|
||||||
|
static bool HasAccess(MenuNodeDto n) => n.CanRead || n.Children.Any(HasAccess);
|
||||||
|
List<MenuNodeDto> Filter(List<MenuNodeDto> nodes) => nodes
|
||||||
|
.Where(HasAccess)
|
||||||
|
.Select(n => n with { Children = Filter(n.Children) })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Filter(tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@
|
|||||||
<PackageReference Include="AutoMapper" Version="16.1.1" />
|
<PackageReference Include="AutoMapper" Version="16.1.1" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
14
src/Backend/SolutionErp.Domain/Identity/MenuItem.cs
Normal file
14
src/Backend/SolutionErp.Domain/Identity/MenuItem.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
public class MenuItem
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty; // PK, PascalCase
|
||||||
|
public string Label { get; set; } = string.Empty; // Tiếng Việt display
|
||||||
|
public string? ParentKey { get; set; } // NULL nếu root
|
||||||
|
public int Order { get; set; }
|
||||||
|
public string? Icon { get; set; } // lucide-react icon name
|
||||||
|
|
||||||
|
public MenuItem? Parent { get; set; }
|
||||||
|
public List<MenuItem> Children { get; set; } = new();
|
||||||
|
public List<Permission> Permissions { get; set; } = new();
|
||||||
|
}
|
||||||
29
src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
Normal file
29
src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
namespace SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
// Nguồn duy nhất (single source of truth) cho menu key — dùng ở cả BE (policy) và seed.
|
||||||
|
// FE có `src/lib/menuKeys.ts` đồng bộ tay.
|
||||||
|
public static class MenuKeys
|
||||||
|
{
|
||||||
|
public const string Dashboard = "Dashboard";
|
||||||
|
public const string Master = "Master";
|
||||||
|
public const string Suppliers = "Suppliers";
|
||||||
|
public const string Projects = "Projects";
|
||||||
|
public const string Departments = "Departments";
|
||||||
|
public const string Contracts = "Contracts";
|
||||||
|
public const string Forms = "Forms";
|
||||||
|
public const string Reports = "Reports";
|
||||||
|
public const string System = "System";
|
||||||
|
public const string Users = "Users";
|
||||||
|
public const string Roles = "Roles";
|
||||||
|
public const string Permissions = "Permissions";
|
||||||
|
|
||||||
|
public static readonly string[] All =
|
||||||
|
[
|
||||||
|
Dashboard,
|
||||||
|
Master, Suppliers, Projects, Departments,
|
||||||
|
Contracts, Forms, Reports,
|
||||||
|
System, Users, Roles, Permissions,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
||||||
|
}
|
||||||
15
src/Backend/SolutionErp.Domain/Identity/Permission.cs
Normal file
15
src/Backend/SolutionErp.Domain/Identity/Permission.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
public class Permission
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid RoleId { get; set; }
|
||||||
|
public string MenuKey { get; set; } = string.Empty;
|
||||||
|
public bool CanRead { get; set; }
|
||||||
|
public bool CanCreate { get; set; }
|
||||||
|
public bool CanUpdate { get; set; }
|
||||||
|
public bool CanDelete { get; set; }
|
||||||
|
|
||||||
|
public Role? Role { get; set; }
|
||||||
|
public MenuItem? Menu { get; set; }
|
||||||
|
}
|
||||||
11
src/Backend/SolutionErp.Domain/Master/Department.cs
Normal file
11
src/Backend/SolutionErp.Domain/Master/Department.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
public class Department : AuditableEntity
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty; // vd "CCM", "PRO", "FIN"
|
||||||
|
public string Name { get; set; } = string.Empty; // vd "Phòng Kiểm soát Chi phí"
|
||||||
|
public Guid? ManagerUserId { get; set; } // TPB — Trưởng Phòng ban
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
14
src/Backend/SolutionErp.Domain/Master/Project.cs
Normal file
14
src/Backend/SolutionErp.Domain/Master/Project.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
public class Project : AuditableEntity
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty; // vd "FLOCK 01"
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public DateTime? StartDate { get; set; }
|
||||||
|
public DateTime? EndDate { get; set; }
|
||||||
|
public Guid? ManagerUserId { get; set; } // PM — Giám đốc Dự án
|
||||||
|
public decimal? BudgetTotal { get; set; } // Tổng ngân sách dự án (tham chiếu cho CCM check)
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
16
src/Backend/SolutionErp.Domain/Master/Supplier.cs
Normal file
16
src/Backend/SolutionErp.Domain/Master/Supplier.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
public class Supplier : AuditableEntity
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty; // Mã NCC (viết tắt, dùng trong mã HĐ)
|
||||||
|
public string Name { get; set; } = string.Empty; // Tên công ty đầy đủ
|
||||||
|
public SupplierType Type { get; set; }
|
||||||
|
public string? TaxCode { get; set; } // Mã số thuế
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? ContactPerson { get; set; } // Người liên hệ
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
10
src/Backend/SolutionErp.Domain/Master/SupplierType.cs
Normal file
10
src/Backend/SolutionErp.Domain/Master/SupplierType.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
public enum SupplierType
|
||||||
|
{
|
||||||
|
NhaCungCap = 1, // NCC — Nhà cung cấp
|
||||||
|
NhaThauPhu = 2, // NTP — Nhà thầu phụ
|
||||||
|
ToDoi = 3, // TĐ — Tổ đội
|
||||||
|
DonViDichVu = 4, // ĐVDV — Đơn vị dịch vụ
|
||||||
|
ChuDauTu = 5, // CĐT — Chủ đầu tư (đặc biệt, bypass quy trình CCM)
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Persistence;
|
namespace SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
@ -10,6 +11,12 @@ public class ApplicationDbContext
|
|||||||
{
|
{
|
||||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
|
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Supplier> Suppliers => Set<Supplier>();
|
||||||
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
|
public DbSet<Department> Departments => Set<Department>();
|
||||||
|
public DbSet<MenuItem> MenuItems => Set<MenuItem>();
|
||||||
|
public DbSet<Permission> Permissions => Set<Permission>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class DepartmentConfiguration : IEntityTypeConfiguration<Department>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Department> b)
|
||||||
|
{
|
||||||
|
b.ToTable("Departments");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
b.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
||||||
|
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
|
b.Property(x => x.Note).HasMaxLength(1000);
|
||||||
|
|
||||||
|
b.HasIndex(x => x.Code).IsUnique();
|
||||||
|
|
||||||
|
b.HasQueryFilter(x => !x.IsDeleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class MenuItemConfiguration : IEntityTypeConfiguration<MenuItem>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<MenuItem> b)
|
||||||
|
{
|
||||||
|
b.ToTable("MenuItems");
|
||||||
|
b.HasKey(x => x.Key);
|
||||||
|
|
||||||
|
b.Property(x => x.Key).HasMaxLength(50);
|
||||||
|
b.Property(x => x.Label).HasMaxLength(200).IsRequired();
|
||||||
|
b.Property(x => x.ParentKey).HasMaxLength(50);
|
||||||
|
b.Property(x => x.Icon).HasMaxLength(50);
|
||||||
|
|
||||||
|
b.HasOne(x => x.Parent)
|
||||||
|
.WithMany(x => x.Children)
|
||||||
|
.HasForeignKey(x => x.ParentKey)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasIndex(x => x.ParentKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class PermissionConfiguration : IEntityTypeConfiguration<Permission>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Permission> b)
|
||||||
|
{
|
||||||
|
b.ToTable("Permissions");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
b.Property(x => x.MenuKey).HasMaxLength(50).IsRequired();
|
||||||
|
|
||||||
|
b.HasOne(x => x.Role)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.RoleId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne(x => x.Menu)
|
||||||
|
.WithMany(m => m.Permissions)
|
||||||
|
.HasForeignKey(x => x.MenuKey)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasIndex(x => new { x.RoleId, x.MenuKey }).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Project> b)
|
||||||
|
{
|
||||||
|
b.ToTable("Projects");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
b.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
||||||
|
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
|
b.Property(x => x.BudgetTotal).HasPrecision(18, 2);
|
||||||
|
b.Property(x => x.Note).HasMaxLength(1000);
|
||||||
|
|
||||||
|
b.HasIndex(x => x.Code).IsUnique();
|
||||||
|
|
||||||
|
b.HasQueryFilter(x => !x.IsDeleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class SupplierConfiguration : IEntityTypeConfiguration<Supplier>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Supplier> b)
|
||||||
|
{
|
||||||
|
b.ToTable("Suppliers");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
b.Property(x => x.Code).HasMaxLength(50).IsRequired();
|
||||||
|
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
|
b.Property(x => x.Type).HasConversion<int>();
|
||||||
|
b.Property(x => x.TaxCode).HasMaxLength(20);
|
||||||
|
b.Property(x => x.Phone).HasMaxLength(30);
|
||||||
|
b.Property(x => x.Email).HasMaxLength(100);
|
||||||
|
b.Property(x => x.Address).HasMaxLength(500);
|
||||||
|
b.Property(x => x.ContactPerson).HasMaxLength(200);
|
||||||
|
b.Property(x => x.Note).HasMaxLength(1000);
|
||||||
|
|
||||||
|
b.HasIndex(x => x.Code).IsUnique();
|
||||||
|
b.HasIndex(x => x.Type);
|
||||||
|
|
||||||
|
b.HasQueryFilter(x => !x.IsDeleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,14 @@ public static class DbInitializer
|
|||||||
logger.LogInformation("Applying migrations...");
|
logger.LogInformation("Applying migrations...");
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
await SeedRolesAsync(roleManager, logger);
|
||||||
|
await SeedAdminAsync(userManager, logger);
|
||||||
|
await SeedMenuTreeAsync(db, logger);
|
||||||
|
await SeedAdminPermissionsAsync(db, roleManager, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedRolesAsync(RoleManager<Role> roleManager, ILogger logger)
|
||||||
|
{
|
||||||
foreach (var roleName in AppRoles.All)
|
foreach (var roleName in AppRoles.All)
|
||||||
{
|
{
|
||||||
if (!await roleManager.RoleExistsAsync(roleName))
|
if (!await roleManager.RoleExistsAsync(roleName))
|
||||||
@ -31,7 +39,10 @@ public static class DbInitializer
|
|||||||
logger.LogInformation("Created role {Role}", roleName);
|
logger.LogInformation("Created role {Role}", roleName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAdminAsync(UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
var admin = await userManager.FindByEmailAsync(AdminEmail);
|
var admin = await userManager.FindByEmailAsync(AdminEmail);
|
||||||
if (admin is null)
|
if (admin is null)
|
||||||
{
|
{
|
||||||
@ -54,4 +65,67 @@ public static class DbInitializer
|
|||||||
logger.LogInformation("Seeded admin user {Email}", AdminEmail);
|
logger.LogInformation("Seeded admin user {Email}", AdminEmail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
// (key, label, parent, order, icon) — icon là lucide-react name
|
||||||
|
var tree = new (string Key, string Label, string? Parent, int Order, string Icon)[]
|
||||||
|
{
|
||||||
|
(MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"),
|
||||||
|
(MenuKeys.Master, "Danh mục", null, 20, "Database"),
|
||||||
|
(MenuKeys.Suppliers, "Nhà cung cấp", MenuKeys.Master, 21, "Building2"),
|
||||||
|
(MenuKeys.Projects, "Dự án", MenuKeys.Master, 22, "FolderKanban"),
|
||||||
|
(MenuKeys.Departments, "Phòng ban", MenuKeys.Master, 23, "Users"),
|
||||||
|
(MenuKeys.Contracts, "Hợp đồng", null, 30, "FileText"),
|
||||||
|
(MenuKeys.Forms, "Biểu mẫu", null, 40, "FileSpreadsheet"),
|
||||||
|
(MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"),
|
||||||
|
(MenuKeys.System, "Hệ thống", null, 90, "Settings"),
|
||||||
|
(MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"),
|
||||||
|
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
|
||||||
|
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var existingKeys = await db.MenuItems.Select(m => m.Key).ToListAsync();
|
||||||
|
var added = 0;
|
||||||
|
foreach (var (key, label, parent, order, icon) in tree)
|
||||||
|
{
|
||||||
|
if (existingKeys.Contains(key)) continue;
|
||||||
|
db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = order, Icon = icon });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
if (added > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded {Count} menu items", added);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAdminPermissionsAsync(ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var adminRole = await roleManager.FindByNameAsync(AppRoles.Admin);
|
||||||
|
if (adminRole is null) return;
|
||||||
|
|
||||||
|
var existingMenuKeys = await db.Permissions
|
||||||
|
.Where(p => p.RoleId == adminRole.Id)
|
||||||
|
.Select(p => p.MenuKey)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
foreach (var menuKey in MenuKeys.All)
|
||||||
|
{
|
||||||
|
if (existingMenuKeys.Contains(menuKey)) continue;
|
||||||
|
db.Permissions.Add(new Permission
|
||||||
|
{
|
||||||
|
RoleId = adminRole.Id,
|
||||||
|
MenuKey = menuKey,
|
||||||
|
CanRead = true, CanCreate = true, CanUpdate = true, CanDelete = true,
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
if (added > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded {Count} admin permissions", added);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
494
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421041944_AddMasterData.Designer.cs
generated
Normal file
494
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421041944_AddMasterData.Designer.cs
generated
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260421041944_AddMasterData")]
|
||||||
|
partial class AddMasterData
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.6")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("RoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("UserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("UserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex")
|
||||||
|
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Roles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("FullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex")
|
||||||
|
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Department", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManagerUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Departments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<decimal?>("BudgetTotal")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManagerUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Projects", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPerson")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("nvarchar(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TaxCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Type");
|
||||||
|
|
||||||
|
b.ToTable("Suppliers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMasterData : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Departments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
ManagerUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Departments", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Projects",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
StartDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
EndDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
ManagerUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
BudgetTotal = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
|
||||||
|
Note = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Projects", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Suppliers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
Type = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TaxCode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||||
|
Phone = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
|
Address = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
ContactPerson = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
Note = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Suppliers", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Departments_Code",
|
||||||
|
table: "Departments",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Projects_Code",
|
||||||
|
table: "Projects",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Suppliers_Code",
|
||||||
|
table: "Suppliers",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Suppliers_Type",
|
||||||
|
table: "Suppliers",
|
||||||
|
column: "Type");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Departments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Projects");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Suppliers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
595
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421042236_AddPermissions.Designer.cs
generated
Normal file
595
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421042236_AddPermissions.Designer.cs
generated
Normal file
@ -0,0 +1,595 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260421042236_AddPermissions")]
|
||||||
|
partial class AddPermissions
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.6")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("RoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("UserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("UserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ParentKey")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.HasIndex("ParentKey");
|
||||||
|
|
||||||
|
b.ToTable("MenuItems", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("CanCreate")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanDelete")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanRead")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanUpdate")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("MenuKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MenuKey");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId", "MenuKey")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Permissions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex")
|
||||||
|
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Roles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("FullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex")
|
||||||
|
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Department", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManagerUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Departments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<decimal?>("BudgetTotal")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManagerUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Projects", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPerson")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("nvarchar(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TaxCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Type");
|
||||||
|
|
||||||
|
b.ToTable("Suppliers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentKey")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu")
|
||||||
|
.WithMany("Permissions")
|
||||||
|
.HasForeignKey("MenuKey")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.Role", "Role")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Menu");
|
||||||
|
|
||||||
|
b.Navigation("Role");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
|
b.Navigation("Permissions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPermissions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MenuItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||||
|
Label = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
ParentKey = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||||
|
Order = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Icon = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MenuItems", x => x.Key);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_MenuItems_MenuItems_ParentKey",
|
||||||
|
column: x => x.ParentKey,
|
||||||
|
principalTable: "MenuItems",
|
||||||
|
principalColumn: "Key",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Permissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
MenuKey = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||||
|
CanRead = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
CanCreate = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
CanUpdate = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
CanDelete = table.Column<bool>(type: "bit", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Permissions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Permissions_MenuItems_MenuKey",
|
||||||
|
column: x => x.MenuKey,
|
||||||
|
principalTable: "MenuItems",
|
||||||
|
principalColumn: "Key",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Permissions_Roles_RoleId",
|
||||||
|
column: x => x.RoleId,
|
||||||
|
principalTable: "Roles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MenuItems_ParentKey",
|
||||||
|
table: "MenuItems",
|
||||||
|
column: "ParentKey");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Permissions_MenuKey",
|
||||||
|
table: "Permissions",
|
||||||
|
column: "MenuKey");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Permissions_RoleId_MenuKey",
|
||||||
|
table: "Permissions",
|
||||||
|
columns: new[] { "RoleId", "MenuKey" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Permissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MenuItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -125,6 +125,71 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("UserTokens", (string)null);
|
b.ToTable("UserTokens", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ParentKey")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.HasIndex("ParentKey");
|
||||||
|
|
||||||
|
b.ToTable("MenuItems", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("CanCreate")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanDelete")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanRead")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanUpdate")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("MenuKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MenuKey");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId", "MenuKey")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Permissions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -247,6 +312,194 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Users", (string)null);
|
b.ToTable("Users", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Department", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManagerUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Departments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<decimal?>("BudgetTotal")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManagerUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Projects", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPerson")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("nvarchar(30)");
|
||||||
|
|
||||||
|
b.Property<string>("TaxCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Type");
|
||||||
|
|
||||||
|
b.ToTable("Suppliers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
@ -297,6 +550,42 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentKey")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu")
|
||||||
|
.WithMany("Permissions")
|
||||||
|
.HasForeignKey("MenuKey")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Identity.Role", "Role")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Menu");
|
||||||
|
|
||||||
|
b.Navigation("Role");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
|
b.Navigation("Permissions");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user