feat(dashboard): Jalali date pickers + mobile/tablet responsive shell
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m41s

Full Persian calendar:
- New JalaliDateField — Shamsi popover picker (Saturday-first weeks,
  Persian digits, امروز shortcut); wire format stays ISO Gregorian
  YYYY-MM-DD. Falls back to the native input for the en locale.
- Replaces all 5 native type="date" inputs (Gregorian-only pickers):
  reservations, expenses from/to, reports from/to.
- Reservations list date now renders Jalali instead of the raw ISO
  string; branches purge timestamp now formats with fa-IR.

Responsive shell (mobile + tablet):
- New MobileNav: hamburger in the topbar (< md) opening an RTL-aware
  slide-over drawer with all nav destinations, permission-filtered,
  Escape/backdrop close and body scroll lock.
- Desktop sidebar hidden below md; header center cluster (clock/plan)
  hidden below md; language switcher hidden below sm.
- Main content padding scales p-3 → p-4 → p-6.
- Verified at 375px and 768px: no horizontal overflow, drawer and
  Jalali picker fully functional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 23:10:38 +03:30
parent d811b7d6d5
commit 2a4cf1d20b
10 changed files with 439 additions and 53 deletions
@@ -50,7 +50,7 @@ export function HeaderCenterCluster() {
return (
<div
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center"
className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 items-center md:flex"
aria-live="polite"
>
<div
@@ -0,0 +1,187 @@
"use client";
// Mobile/tablet navigation: hamburger button + slide-over drawer (< md screens).
// Shows the same destinations as the desktop sidebar — flat main items, then the
// collapsible groups rendered as plain titled sections (everything visible, the
// drawer itself scrolls), then the footer utility links.
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Menu, X } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing";
import { canSeeNavItem } from "@/lib/auth-permissions";
import { permissionsOf } from "@/lib/permissions";
import { FOOTER_NAV, NAV_GROUPS, type NavItemDef } from "@/lib/sidebar-nav";
import { useAuthStore } from "@/lib/stores/auth.store";
import { cn } from "@/lib/utils";
function DrawerLink({
item,
label,
active,
onNavigate,
}: {
item: NavItemDef;
label: string;
active: boolean;
onNavigate: () => void;
}) {
const Icon = item.icon;
return (
<Link
href={item.href}
onClick={onNavigate}
className={cn(
"flex min-h-[44px] items-center gap-3 rounded-xl px-3 text-sm transition-colors cursor-pointer",
active
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<Icon className={cn("h-5 w-5 shrink-0", active && "text-primary")} aria-hidden />
<span className="min-w-0 truncate">{label}</span>
</Link>
);
}
export function MobileNav() {
const t = useTranslations("nav");
const tGroups = useTranslations("nav.groups");
const tBrand = useTranslations("brand");
const locale = useLocale();
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const role = user?.role;
const branchId = user?.branchId ?? null;
const permissions = useMemo(() => permissionsOf(user), [user]);
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Close on Escape; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open]);
const close = () => setOpen(false);
const isRtl = locale !== "en";
const visible = (items: NavItemDef[]) =>
items.filter((item) => canSeeNavItem(item.key, role, branchId, permissions));
const isActive = (item: NavItemDef) =>
item.href === "/"
? pathname === "/"
: pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={t("aria")}
className="flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:hidden"
>
<Menu className="h-5 w-5" aria-hidden />
</button>
{mounted &&
open &&
createPortal(
<div className="fixed inset-0 z-50 md:hidden" dir={isRtl ? "rtl" : "ltr"}>
{/* Backdrop */}
<button
type="button"
aria-label={t("aria")}
onClick={close}
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
/>
{/* Panel */}
<div className="absolute inset-y-0 start-0 flex w-[280px] max-w-[85vw] flex-col bg-background shadow-2xl">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
<span className="text-sm font-bold tracking-tight">{tBrand("name")}</span>
<button
type="button"
onClick={close}
aria-label={t("collapseSidebar")}
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-5 w-5" aria-hidden />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3" aria-label={t("aria")}>
{NAV_GROUPS.map((group) => {
const items = visible(group.items);
if (items.length === 0) return null;
return (
<div key={group.id} className="mb-3">
{!group.flat && (
<p className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
{tGroups(group.id)}
</p>
)}
<div className="space-y-0.5">
{items.map((item) => (
<DrawerLink
key={item.key}
item={item}
label={t(item.key)}
active={isActive(item)}
onNavigate={close}
/>
))}
</div>
</div>
);
})}
{visible(FOOTER_NAV).length > 0 && (
<div className="border-t border-border/60 pt-2">
<div className="space-y-0.5">
{visible(FOOTER_NAV).map((item) => (
<DrawerLink
key={item.key}
item={item}
label={t(item.key)}
active={isActive(item)}
onNavigate={close}
/>
))}
</div>
</div>
)}
</nav>
{user && (
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-semibold text-primary">
{(user.actor ?? user.role).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{user.actor ?? user.userId}</p>
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
</div>
</div>
)}
</div>
</div>,
document.body
)}
</>
);
}
@@ -283,7 +283,8 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
return (
<aside
className={cn(
"flex shrink-0 flex-col bg-background overflow-hidden",
// Hidden below md — mobile/tablet use the MobileNav drawer instead.
"hidden shrink-0 flex-col bg-background overflow-hidden md:flex",
"transition-[width] duration-200 ease-in-out",
collapsed ? "w-14" : "w-56",
"border-border",
+11 -3
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
import { BranchSwitcher } from "@/components/layout/branch-switcher";
import { MobileNav } from "@/components/layout/mobile-nav";
import { NotificationCenter } from "@/components/notifications/notification-center";
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
@@ -36,7 +37,10 @@ export function Topbar() {
};
return (
<header className="relative flex h-14 items-center gap-3 border-b border-border bg-background px-4 sm:px-6">
<header className="relative flex h-14 items-center gap-2 border-b border-border bg-background px-2 sm:gap-3 sm:px-4 lg:px-6">
{/* Hamburger — mobile/tablet only */}
<MobileNav />
{/* Cafe name */}
<div className="flex min-w-0 flex-1 items-center gap-2">
{showNameSkeleton ? (
@@ -67,10 +71,14 @@ export function Topbar() {
<SyncStatusIndicator />
<NotificationCenter />
{/* Language switcher */}
{/* Language switcher — hidden on phones to save header space */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2.5 text-xs cursor-pointer">
<Button
variant="ghost"
size="sm"
className="hidden h-8 gap-1 px-2.5 text-xs cursor-pointer sm:inline-flex"
>
{t(`languages.${locale}`)}
</Button>
</DropdownMenuTrigger>