[CLAUDE] Phase1: foundation - BE Clean Arch + Identity + JWT + 2 FE React + login E2E
Backend (.NET 10): - Domain: BaseEntity/AuditableEntity, ContractType/Phase/ApprovalDecision enums, User/Role (Identity<Guid>), AppRoles (12 const) - Application: IApplicationDbContext/ICurrentUser/IDateTime/IJwtTokenService, custom exceptions, ValidationBehavior (MediatR pipeline), Auth CQRS (Login/Refresh/Me), DependencyInjection - Infrastructure: ApplicationDbContext (IdentityDbContext), AuditingInterceptor (auto audit + soft delete), DbInitializer (seed 12 role + admin), DesignTimeDbContextFactory, JwtTokenService, DateTimeService, DI - Api: CurrentUserService, GlobalExceptionMiddleware (ProblemDetails), AuthController, Program.cs rewrite (Serilog + JWT + CORS + Swagger), appsettings + launchSettings (port 5443) - Migration Init applied to SolutionErp_Dev LocalDB Frontend (React 19 + Vite 8 + Tailwind 4): - fe-admin (:8082 blue) + fe-user (:8080 emerald) - shared structure, khac menu + brand color - Tailwind 4 via @tailwindcss/vite plugin, theme brand colors - AuthContext (localStorage token), ProtectedRoute, Layout (sidebar + header) - UI kit: Button/Input/Label (CVA + Tailwind) - LoginPage voi toast error, DashboardPage/InboxPage placeholder - Axios interceptor: auto Bearer + 401 redirect - TanStack Query client, React Router 7, Sonner toast Package downgrades (do .NET 10 / TS 6 compat): - MediatR 14 -> 12.4.1 (v14 breaking changes) - Swashbuckle 10 -> 6.9.0 (v10 khong tuong thich OpenApi 2) - Removed Microsoft.AspNetCore.OpenApi (conflict voi Swashbuckle) E2E verified: POST /api/auth/login qua Vite proxy ca 2 FE -> JWT + user info Credentials seed: admin@solutionerp.local / Admin@123456 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
63
fe-admin/src/components/Layout.tsx
Normal file
63
fe-admin/src/components/Layout.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom'
|
||||
import { LogOut, LayoutDashboard, FileText, Users, Building2, Settings } from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
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 },
|
||||
]
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
|
||||
<div className="flex h-14 items-center border-b border-slate-200 px-6">
|
||||
<Link to="/dashboard" className="text-base font-bold text-brand-700">
|
||||
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>
|
||||
<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">{user?.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-slate-600 transition hover:bg-slate-100"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
fe-admin/src/components/ProtectedRoute.tsx
Normal file
16
fe-admin/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isBootstrapping } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (isBootstrapping) {
|
||||
return <div className="flex h-screen items-center justify-center text-slate-500">Đang tải…</div>
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
33
fe-admin/src/components/ui/Button.tsx
Normal file
33
fe-admin/src/components/ui/Button.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-brand-500',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand-600 text-white hover:bg-brand-700',
|
||||
secondary: 'bg-slate-200 text-slate-900 hover:bg-slate-300',
|
||||
outline: 'border border-slate-300 bg-white hover:bg-slate-50',
|
||||
ghost: 'hover:bg-slate-100',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3',
|
||||
md: 'h-10 px-4',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'primary', size: 'md' },
|
||||
},
|
||||
)
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||
),
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
16
fe-admin/src/components/ui/Input.tsx
Normal file
16
fe-admin/src/components/ui/Input.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, Props>(({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Input.displayName = 'Input'
|
||||
11
fe-admin/src/components/ui/Label.tsx
Normal file
11
fe-admin/src/components/ui/Label.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { LabelHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
<label
|
||||
className={cn('text-sm font-medium text-slate-700', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user