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:
@@ -37,7 +37,7 @@ export default function DashboardLayout({
|
|||||||
const mainColumn = (
|
const mainColumn = (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<Topbar />
|
<Topbar />
|
||||||
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
|
<main className="min-h-0 flex-1 overflow-auto bg-background p-3 sm:p-4 lg:p-6">
|
||||||
<RouteGuard>{children}</RouteGuard>
|
<RouteGuard>{children}</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export function BranchesScreen() {
|
|||||||
</p>
|
</p>
|
||||||
{b.scheduledPermanentDeleteAt ? (
|
{b.scheduledPermanentDeleteAt ? (
|
||||||
<p className="text-[10px] text-muted-foreground" dir="ltr">
|
<p className="text-[10px] text-muted-foreground" dir="ltr">
|
||||||
{new Date(b.scheduledPermanentDeleteAt).toLocaleString()}
|
{new Date(b.scheduledPermanentDeleteAt).toLocaleString("fa-IR")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { isoTodayTehran } from "@/lib/reports/analytics";
|
|||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
type Branch = { id: string; name: string };
|
type Branch = { id: string; name: string };
|
||||||
@@ -172,27 +173,10 @@ export function ExpensesScreen() {
|
|||||||
</select>
|
</select>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("fromDate")} htmlFor="exp-from">
|
<LabeledField label={t("fromDate")} htmlFor="exp-from">
|
||||||
<Input
|
<JalaliDateField id="exp-from" className="w-40" value={from} onChange={setFrom} />
|
||||||
id="exp-from"
|
|
||||||
type="date"
|
|
||||||
dir="ltr"
|
|
||||||
className="w-40 text-end"
|
|
||||||
value={from}
|
|
||||||
max={to}
|
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
|
||||||
/>
|
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("toDate")} htmlFor="exp-to">
|
<LabeledField label={t("toDate")} htmlFor="exp-to">
|
||||||
<Input
|
<JalaliDateField id="exp-to" className="w-40" value={to} onChange={setTo} />
|
||||||
id="exp-to"
|
|
||||||
type="date"
|
|
||||||
dir="ltr"
|
|
||||||
className="w-40 text-end"
|
|
||||||
value={to}
|
|
||||||
min={from}
|
|
||||||
max={today}
|
|
||||||
onChange={(e) => setTo(e.target.value)}
|
|
||||||
/>
|
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<div className="ms-auto text-end">
|
<div className="ms-auto text-end">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function HeaderCenterCluster() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div
|
<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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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",
|
"transition-[width] duration-200 ease-in-out",
|
||||||
collapsed ? "w-14" : "w-56",
|
collapsed ? "w-14" : "w-56",
|
||||||
"border-border",
|
"border-border",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
||||||
import { BranchSwitcher } from "@/components/layout/branch-switcher";
|
import { BranchSwitcher } from "@/components/layout/branch-switcher";
|
||||||
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { NotificationCenter } from "@/components/notifications/notification-center";
|
import { NotificationCenter } from "@/components/notifications/notification-center";
|
||||||
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
||||||
|
|
||||||
@@ -36,7 +37,10 @@ export function Topbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Cafe name */}
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{showNameSkeleton ? (
|
{showNameSkeleton ? (
|
||||||
@@ -67,10 +71,14 @@ export function Topbar() {
|
|||||||
<SyncStatusIndicator />
|
<SyncStatusIndicator />
|
||||||
<NotificationCenter />
|
<NotificationCenter />
|
||||||
|
|
||||||
{/* Language switcher */}
|
{/* Language switcher — hidden on phones to save header space */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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}`)}
|
{t(`languages.${locale}`)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -222,30 +223,19 @@ export function ReportsScreen() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LabeledField label={t("fromDate")} htmlFor="report-from">
|
<LabeledField label={t("fromDate")} htmlFor="report-from">
|
||||||
<Input
|
<JalaliDateField
|
||||||
id="report-from"
|
id="report-from"
|
||||||
type="date"
|
className="w-40"
|
||||||
dir="ltr"
|
|
||||||
className="w-40 text-end"
|
|
||||||
value={range.from}
|
value={range.from}
|
||||||
max={range.to}
|
onChange={(iso) => setRange((r) => ({ ...r, from: iso, preset: "custom" }))}
|
||||||
onChange={(e) =>
|
|
||||||
setRange((r) => ({ ...r, from: e.target.value, preset: "custom" }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("toDate")} htmlFor="report-to">
|
<LabeledField label={t("toDate")} htmlFor="report-to">
|
||||||
<Input
|
<JalaliDateField
|
||||||
id="report-to"
|
id="report-to"
|
||||||
type="date"
|
className="w-40"
|
||||||
dir="ltr"
|
|
||||||
className="w-40 text-end"
|
|
||||||
value={range.to}
|
value={range.to}
|
||||||
min={range.from}
|
onChange={(iso) => setRange((r) => ({ ...r, to: iso, preset: "custom" }))}
|
||||||
max={isoTodayTehran()}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRange((r) => ({ ...r, to: e.target.value, preset: "custom" }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||||
@@ -13,6 +13,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
|
|||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { JalaliDateField, formatIsoDateJalali } from "@/components/ui/jalali-date-field";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -50,6 +51,7 @@ const statusStyle: Record<ReservationStatus, string> = {
|
|||||||
export function ReservationsScreen() {
|
export function ReservationsScreen() {
|
||||||
const t = useTranslations("reservations");
|
const t = useTranslations("reservations");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const locale = useLocale();
|
||||||
const apiError = useApiError();
|
const apiError = useApiError();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -160,14 +162,7 @@ export function ReservationsScreen() {
|
|||||||
</select>
|
</select>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("date")} htmlFor="res-date">
|
<LabeledField label={t("date")} htmlFor="res-date">
|
||||||
<Input
|
<JalaliDateField id="res-date" value={date} onChange={setDate} />
|
||||||
id="res-date"
|
|
||||||
type="date"
|
|
||||||
value={date}
|
|
||||||
onChange={(e) => setDate(e.target.value)}
|
|
||||||
dir="ltr"
|
|
||||||
className="text-end"
|
|
||||||
/>
|
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("time")} htmlFor="res-time">
|
<LabeledField label={t("time")} htmlFor="res-time">
|
||||||
<Input
|
<Input
|
||||||
@@ -222,7 +217,7 @@ export function ReservationsScreen() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{r.guestName}</p>
|
<p className="font-medium">{r.guestName}</p>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{r.date} {r.time.slice(0, 5)} · {formatNumber(r.partySize)} {t("party")}
|
{formatIsoDateJalali(r.date, locale)} {r.time.slice(0, 5)} · {formatNumber(r.partySize)} {t("party")}
|
||||||
{r.tableNumber ? ` · ${t("tableNumber", { number: r.tableNumber })}` : ""}
|
{r.tableNumber ? ` · ${t("tableNumber", { number: r.tableNumber })}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground">{r.guestPhone}</p>
|
<p className="text-[11px] text-muted-foreground">{r.guestPhone}</p>
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Jalali (Shamsi) date picker. The `value`/`onChange` wire format stays the ISO
|
||||||
|
// Gregorian `YYYY-MM-DD` the API expects — only the UI is Persian-calendar.
|
||||||
|
// For the `en` locale it falls back to the native Gregorian date input.
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import {
|
||||||
|
addMonths,
|
||||||
|
eachDayOfInterval,
|
||||||
|
endOfMonth,
|
||||||
|
endOfWeek,
|
||||||
|
format as formatJalali,
|
||||||
|
isSameDay,
|
||||||
|
isSameMonth,
|
||||||
|
parseISO,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
} from "date-fns-jalali";
|
||||||
|
import { faIR } from "date-fns-jalali/locale/fa-IR";
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
|
||||||
|
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
|
||||||
|
|
||||||
|
function localizeDigits(s: string, locale: string): string {
|
||||||
|
if (locale === "en") return s;
|
||||||
|
const digits = locale === "ar" ? AR_DIGITS : FA_DIGITS;
|
||||||
|
return s.replace(/\d/g, (d) => digits[Number(d)]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gregorian ISO date (YYYY-MM-DD) from a local Date — no UTC shift. */
|
||||||
|
function toIsoDate(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||||
|
d.getDate()
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saturday-first week, the Iranian convention. */
|
||||||
|
const WEEK_STARTS_ON = 6 as const;
|
||||||
|
const FA_WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||||
|
|
||||||
|
export function JalaliDateField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
/** ISO Gregorian date string (YYYY-MM-DD) or "". */
|
||||||
|
value: string;
|
||||||
|
onChange: (iso: string) => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = useMemo(() => (value ? parseISO(value) : null), [value]);
|
||||||
|
const [viewDate, setViewDate] = useState<Date>(() => selected ?? new Date());
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Keep the visible month in sync when the value changes externally.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected) setViewDate(selected);
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// English UI keeps the familiar native Gregorian picker.
|
||||||
|
if (locale === "en") {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
className={cn("text-end", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = eachDayOfInterval({
|
||||||
|
start: startOfWeek(startOfMonth(viewDate), { weekStartsOn: WEEK_STARTS_ON }),
|
||||||
|
end: endOfWeek(endOfMonth(viewDate), { weekStartsOn: WEEK_STARTS_ON }),
|
||||||
|
});
|
||||||
|
const today = new Date();
|
||||||
|
const label = selected
|
||||||
|
? localizeDigits(formatJalali(selected, "yyyy/MM/dd"), locale)
|
||||||
|
: (placeholder ?? "");
|
||||||
|
const monthTitle = localizeDigits(
|
||||||
|
formatJalali(viewDate, "MMMM yyyy", { locale: faIR }),
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
!selected && "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label || "—"}</span>
|
||||||
|
<CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
className="absolute z-50 mt-1 w-64 rounded-xl border border-border bg-popover p-2 shadow-lg start-0"
|
||||||
|
>
|
||||||
|
{/* Month header */}
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewDate((d) => addMonths(d, -1))}
|
||||||
|
aria-label="ماه قبل"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 rtl:block ltr:hidden" aria-hidden />
|
||||||
|
<ChevronLeft className="h-4 w-4 rtl:hidden ltr:block" aria-hidden />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold">{monthTitle}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||||||
|
aria-label="ماه بعد"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 rtl:block ltr:hidden" aria-hidden />
|
||||||
|
<ChevronRight className="h-4 w-4 rtl:hidden ltr:block" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekday header */}
|
||||||
|
<div className="grid grid-cols-7 text-center">
|
||||||
|
{FA_WEEKDAYS.map((w) => (
|
||||||
|
<span key={w} className="py-1 text-[10px] font-semibold text-muted-foreground">
|
||||||
|
{w}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day grid */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{days.map((day) => {
|
||||||
|
const inMonth = isSameMonth(day, viewDate);
|
||||||
|
const isSelected = selected ? isSameDay(day, selected) : false;
|
||||||
|
const isToday = isSameDay(day, today);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.getTime()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(toIsoDate(day));
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 cursor-pointer items-center justify-center rounded-lg text-xs transition-colors",
|
||||||
|
inMonth ? "text-foreground" : "text-muted-foreground/40",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary font-bold text-primary-foreground"
|
||||||
|
: isToday
|
||||||
|
? "border border-primary/50 font-semibold text-primary hover:bg-accent"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{localizeDigits(formatJalali(day, "d"), locale)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Today shortcut */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(toIsoDate(today));
|
||||||
|
setViewDate(today);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full cursor-pointer rounded-lg py-1.5 text-center text-xs font-medium text-primary transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
امروز
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an ISO Gregorian date string as a Jalali date for display. */
|
||||||
|
export function formatIsoDateJalali(iso: string, locale: string): string {
|
||||||
|
if (locale === "en") return iso;
|
||||||
|
try {
|
||||||
|
return localizeDigits(formatJalali(parseISO(iso), "yyyy/MM/dd"), locale);
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user