Ui fixings

This commit is contained in:
2026-09-24 12:30:21 +05:30
parent f5e8e0e5b9
commit 049c98298a
34 changed files with 1021 additions and 1247 deletions
@@ -22,7 +22,11 @@ import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
@@ -86,6 +90,14 @@ const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock/wastage/new": "Record Wastage",
}
const PRODUCTION_TITLES: Record<string, string> = {
"/dashboard/production": "Production",
"/dashboard/production/templates": "Production Templates",
"/dashboard/production/templates/new": "New Template",
"/dashboard/production/runs": "Production Runs",
"/dashboard/production/produced": "Produced Items",
}
function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
@@ -103,6 +115,10 @@ function titleFromPath(pathname: string) {
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
if (PRODUCTION_TITLES[pathname]) return PRODUCTION_TITLES[pathname]
if (/^\/dashboard\/production\/templates\/[^/]+$/.test(pathname)) return "Template Builder"
if (/^\/dashboard\/production\/runs\/[^/]+$/.test(pathname)) return "Production Run"
if (PROCUREMENT_TITLES[pathname]) return PROCUREMENT_TITLES[pathname]
if (/^\/dashboard\/procurement\/requisitions\/[^/]+$/.test(pathname)) return "Requisition"
if (/^\/dashboard\/procurement\/rfqs\/[^/]+$/.test(pathname)) return "RFQ"
@@ -277,12 +293,16 @@ export function Header() {
type="button"
onClick={() => router.back()}
aria-label="Go back"
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground sm:size-9"
>
<ArrowLeft className="size-5" />
</button>
)}
<h1 className="min-w-0 truncate text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1>
{/* Phones wrap to two lines rather than truncating — at this width a one-line title
is cut to a few letters ("Prod…"). One truncated line from sm, where it fits. */}
<h1 className="line-clamp-2 min-w-0 text-base leading-tight font-bold tracking-tight wrap-break-word text-foreground sm:truncate sm:text-xl sm:leading-normal">
{title}
</h1>
</div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
@@ -377,7 +397,8 @@ export function Header() {
)}
</div>
<ThemeToggle />
{/* 108px wide — on phones it moves into the account menu so the title has room. */}
<ThemeToggle className="hidden sm:flex" />
<Popover onOpenChange={(open) => open && loadNotifications()}>
<PopoverTrigger
@@ -443,7 +464,7 @@ export function Header() {
</Popover>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full p-1 outline-none hover:bg-muted sm:pr-2">
<Avatar>
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
{user ? initials(displayName(user)) : "?"}
@@ -466,6 +487,39 @@ export function Header() {
</p>
)}
</div>
<div className="flex items-center justify-between gap-3 px-2 pb-2.5 sm:hidden">
<span className="text-sm text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
{/* The header's branch picker is hidden on phones, so admins switch here. Radio
items rather than a nested Select: a Select's portaled popup would read as an
outside press and close this menu. */}
{branchingEnabled && isAdmin && (
<div className="sm:hidden">
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 text-sm">
<Building2 className="size-3.5" />
Branch
</DropdownMenuLabel>
<div className="max-h-48 overflow-y-auto">
<DropdownMenuRadioGroup
value={activeBranchId ? String(activeBranchId) : ALL_BRANCHES}
onValueChange={(value: string) => handleBranchChange(value)}
>
<DropdownMenuRadioItem value={ALL_BRANCHES} closeOnClick className="py-2 text-base">
All Branches
</DropdownMenuRadioItem>
{branches.map((b) => (
<DropdownMenuRadioItem key={b.branchId} value={String(b.branchId)} closeOnClick className="py-2 text-base">
{b.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuGroup>
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link href="/dashboard/profile" />}
@@ -0,0 +1,40 @@
import Image from "next/image"
import Link from "next/link"
/** Sign-in page chrome shared by the auth screens: illustration on the right, the form panel
* (logo + content) overlaying the left 45%. */
export function AuthShell({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen overflow-hidden">
{/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free
cutout (whithoutbackground.png) so there's no white scene box floating on the
dark panel. */}
<div className="absolute inset-y-0 left-[45%] right-0 bg-white dark:bg-background">
<Image
src="/images/loginerps.png"
alt="ERP Illustration"
fill
className="object-contain object-center dark:hidden"
priority
/>
<Image
src="/images/whithoutbackground.png"
alt="ERP Illustration"
fill
className="hidden object-contain object-center dark:block"
priority
/>
</div>
{/* Form panel — overlays left side, no hard border with the photo */}
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
<div className="mb-8 flex justify-center">
<Link href="/" className="inline-flex items-center">
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
</Link>
</div>
{children}
</main>
</div>
)
}
@@ -4,7 +4,7 @@
// (Branch, Designation, EmploymentType) — identical shape to each other, so one
// component parameterized by the resource's api/labels replaces 3 near-duplicate pages.
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { CheckCircle2, Pencil, Plus, Search, Trash2, XCircle, X } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -507,54 +508,13 @@ export function CodeNameMasterPage<T extends CodeNamed>({
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> {title.toLowerCase()}
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -52,25 +52,16 @@ function fmt(n: number): string {
}
/**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
* from the template at creation) and colored by its live status. Clicking it opens the stage
* drawer — handled by the page via `onNodeClick`, not here.
* The body of a stage card — everything but the frame. Shared by the canvas node below and the
* run page's phone list, so the two views of a stage can't drift apart.
*/
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
export function RunStageCardBody({ data }: { data: RunStageData }) {
const color = STAGE_STATUS_COLOR[data.state]
const running = data.actualStartAt !== null && data.actualMinutes === null
const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned
return (
<div
className={cn(
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${color}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<>
<div className="flex items-start gap-1.5">
<span className="mt-1 size-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<p className="min-w-0 flex-1 truncate text-sm font-bold text-foreground">{data.name}</p>
@@ -116,7 +107,26 @@ function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
{fmt(data.availableToTransfer)} to transfer
</div>
)}
</>
)
}
/**
* One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied
* from the template at creation) and colored by its live status. Clicking it opens the stage
* drawer — handled by the page via `onNodeClick`, not here.
*/
function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) {
return (
<div
className={cn(
"w-52 cursor-pointer rounded-2xl bg-card p-3 shadow-sm ring-2 transition-all hover:ring-primary/40",
selected ? "ring-primary" : "ring-foreground/10"
)}
style={data.isActive && !selected ? { boxShadow: `0 0 0 3px ${STAGE_STATUS_COLOR[data.state]}` } : undefined}
>
<Handle type="target" position={Position.Left} className="!bg-primary !size-2.5" />
<RunStageCardBody data={data} />
<Handle type="source" position={Position.Right} className="!bg-primary !size-2.5" />
</div>
)
@@ -8,7 +8,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Building2, ChevronLeft, ChevronRight, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
import { Building2, CheckCircle2, Eye, Pencil, Plus, Search, Trash2, X, XCircle } from "lucide-react"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
@@ -22,6 +22,7 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -477,54 +478,13 @@ export function BranchMasterPage() {
)}
{pagination && pagination.totalItems > 0 && (
<div className="hidden items-center gap-3 md:flex md:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> branches
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="hidden md:block">
<PaginationFooter
pagination={pagination}
onPageChange={setPage}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
/>
</div>
)}
</div>
@@ -38,7 +38,7 @@ export default function OtpInput({ length = 6, value = "", onChange }: OtpInputP
value={char}
onChange={(e) => handleChange(e.target.value, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
className="w-14 h-14 mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30"
className="size-11 sm:size-14 mx-0.5 sm:mx-1 text-center text-lg font-semibold border-2 border-input rounded-lg bg-background text-foreground transition-all outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:focus:ring-ring/30"
/>
)
}