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
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user