[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:
pqhuy1987
2026-04-21 11:30:14 +07:00
parent 49a5f57a50
commit 54d6c9ba52
63 changed files with 4422 additions and 93 deletions

View File

@ -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`.
**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
_(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)
| 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 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 | **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 | **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` |
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-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
- [ ] `Domain/Entities/Supplier`, `Project`, `Department` (+ EF configurations)
- [ ] `Application/Suppliers/{Commands,Queries}/*` (Create, Update, Delete, GetById, List)
- [ ] Tương tự cho Project + Department
- [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
- [ ] Pagination, search, sort server-side (`GetListQuery` với `PagedResult<T>`)
- [ ] Migration 2: `AddMasterData`
- [ ] Convert 3 file `.doc` (FO-002.02/03/06) → `.docx` qua PowerShell COM hoặc LibreOffice headless
- [ ] Parse chi tiết field specs cho 5 template HĐ → JSON spec
- [ ] Add NuGet: DocumentFormat.OpenXml, ClosedXML
- [ ] `Domain/Entities/ContractTemplate`, `ContractClause` + EF config
- [ ] `Application/Forms/Services/IFormRenderer` + `DocxRenderer` + `XlsxRenderer`
- [ ] `Api/Controllers/FormsController` (list template, get spec, render preview, render final)
- [ ] 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
- [ ] `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)
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.
### Contract draft (chưa workflow)
- [ ] `Domain/Entities/Contract` skeleton (không state machine)
- [ ] Basic CRUD controller + FE list page
## 🔄 Còn có thể làm parallel (optional, không block Phase 2)
### FE
- [ ] `<PermissionGuard menuKey="Contracts">` + `usePermission()` hook
- [ ] 3 trang CRUD admin (Suppliers / Projects / Departments) với table + modal
- [ ] Route guard theo role (admin-only routes)
- [ ] FE Users management (tạo user + gán role) — cần để test permission với role khác Admin
- [ ] FE Roles CRUD (tạo custom role mới)
- [ ] Contract entity skeleton (không state machine, chỉ CRUD draft)
- [ ] 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)
- **Frontend LOC:** ~450 mỗi app (shared 90%)
- **Build time:** .NET ~4s, FE TS check ~3s mỗi app, Vite dev ~3s ready
- **E2E verified:** Login (fe-admin proxy + fe-user proxy) → API → JWT + user info + /me
- **Backend LOC:** ~1500 (Domain 150 + Application 800 + Infrastructure 350 + Api 200)
- **Migrations:** Init + AddMasterData + AddPermissions
- **DB tables:** 7 Identity + 3 Master (Suppliers/Projects/Departments) + 2 Permission (MenuItems/Permissions)
- **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
-**Gitea remote** chưa có URL — push sau
- ⚠️ **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.
- ⚠️ **MediatR 14.x** breaking changes — đã downgrade về 12.4.1. Ok cho Phase 1-5.
- ⚠️ **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.
- ⚠️ **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
-**Gitea remote** — URL chờ user cấp
- ⚠️ **fe-user** chưa được update với menu động — Phase 2 sẽ sync
- ⚠️ **Users CRUD** chưa có UI → khó test permission với non-admin role thật
- ⚠️ **3 file `.doc`** Phase 2 cần convert COM
## Credentials mặc định
@ -72,6 +69,6 @@ Password: Admin@123456
```
URLs dev:
- API: http://localhost:5443 — Swagger `/swagger`
- API: http://localhost:5443 — Swagger `/swagger`
- Admin FE: http://localhost:8082
- User FE: http://localhost:8080

View File

@ -60,27 +60,28 @@
### 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)
- [ ] `Domain/Entities/Project` (Code, Name, StartDate, EndDate, ManagerUserId)
- [ ] `Domain/Entities/Department` (Code, Name, ManagerUserId)
- [ ] EF `IEntityTypeConfiguration<T>` cho mỗi entity
- [ ] CQRS CRUD: Create/Update/Delete/GetById/List (với paging) cho 3 entity
- [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
- [ ] Migration 2: `AddMasterData`
- [ ] `Domain/Entities/MenuItem` (Key PascalCase, Label, ParentKey, Order, Icon)
- [ ] `Domain/Entities/Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
- [ ] Seed default menu tree + permission admin có full access
- [ ] `Application/Permissions/Queries/GetMyMenuTreeQuery` — resolve per-user, cache
- [ ] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
- [ ] Migration 3: `AddPermissions`
- [ ] `Domain/Entities/Contract` skeleton (Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData JSON)
- [ ] Contract CRUD draft only (không workflow Phase 3)
- [ ] FE: `<PermissionGuard menuKey="Suppliers" action="Update">` + `usePermission()` hook
- [ ] FE Admin: 3 trang CRUD Supplier/Project/Department với table + modal + search/sort
- [ ] FE Admin: Permission Matrix grid page (role × menu × CRUD checkbox)
- [ ] FE User: trang "HĐ của tôi" list + filter
- [ ] Route guard theo role admin-only
- [ ] Update `SolutionErp.slnx` nếu thêm project mới
- [x] `Domain/Master/Supplier` (+ SupplierType enum 5 loại) / `Project` / `Department` (AuditableEntity)
- [x] EF `IEntityTypeConfiguration<T>` cho mỗi entity (unique Code + query filter IsDeleted)
- [x] CQRS CRUD: Create/Update/Delete/GetById/List (PagedResult) cho 3 entity
- [x] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
- [x] Migration 2: `AddMasterData`
- [x] `Domain/Identity/MenuItem` (Key PK, Label, ParentKey, Order, Icon) + `MenuKeys` const class
- [x] `Domain/Identity/Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
- [x] Seed default menu tree (12 menu) + admin full access trong DbInitializer
- [x] `Application/Permissions/Queries/GetMyMenuTreeQuery` — resolve per-user, union OR, tree filter
- [x] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
- [x] Migration 3: `AddPermissions`
- [x] Authorization handler `MenuPermissionHandler` + register 48 policy `{menu}.{action}`
- [ ] `Domain/Entities/Contract` skeleton (Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData JSON) — deferred Phase 2/3
- [ ] Contract CRUD draft only (không workflow Phase 3) — deferred
- [x] 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
- [x] 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 — Phase 3
- [ ] FE Admin: Users management page (tạo user + gán role) — sắp tớ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

View File

@ -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

View File

@ -5,6 +5,10 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage'
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() {
return (
@ -20,12 +24,16 @@ function App() {
}
>
<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={
<div className="p-8 text-slate-500">
Trang này chưa đưc build sẽ Phase 1 đt 2 / Phase 2 / 3.
Trang này chưa đưc build sẽ Phase tiếp theo.
</div>
}
/>

View 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>
)
}

View File

@ -1,18 +1,80 @@
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 type { MenuNode } from '@/types/menu'
import { cn } from '@/lib/cn'
const menuItems = [
{ to: '/dashboard', label: 'Tổng quan', icon: LayoutDashboard },
{ to: '/contracts', label: 'Hợp đồng', icon: FileText },
{ to: '/suppliers', label: 'Nhà cung cấp', icon: Building2 },
{ to: '/users', label: 'Người dùng', icon: Users },
{ to: '/settings', label: 'Cài đặt', icon: Settings },
]
// Map icon name → component (fallback Circle)
function getIcon(name: string | null): LucideIcon {
if (!name) return Circle
const candidate = (Icons as unknown as Record<string, LucideIcon>)[name]
return candidate ?? Circle
}
// 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() {
const { user, logout } = useAuth()
const { user, menu, logout } = useAuth()
return (
<div className="flex h-screen">
@ -22,28 +84,12 @@ export function Layout() {
SOLUTION ERP · Admin
</Link>
</div>
<nav className="flex-1 space-y-1 p-3">
{menuItems.map(item => (
<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 className="flex-1 space-y-1 overflow-y-auto p-3">
{menu.map(n => (n.children.length > 0 ? <MenuGroup key={n.key} node={n} /> : <MenuLeaf key={n.key} node={n} />))}
</nav>
<div className="border-t border-slate-200 p-3">
<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>
<button

View 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>
)
}

View 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}</>
}

View 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>
)
}

View 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'

View 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'

View File

@ -1,29 +1,48 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, TOKEN_KEY, USER_KEY } from '@/lib/api'
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
import type { MenuNode } from '@/types/menu'
type AuthContextValue = {
user: UserInfo | null
menu: MenuNode[]
isAuthenticated: boolean
isBootstrapping: boolean
login: (payload: LoginPayload) => Promise<void>
logout: () => void
refreshMenu: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
const MENU_KEY = 'solution-erp-admin-menu'
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null)
const [menu, setMenu] = useState<MenuNode[]>([])
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(() => {
const token = localStorage.getItem(TOKEN_KEY)
const raw = localStorage.getItem(USER_KEY)
if (token && raw) {
const userRaw = localStorage.getItem(USER_KEY)
const menuRaw = localStorage.getItem(MENU_KEY)
if (token && userRaw) {
try {
setUser(JSON.parse(raw))
setUser(JSON.parse(userRaw))
if (menuRaw) setMenu(JSON.parse(menuRaw))
loadMenu()
} catch {
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENU_KEY)
}
}
setIsBootstrapping(false)
@ -34,16 +53,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
setUser(res.data.user)
await loadMenu()
}
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENU_KEY)
setUser(null)
setMenu([])
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isBootstrapping, login, logout }}>
<AuthContext.Provider value={{ user, menu, isAuthenticated: !!user, isBootstrapping, login, logout, refreshMenu: loadMenu }}>
{children}
</AuthContext.Provider>
)

