Merge pull request 'Dev' (#65) from dev into production

Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
2026-10-19 02:45:35 +05:30
43 changed files with 350 additions and 143 deletions
@@ -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)
{
+6 -1
View File
@@ -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 -17
View File
@@ -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)
+1 -1
View File
@@ -21,7 +21,7 @@
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:6705"
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
+158 -43
View File
@@ -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}
@@ -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])
@@ -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' }))
}, [])
@@ -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' }))
@@ -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
@@ -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' })
@@ -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' })
@@ -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])
@@ -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({
@@ -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>
)
}
+5 -4
View File
@@ -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')
+4 -3
View File
@@ -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}`),
}
+1 -1
View File
@@ -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)$).*)'],
}
+16
View File
@@ -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",
+1
View File
@@ -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