Dev #65
@@ -26,8 +26,8 @@ public class StoreController(
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
||||
return StatusCode(403);
|
||||
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
||||
// return StatusCode(403);
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
@@ -31,13 +31,6 @@ public class UserController(UserService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/role")]
|
||||
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignRoleAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/scope")]
|
||||
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,12 @@ public record CreateUserRequest(
|
||||
Guid RoleId,
|
||||
Guid? ReportsToUserId);
|
||||
|
||||
public record UpdateUserRequest(string FullName);
|
||||
public record UpdateUserRequest(
|
||||
string FullName,
|
||||
string? Username = null,
|
||||
Guid? RoleId = null,
|
||||
string? CurrentPassword = null,
|
||||
string? NewPassword = null);
|
||||
|
||||
public record AssignUserRoleRequest(Guid RoleId);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
|
||||
email = request.Username,
|
||||
fullname = request.FullName,
|
||||
password = request.Password,
|
||||
roleId = request.RoleId,
|
||||
roleId = request.RoleId.ToString(),
|
||||
userTypeId = "00000001-0000-0000-0000-200000000000",
|
||||
sendCredentialsEmail = false
|
||||
}, reference: user.Id.ToString(), ct: ct);
|
||||
@@ -69,12 +69,52 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
|
||||
{
|
||||
var user = await repo.GetByIdAsync(id);
|
||||
if (user is null || !user.IsActive) return null;
|
||||
|
||||
user.FullName = request.FullName;
|
||||
if (!string.IsNullOrWhiteSpace(request.Username))
|
||||
user.Username = request.Username;
|
||||
if (request.RoleId.HasValue)
|
||||
user.FK_RoleId = request.RoleId.Value;
|
||||
user.UpdatedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(user);
|
||||
|
||||
var hasUsernameChange = !string.IsNullOrWhiteSpace(request.Username);
|
||||
var hasRoleChange = request.RoleId.HasValue;
|
||||
|
||||
if (hasUsernameChange || hasRoleChange)
|
||||
{
|
||||
var payload = new Dictionary<string, object> { ["userId"] = user.Id.ToString() };
|
||||
if (hasUsernameChange) payload["userName"] = request.Username!;
|
||||
if (hasRoleChange) payload["roleCode"] = request.RoleId!.Value.ToString();
|
||||
|
||||
var authResult = await authHex.CallAsync("UpdateUser", payload, reference: user.Id.ToString(), ct: ct);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw new InvalidOperationException($"AuthHex update failed: {authResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CurrentPassword) && !string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
{
|
||||
var authResult = await authHex.CallAsync("ChangeUserPassword", new
|
||||
{
|
||||
userId = user.Id.ToString(),
|
||||
currentPassword = request.CurrentPassword,
|
||||
newPassword = request.NewPassword
|
||||
}, reference: user.Id.ToString(), ct: ct);
|
||||
|
||||
if (!authResult.Success)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw new InvalidOperationException($"AuthHex password change failed: {authResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
@@ -87,22 +127,9 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
|
||||
|
||||
public async Task<UserResponse?> AssignRoleAsync(Guid id, AssignUserRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await repo.GetByIdAsync(id);
|
||||
if (user is null || !user.IsActive) return null;
|
||||
user.FK_RoleId = request.RoleId;
|
||||
user.UpdatedAt = DateTime.Now;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(user);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(user);
|
||||
return await UpdateAsync(id, new UpdateUserRequest(
|
||||
FullName: (await repo.GetByIdAsync(id))?.FullName ?? string.Empty,
|
||||
RoleId: request.RoleId), ct);
|
||||
}
|
||||
|
||||
public async Task<bool> AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default)
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:6703"
|
||||
"BaseUrl": "http://localhost:6705"
|
||||
},
|
||||
"StoreManagement": {
|
||||
"UnverifiedKycOrderThreshold": 0
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, type FormEvent } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { Space_Grotesk } from 'next/font/google'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { getApiError } from '@/lib/api'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const sg = Space_Grotesk({ subsets: ['latin'], weight: ['400', '500', '600', '700'] })
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
router.replace('/')
|
||||
}
|
||||
if (!loading && user) router.replace('/')
|
||||
}, [user, loading, router])
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
@@ -36,47 +40,158 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background">
|
||||
<form onSubmit={handleSubmit} className="flex w-full max-w-sm flex-col gap-6 p-6">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">Distribution ERP</p>
|
||||
<div className={`${sg.className} min-h-svh flex flex-col md:flex-row`}>
|
||||
|
||||
{/* ── Left panel: image (hidden on mobile, 60% on desktop) ── */}
|
||||
<div className="relative hidden md:flex md:w-[60%] shrink-0 flex-col">
|
||||
<Image
|
||||
src="/HexaDistribution.png"
|
||||
alt="Distribution warehouse"
|
||||
fill
|
||||
sizes="60vw"
|
||||
className="object-cover object-center"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
|
||||
{/* Logo overlay top-left */}
|
||||
<div className="relative z-10 px-10 pt-9">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||
alt="HexDive"
|
||||
className="h-7 w-auto object-contain"
|
||||
/>
|
||||
<span className="font-bold text-[15px] tracking-wide text-black">
|
||||
HexaDistro
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="identifier">Email</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient at bottom */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.2) 40%, transparent 70%)' }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
{/* Text content pinned to bottom-left */}
|
||||
<div className="relative z-10 mt-auto px-12 pb-12">
|
||||
<h1
|
||||
className="font-bold leading-tight mb-3"
|
||||
style={{ fontSize: 'clamp(28px, 3vw, 46px)', color: '#ffffff' }}
|
||||
>
|
||||
Your operations.<br />
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 mt-1 rounded-sm"
|
||||
style={{ backgroundColor: '#1A1033', color: '#ffffff' }}
|
||||
>
|
||||
Fully in control.
|
||||
</span>
|
||||
</h1>
|
||||
<p style={{ color: 'rgba(255,255,255,0.72)', fontSize: 15, lineHeight: 1.65, maxWidth: 380 }}>
|
||||
Distribution ERP built for teams that deliver right, every time.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Right panel: login card ── */}
|
||||
<div className="flex-1 bg-white flex flex-col items-center justify-center px-6 py-10 sm:px-10">
|
||||
|
||||
{/* Mobile-only logo */}
|
||||
<div className="flex items-center gap-2.5 mb-8 md:hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||
alt="HexDive"
|
||||
className="h-7 w-auto object-contain"
|
||||
/>
|
||||
<span className="font-bold text-[14px] tracking-widest uppercase text-gray-900">
|
||||
HexaDistro
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-white px-6 py-8 sm:px-10 sm:py-11"
|
||||
style={{
|
||||
maxWidth: 440,
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
{/* Logo — desktop only inside card */}
|
||||
<div className="hidden md:flex items-center gap-2.5 mb-8">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||
alt="HexDive"
|
||||
className="h-7 w-auto object-contain"
|
||||
/>
|
||||
<span className="font-bold text-[14px] tracking-widest uppercase text-gray-900">
|
||||
HexaDistro
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-3xl font-semibold text-gray-900 mb-1">Sign in</h1>
|
||||
<p className="text-sm text-gray-500 mb-8">
|
||||
Enter your credentials to access your account.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="identifier" className="text-gray-700">
|
||||
Username or Email
|
||||
</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={identifier}
|
||||
onChange={e => setIdentifier(e.target.value)}
|
||||
className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-gray-700">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
className="absolute inset-y-0 right-2.5 flex items-center text-gray-400 hover:text-gray-600"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full mt-2">
|
||||
{submitting ? 'Signing in…' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ function emptyDashboardData(): DashboardData {
|
||||
lowStock: [], movements: [], warehouseBalances: [],
|
||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function DriverSheet({ open, onClose, driver, onSuccess }: DriverSheetPro
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
usersApi.list({ pageSize: 100 }).then((res) => setUsersList(getPaginatedItems(res))).catch((err) =>
|
||||
usersApi.list().then((res) => setUsersList(res)).catch((err) =>
|
||||
toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -13,6 +14,9 @@ export default function DashboardLayout({
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||
{children}
|
||||
|
||||
+2
-2
@@ -89,11 +89,11 @@ export function RegionManageEntrySheet({ open, onClose, onSuccess, initial }: Re
|
||||
|
||||
Promise.all([
|
||||
regionsApi.list({ page: 1, pageSize: 200 }),
|
||||
usersApi.list({ page: 1, pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([r, u]) => {
|
||||
setRegionsList(getPaginatedItems(r).filter((x) => x.isActive))
|
||||
setAgencyUsers(getPaginatedItems(u).filter((x) => x.roleName === 'RegionalAgency'))
|
||||
setAgencyUsers(u.filter((x) => x.roleName === 'RegionalAgency'))
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||
}, [open, initial])
|
||||
|
||||
+2
-2
@@ -81,8 +81,8 @@ export function RegionManageListClient() {
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => setAgencyUsers(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setAgencyUsers(res))
|
||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
|
||||
+2
-2
@@ -45,13 +45,13 @@ export function RegionManagerDialog({ open, onClose, region }: RegionManagerDial
|
||||
setCurrent(null)
|
||||
Promise.all([
|
||||
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
|
||||
usersApi.list({ pageSize: 500 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([manager, userRes]) => {
|
||||
if (cancelled) return
|
||||
setCurrent(manager)
|
||||
setSelected(manager?.userId ?? '')
|
||||
setCandidates(getPaginatedItems(userRes).filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
||||
setCandidates(userRes.filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
|
||||
@@ -46,8 +46,8 @@ export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManag
|
||||
setOwnerUserId(route.ownerUserId ?? '')
|
||||
setStoreFilter('')
|
||||
|
||||
usersApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep')))
|
||||
usersApi.list()
|
||||
.then((res) => setSalesReps(res.filter((u) => u.roleName === 'SalesRep')))
|
||||
.catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' }))
|
||||
|
||||
storesApi.list({ page: 1, pageSize: 200 })
|
||||
|
||||
@@ -46,10 +46,10 @@ export function RoutesClient() {
|
||||
}, [fetchData, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi.list({ page: 1, pageSize: 200 })
|
||||
usersApi.list()
|
||||
.then((res) => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const u of getPaginatedItems(res)) map[u.id] = u.fullName
|
||||
for (const u of res) map[u.id] = u.fullName
|
||||
setUserNameById(map)
|
||||
})
|
||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ export function ConfirmLoadingSheetDialog({ open, onClose, onSuccess, sheet }: C
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ confirmedByUserId: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ function LoadingSheetForm() {
|
||||
regionsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
ridesApi.list({ status: isEdit ? undefined : 'Open', pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
itemsApi.list({ pageSize: 500 }),
|
||||
])
|
||||
.then(([regionsRes, warehousesRes, ridesRes, usersRes, itemsRes]) => {
|
||||
@@ -176,7 +176,7 @@ function LoadingSheetForm() {
|
||||
setRegionsList(getPaginatedItems(regionsRes))
|
||||
setWarehousesList(getPaginatedItems(warehousesRes))
|
||||
setRidesList(getPaginatedItems(ridesRes))
|
||||
setUsersList(getPaginatedItems(usersRes))
|
||||
setUsersList(usersRes)
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||
}, [isEdit])
|
||||
|
||||
@@ -148,14 +148,14 @@ export default function NewGrnPage() {
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
suppliersApi.list(),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([ws, us, ss, its, pos]) => {
|
||||
setWarehousesList(getPaginatedItems(ws))
|
||||
setUsersList(getPaginatedItems(us))
|
||||
setUsersList(us)
|
||||
setSuppliersList(ss)
|
||||
setItemsList(getPaginatedItems(its))
|
||||
// Only POs that can still receive goods
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ export function PurchaseReturnDialog({ open, onClose, onSuccess, order, existing
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ reason: '', createdByUserId: '', items: [defaultItem()] })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -79,12 +79,12 @@ export default function NewPurchaseOrderPage() {
|
||||
Promise.all([
|
||||
suppliersApi.list(),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([suppliersRes, itemsRes, usersRes]) => {
|
||||
setSuppliersList(suppliersRes)
|
||||
setItemsList(getPaginatedItems(itemsRes))
|
||||
setUsersList(getPaginatedItems(usersRes))
|
||||
setUsersList(usersRes)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ export function ClaimSettleDialog({ open, onClose, onSuccess, claim }: ClaimSett
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ settlementAmount: 0, settledByUserId: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ export function ClaimSheet({ open, onClose, onSuccess, claim }: ClaimSheetProps)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ agencyUserId: claim?.agencyUserId ?? '', details: claim?.details ?? '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open, claim])
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ export function ClaimsClient() {
|
||||
try {
|
||||
const [claimsList, usersRes] = await Promise.all([
|
||||
claimsApi.list(),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
setUserNameById(new Map(getPaginatedItems(usersRes).map((u) => [u.id, u.fullName])))
|
||||
setUserNameById(new Map(usersRes.map((u) => [u.id, u.fullName])))
|
||||
setData(claimsList)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load claims', description: getApiError(err), type: 'error' })
|
||||
|
||||
+2
-2
@@ -88,12 +88,12 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
|
||||
Promise.all([
|
||||
shopPosApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([posRes, itemsRes, usersRes]) => {
|
||||
setShopPOList(getPaginatedItems(posRes))
|
||||
setItemsList(getPaginatedItems(itemsRes))
|
||||
setUsersList(getPaginatedItems(usersRes))
|
||||
setUsersList(usersRes)
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
+2
-2
@@ -84,12 +84,12 @@ export function SupplierClaimDialog({ open, onClose, onSuccess, claim }: Supplie
|
||||
if (!open) return
|
||||
Promise.all([
|
||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([posRes, usersRes]) => {
|
||||
const orderList = getPaginatedItems(posRes)
|
||||
setOrdersList(orderList)
|
||||
setUsersList(getPaginatedItems(usersRes))
|
||||
setUsersList(usersRes)
|
||||
|
||||
if (claim) {
|
||||
form.reset({
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@ export function SupplierClaimResolveDialog({ open, onClose, onSuccess, claim, mo
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ resolutionType: 'Replacement', resolutionAmount: 0, resolutionNotes: '', userId: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open, mode])
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function SalesVisitsClient() {
|
||||
routesApi.list({ page: 1, pageSize: 200 })
|
||||
.then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive)))
|
||||
.catch(() => toast.add({ title: 'Failed to load routes', type: 'error' }))
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -76,11 +76,11 @@ export default function EditShopPOPage() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([shopPosApi.get(id), itemsApi.list({ pageSize: 200 }), usersApi.list({ pageSize: 200 })])
|
||||
Promise.all([shopPosApi.get(id), itemsApi.list({ pageSize: 200 }), usersApi.list()])
|
||||
.then(([poRes, items, users]) => {
|
||||
setPo(poRes)
|
||||
setItemsList(getPaginatedItems(items))
|
||||
setUsersList(getPaginatedItems(users))
|
||||
setUsersList(users)
|
||||
form.reset({
|
||||
items: poRes.items
|
||||
.filter((i) => i.isActive)
|
||||
|
||||
@@ -50,8 +50,8 @@ export function CancelShopPODialog({ open, onClose, onSuccess, po }: CancelShopP
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ cancelledByUserId: '', reason: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ confirmedByUserId: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -67,12 +67,12 @@ export function SalesClient() {
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
routesApi.list({ pageSize: 200 }),
|
||||
storesApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([u, r, s]) => {
|
||||
setAgentsList(getPaginatedItems(u))
|
||||
setAgentsList(u)
|
||||
setRoutesList(getPaginatedItems(r))
|
||||
setStoresList(getPaginatedItems(s))
|
||||
})
|
||||
|
||||
@@ -110,12 +110,12 @@ export default function NewShopPOPage() {
|
||||
Promise.all([
|
||||
storesApi.list({ pageSize: 200 }),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
usersApi.list(),
|
||||
])
|
||||
.then(([stores, items, users]) => {
|
||||
setStoresList(getPaginatedItems(stores))
|
||||
setItemsList(getPaginatedItems(items))
|
||||
setUsersList(getPaginatedItems(users))
|
||||
setUsersList(users)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||
|
||||
@@ -55,8 +55,8 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ userId: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ export function StockAdjustDialog({ open, onClose, onSuccess, balance }: StockAd
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({ quantity: 0, reasonCode: '', adjustedByUserId: '' })
|
||||
usersApi.list({ pageSize: 200 })
|
||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
||||
usersApi.list()
|
||||
.then((res) => setUsersList(res))
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -45,7 +45,15 @@ export function StockBalancesClient() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await stockApi.balances({ page: p, pageSize: ps })
|
||||
setData(getPaginatedItems(res))
|
||||
const items = getPaginatedItems(res)
|
||||
const seen = new Set<string>()
|
||||
const deduped = items.filter(r => {
|
||||
const k = `${r.itemId}-${r.locationId}`
|
||||
if (seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
})
|
||||
setData(deduped)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' })
|
||||
|
||||
@@ -83,10 +83,10 @@ export function UserSheet({ open, onClose, user, roles, users, onSuccess }: User
|
||||
|
||||
async function onEditSubmit(values: EditValues) {
|
||||
try {
|
||||
await usersApi.update(user!.id, { fullName: values.fullName })
|
||||
if (values.roleId !== user!.roleId) {
|
||||
await usersApi.assignRole(user!.id, values.roleId)
|
||||
}
|
||||
await usersApi.update(user!.id, {
|
||||
fullName: values.fullName,
|
||||
roleId: values.roleId !== user!.roleId ? values.roleId : undefined,
|
||||
})
|
||||
toast.add({ title: 'User updated', type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
|
||||
@@ -81,11 +81,14 @@ export function AppSidebar() {
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
||||
D
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||
alt="HexDive"
|
||||
className="h-7 w-auto object-contain shrink-0"
|
||||
/>
|
||||
<span className="truncate font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
Distribution
|
||||
HexaDistro
|
||||
</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
@@ -114,11 +117,15 @@ export function AppSidebar() {
|
||||
const visibleItems = group.items.filter((item) =>
|
||||
canSee(item, permissions),
|
||||
)
|
||||
const groupActive = visibleItems.some(
|
||||
(item) =>
|
||||
const groupActive = visibleItems.some((item) => {
|
||||
const hasSiblingUnder = visibleItems.some(
|
||||
s => s.href !== item.href && s.href.startsWith(item.href + '/')
|
||||
)
|
||||
return (
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href)),
|
||||
)
|
||||
(!hasSiblingUnder && item.href !== '/' && pathname.startsWith(item.href + '/'))
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -150,10 +157,14 @@ export function AppSidebar() {
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{visibleItems.map((item) => {
|
||||
const hasSiblingUnder = visibleItems.some(
|
||||
s => s.href !== item.href && s.href.startsWith(item.href + '/')
|
||||
)
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' &&
|
||||
pathname.startsWith(item.href))
|
||||
(!hasSiblingUnder &&
|
||||
item.href !== '/' &&
|
||||
pathname.startsWith(item.href + '/'))
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<SidebarMenuSubItem>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{mounted && (resolvedTheme === 'dark' ? <Sun size={16} /> : <Moon size={16} />)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ interface AuthContextValue {
|
||||
}
|
||||
|
||||
const PERMISSIONS_KEY = 'app:permissions'
|
||||
const USER_STORAGE_KEYS = [PERMISSIONS_KEY, 'distribution-dashboard-widgets-v3']
|
||||
|
||||
function savePermissions(perms: string[]): void {
|
||||
try {
|
||||
@@ -37,9 +38,9 @@ function readPermissions(): Set<string> {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function clearPermissions(): void {
|
||||
function clearUserStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(PERMISSIONS_KEY)
|
||||
USER_STORAGE_KEYS.forEach(k => localStorage.removeItem(k))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
return registerUnauthorizedHandler(() => {
|
||||
clearPermissions()
|
||||
clearUserStorage()
|
||||
setUser(null)
|
||||
setPermissions(new Set())
|
||||
router.push('/login')
|
||||
@@ -95,7 +96,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} finally {
|
||||
clearPermissions()
|
||||
clearUserStorage()
|
||||
setUser(null)
|
||||
setPermissions(new Set())
|
||||
router.push('/login')
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface CreateUserBody {
|
||||
|
||||
export interface UpdateUserBody {
|
||||
fullName: string
|
||||
username?: string
|
||||
roleId?: string
|
||||
currentPassword?: string
|
||||
newPassword?: string
|
||||
}
|
||||
|
||||
export const users = {
|
||||
@@ -28,9 +32,6 @@ export const users = {
|
||||
update: (id: string, body: UpdateUserBody) =>
|
||||
api.put<UserResponse>(`/users/${id}`, body).then(r => r.data),
|
||||
|
||||
assignRole: (id: string, roleId: string) =>
|
||||
api.put<UserResponse>(`/users/${id}/role`, { roleId }).then(r => r.data),
|
||||
|
||||
deactivate: (id: string) =>
|
||||
api.delete(`/users/${id}`),
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf)$).*)'],
|
||||
}
|
||||
|
||||
Generated
+16
@@ -21,6 +21,7 @@
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.9",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.59.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
@@ -8073,6 +8074,21 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.59.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz",
|
||||
"integrity": "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.9",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.59.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Reference in New Issue
Block a user