View 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
},
}
}

View 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
}

View 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'

View 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> 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>
)
}

View 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> 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>
)
}

View 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> 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>
)
}

View 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>
)
}

View 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'>

View 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
}

View File

@ -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);
}
}

View File

@ -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"
}

View File

@ -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();
}
}

View 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));
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View 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));
}

View File

@ -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();
}
}

View File

@ -1,12 +1,15 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Serilog;
using SolutionErp.Api.Authorization;
using SolutionErp.Api.Middleware;
using SolutionErp.Api.Services;
using SolutionErp.Application;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure;
using SolutionErp.Infrastructure.Identity;
using SolutionErp.Infrastructure.Persistence;
@ -48,7 +51,19 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
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) ----------
builder.Services.AddCors(opts =>

View File

@ -1,6 +1,16 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Common.Interfaces;
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);
}

View File

@ -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; }
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -8,6 +8,7 @@
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.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" />
</ItemGroup>

View 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();
}

View 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"];
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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)
}

View File

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
namespace SolutionErp.Infrastructure.Persistence;
@ -10,6 +11,12 @@ public class ApplicationDbContext
{
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)
{
base.OnModelCreating(builder);

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -23,6 +23,14 @@ public static class DbInitializer
logger.LogInformation("Applying migrations...");
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)
{
if (!await roleManager.RoleExistsAsync(roleName))
@ -31,7 +39,10 @@ public static class DbInitializer
logger.LogInformation("Created role {Role}", roleName);
}
}
}
private static async Task SeedAdminAsync(UserManager<User> userManager, ILogger logger)
{
var admin = await userManager.FindByEmailAsync(AdminEmail);
if (admin is null)
{
@ -54,4 +65,67 @@ public static class DbInitializer
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);
}
}
}

View 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
}
}
}

View File

@ -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");
}
}
}

View 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
}
}
}

View File

@ -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");
}
}
}

View File

@ -125,6 +125,71 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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")
@ -247,6 +312,194 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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)
@ -297,6 +550,42 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.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
}
}