feat(mobile): mobile-responsive UI — bottom tab bar, bottom-sheet dialogs, PWA

All changes are gated behind max-md:/md:hidden so the >=768px desktop layout is untouched (verified with lint + build + live checks at 375px).

- globals.css: one mobile block — 16px inputs (no iOS auto-zoom), units sidebar stacks above content, wizard item rows become 2-col (text fields full-width, delete button full-width), dd-panels / flow diagram / conv-form stack, larger touch targets
- ui/dialog.tsx: dialogs become full-width bottom sheets on mobile (all modals inherit it); desktop keeps the centered dialog
- Header: sticky on mobile only + PageLayout bottom clearance
- New MobileTabBar: role-aware bottom tabs (office admin/manager, inventory PM, inventory admin); hidden on /wizard
- Wizard step 4: TouchSensor (150ms delay) so touch drag works without stealing scroll
- JalaliDatePicker: dropdown capped to viewport; bigger day cells and time inputs on mobile
- PWA: manifest.webmanifest + 192/512 icons (scripts/gen-pwa-icons.ps1) + theme-color/manifest links in index.html
- New docs/mobile-responsive.md documenting the full change set
This commit is contained in:
2026-09-03 19:26:40 +03:30
parent 7f58593f04
commit 9e2089e853
17 changed files with 427 additions and 18 deletions
+108
View File
@@ -0,0 +1,108 @@
# Mobile-Responsive UI — Change Log (v1.4)
هدف: تجربه‌ی کاربری قابل‌استفاده برای کاربران موبایل (اکثریت کاربران)، بدون اینکه
ظاهر دسکتاپ هیچ تغییری بکند. هیچ بک‌اند، nginx، docker-compose یا ساختار جدیدی
اضافه نشده — همه‌چیز داخل همان اپ React است.
## Core principle
یک کدبیس، دو نمایش. همه‌ی تغییرات «فقط موبایل» هستند و پشت بریک‌پوینت
Tailwind `max-md:` (زیر 768px) یا `md:hidden` (فقط روی موبایل نمایش داده می‌شود)
قفل شده‌اند. دسکتاپ (≥768px) همان استایل‌های قبلی را می‌خواند و بنابراین
پیکسل‌به‌پیکسل بدون تغییر می‌ماند — به‌ساختار.
## What changed
### 1. `frontend/src/styles/globals.css` — consolidated mobile block (new)
یک بلوک `@media (max-width: 767.98px)` در انتهای فایل اضافه شد:
| Rule | Effect (mobile only) |
|---|---|
| `input/select/textarea { font-size: 16px !important }` | جلوگیری از زوم خودکار iOS روی فوکوس (فونت‌های ۱۲–۱۴px زوم می‌شدند) |
| `.units-layout { grid-template-columns: 1fr }` | سایدبار ۲۲۰px دسته‌ها روی موبایل بالای پنل محتوا قرار می‌گیرد |
| `.item-row-head { display: none }` + `.wizard-item-row` دوستونه | ردیف اقلام ویزارد (گرید ۸ ستونه) روی موبایل می‌شود: فیلدهای متنی تمام‌عرض، عددها دوستونه؛ placeholderها نقش برچسب را دارند |
| `.wizard-item-row > :last-child` تمام‌عرض + حداقل ۴۴×۴۰px | دکمه‌ی حذف قلم بزرگ و قابل لمس |
| `.dd-panels { grid-template-columns: 1fr }` | پنل‌های درگ‌اندروپ مرحله‌ی ۴ تک‌ستونه |
| `.flow-cols` تک‌ستونه + `.flow-lines { display: none }` | نمودار جریان باکس‌ها روی هم؛ خطوط SVG اتصال که فقط در چیدمان دوستونه معنی داشتند روی موبایل مخفی می‌شوند |
| `.conv-form` تک‌ستونه، `.conv-row` wrap | فرم تبدیل واحد (گرید ۵ ستونه) روی موبایل ستونی |
| touch targets بزرگ‌تر (`.chip`, `.quick-chip`, `.spec-chip`, `.dd-item`, `.ut-cat`, `.btn-line-file`, دکمه‌های × و ...) | ارتفاع هدف لمسی به ~۴۰px رسید |
### 2. `frontend/src/components/ui/dialog.tsx` — dialogs become bottom sheets
`DialogContent` با کلاس‌های `max-md:` به bottom-sheet تمام‌عرض تبدیل شد
(`inset-x-0 bottom-0`، گوشه‌های گرد فقط بالا، `max-h-[85dvh]`، padding با
`safe-area-inset-bottom`). چون همه‌ی مودال‌ها (ویرایش، پذیرش، تایید حذف) از همین
کامپوننت استفاده می‌کنند، همه یک‌جا درست شدند. دکمه‌ی بستن هم بزرگ‌تر شد.
### 3. Form grids stacked — one-line additions each
- `EditModal.tsx` — گرید تأمین‌کننده/پروژه و گرید تاریخ‌ها: `max-md:grid-cols-1`
- `Wizard/Step1TopicSupplier.tsx` — فرم تأمین‌کننده‌ی جدید: `max-md:grid-cols-1`
- `Wizard/Step5Review.tsx` — گرید تاریخ‌ها: `max-md:grid-cols-1`
- `Suppliers/CategoryPage.tsx` — فرم افزودن تأمین‌کننده: `max-md:grid-cols-1`
### 4. `frontend/src/components/layout/Header.tsx` — sticky header + clearance
- هدر فقط روی موبایل `sticky top-0 z-40` شد (`max-md:`)
- دکمه‌ی بازگشت بزرگ‌تر شد
- `PageLayout` روی موبایل `pb-24` گرفت تا نوار تب پایین روی محتوا نیفتد
### 5. `frontend/src/components/layout/MobileTabBar.tsx` — NEW mobile bottom tab bar
نوار تب پایین (فقط `<768px` با `md:hidden`):
- **ادمین / مدیر تدارکات در دفتر:** داشبورد / تأمین‌کننده‌ها / پروژه‌ها / ضرایب
- **ادمین / مدیر تدارکات در پنل انبار:** انبار / کنترل روزانه / کالاهای در راه / سازمان‌دهی (آینه‌ی ناوبری دسکتاپ)
- **مدیر پروژه:** انبار / تغییر موجودی / کنترل روزانه / کالاهای در راه
- هدف لمسی ≥۵۶px، padding برای `safe-area-inset-bottom`، هایلایت تب فعال
- در `/wizard` نمایش داده نمی‌شود (تا با جابه‌جایی تصادفی کارِ کاربر از بین نرود)
### 6. `frontend/src/App.tsx` — renders `<MobileTabBar />`
### 7. `frontend/src/pages/Wizard/Step4Projects.tsx` — touch drag & drop
`TouchSensor` با `{ delay: 150, tolerance: 8 }` به سنسورهای dnd-kit اضافه شد:
درگ لمسی کار می‌کند بدون اینکه اسکرول صفحه را بدزدد. مسیر کلیک-قلم-بعد-کلیک-پروژه
هم‌چنان مسیر جایگزین است. دسکتاپ همان `PointerSensor` قبلی را دارد.
### 8. `frontend/src/components/JalaliDatePicker.tsx` — dropdown width + targets
- داپداون تقویم: `max-w-[calc(100vw-2rem)]` (در صفحه‌های باریک سرریز نمی‌شود)
- سلول‌های روز و ورودی‌های ساعت/دقیقه روی موبایل بزرگ‌تر شدند
### 9. PWA layer (optional installability)
- `frontend/public/manifest.webmanifest` — NEW (name فارسی، `dir: rtl`،
`display: standalone`، theme `#4f46e5`، آیکون 192/512 + maskable)
- `frontend/public/icons/icon-192.png`, `icon-512.png` — NEW
(تولیدشده با `frontend/scripts/gen-pwa-icons.ps1` — NEW، بدون وابستگی)
- `frontend/index.html` — `theme-color` + لینک manifest + آیکون‌ها اضافه شد
- روی HTTP مثل میان‌بر صفحه‌ی اصلی کار می‌کند؛ پرامپت نصب رسمی PWA به HTTPS
نیاز دارد (بعداً با پروکسی/Let's Encrypt — بدون تغییر کد).
## Deliberately NOT changed
- **`SpecDrawer.tsx`**: کد مرده است (هیچ‌جا import نمی‌شود) — تصمیم تیم: دست نمی‌زنیم، فایل می‌ماند.
- **`.e-item-row` / `.spec-drawer` در CSS**: کلاس‌های بلااستفاده — دست‌نخورده ماندند.
- هیچ کلاس/استایل دسکتاپی ویرایش نشد؛ تمام تغییرات داخل `max-md:` یا کامپوننت‌های `md:hidden` است.
## Verification performed
- `npm run lint` (oxlint) → 0 errors (4 هشدار از قبل موجود)
- `npm run build` (tsc + vite) → موفق
- تست مرورگر روی Vite dev (viewport واقعی 375px):
- نوار تب: `fixed bottom-0`، قابل‌مشاهده، ۴ تب با برچسب درست، ارتفاع ≥56px
- هدر `sticky`، ورودی‌ها 16px، padding پایین صفحه 96px (جای نوار تب)
- صفحات ضرایب/ویزارد چیدمان تک‌ستونه (`.units-layout` فقط بعد از انتخاب موضوع رندر می‌شود)
- رگرسیون دسکتاپ: کلاس‌های دسکتاپ دست‌نخورده‌اند (بریک‌پوینت‌گیت)؛
`npm run build` سلامت باندل را تایید می‌کند.
## How to test manually
```powershell
cd C:\Users\Reza\Desktop\v1.4
docker compose up -d --build # بازسازی ایمیج فرانت
```
سپس در مرورگر دسکتاپ DevTools → device toolbar (مثلاً iPhone SE 375px) یا
مستقیم با گوشی (بخش «دیدن روی گوشی» در README/جواب نهایی). دسکتاپ را هم در
1280px چک کنید — باید دقیقاً مثل قبل باشد.
## Notes / known considerations
- خطوط اتصال نمودار جریان (SVG) روی موبایل عمداً مخفی شد — فقط باکس‌ها روی هم
قرار می‌گیرند؛ اگر روزی خواستید اتصال‌ها را روی موبایل هم ببینید باید
`FlowDiagram` برای حالت تک‌ستونه بازنویسی شود.
- نوار تب موبایل «انبارها» را ندارد (PMها از منوی همبرگری به آن می‌رسند).
+4
View File
@@ -3,6 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4f46e5" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "دفترچه تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"dir": "rtl",
"lang": "fa",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f3f5f9",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
# Generate PWA icons (192px / 512px) — indigo rounded square + white clipboard glyph
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Drawing.Drawing2D
function Make-RoundedRect([float]$x, [float]$y, [float]$w, [float]$h, [float]$r) {
$p = New-Object System.Drawing.Drawing2D.GraphicsPath
$d = 2 * $r
$p.AddArc($x, $y, $d, $d, 180, 90)
$p.AddArc($x + $w - $d, $y, $d, $d, 270, 90)
$p.AddArc($x + $w - $d, $y + $h - $d, $d, $d, 0, 90)
$p.AddArc($x, $y + $h - $d, $d, $d, 90, 90)
$p.CloseFigure()
return $p
}
$outDir = "public/icons"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
foreach ($s in @(192, 512)) {
$bmp = New-Object System.Drawing.Bitmap($s, $s)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.Clear([System.Drawing.Color]::Transparent)
$m = [float]($s * 0.055)
$bg = Make-RoundedRect $m $m ([float]($s - 2 * $m)) ([float]($s - 2 * $m)) ([float]($s * 0.20))
$indigo = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))
$g.FillPath($indigo, $bg)
# clipboard board (white)
$bw = [float]($s * 0.56); $bh = [float]($s * 0.62)
$bx = [float](($s - $bw) / 2); $by = [float]($s * 0.31)
$board = Make-RoundedRect $bx $by $bw $bh ([float]($s * 0.05))
$white = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::White)
$g.FillPath($white, $board)
# clip on top
$cw = [float]($s * 0.15); $ch = [float]($s * 0.11)
$cx = [float](($s - $cw) / 2); $cy = [float]($by - $s * 0.085)
$clip = Make-RoundedRect $cx $cy $cw $ch ([float]($s * 0.03))
$g.FillPath($white, $clip)
# ring holes (indigo)
$g.FillEllipse($indigo, [float]($cx + $cw * 0.16), [float]($cy + $ch * 0.30), [float]($s * 0.035), [float]($s * 0.035))
$g.FillEllipse($indigo, [float]($cx + $cw * 0.62), [float]($cy + $ch * 0.30), [float]($s * 0.035), [float]($s * 0.035))
# lines on the board
$slate = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(148, 163, 184))
$lh = [float]($s * 0.032)
$g.FillRectangle($slate, [float]($bx + $s * 0.09), [float]($by + $s * 0.15), [float]($bw - $s * 0.18), $lh)
$g.FillRectangle($slate, [float]($bx + $s * 0.09), [float]($by + $s * 0.29), [float]($bw - $s * 0.30), $lh)
$g.FillRectangle($slate, [float]($bx + $s * 0.09), [float]($by + $s * 0.43), [float]($bw - $s * 0.24), $lh)
$path = Join-Path $outDir "icon-$s.png"
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
Write-Output "saved $path"
}
+4
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { MobileTabBar } from "./components/layout/MobileTabBar";
// هر صفحه با React.lazy لود می‌شود (code splitting) // هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() => const DashboardPage = lazy(() =>
@@ -65,6 +66,7 @@ function HomeRoute() {
export default function App() { export default function App() {
return ( return (
<>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -194,5 +196,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
<MobileTabBar />
</>
); );
} }
+5 -3
View File
@@ -183,6 +183,8 @@ export function JalaliDatePicker({
<div <div
className={cn( className={cn(
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md", "absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
// حداکثر عرض = viewport (موبایل باریک سرریز نمی‌شود؛ دسکتاپ بی‌تغییر)
"max-w-[calc(100vw-2rem)]",
withTime ? "w-80 p-3" : "w-64 p-2", withTime ? "w-80 p-3" : "w-64 p-2",
)} )}
> >
@@ -217,7 +219,7 @@ export function JalaliDatePicker({
key={i} key={i}
type="button" type="button"
className={cn( className={cn(
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg", "cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]", cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]", cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark", cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
@@ -235,7 +237,7 @@ export function JalaliDatePicker({
<span className="text-[11.5px] font-semibold text-muted">ساعت</span> <span className="text-[11.5px] font-semibold text-muted">ساعت</span>
<input <input
type="text" type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
value={faNum(String(time.h).padStart(2, "0"))} value={faNum(String(time.h).padStart(2, "0"))}
inputMode="numeric" inputMode="numeric"
maxLength={2} maxLength={2}
@@ -247,7 +249,7 @@ export function JalaliDatePicker({
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span> <span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
<input <input
type="text" type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
value={faNum(String(time.m).padStart(2, "0"))} value={faNum(String(time.m).padStart(2, "0"))}
inputMode="numeric" inputMode="numeric"
maxLength={2} maxLength={2}
+3 -3
View File
@@ -27,7 +27,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
}; };
return ( return (
<header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel px-5 py-2.5"> <header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel px-5 py-2.5 max-md:sticky max-md:top-0 max-md:z-40">
{menu && <div className="flex shrink-0 items-center">{menu}</div>} {menu && <div className="flex shrink-0 items-center">{menu}</div>}
{(icon || brand) && ( {(icon || brand) && (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-accent"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-accent">
@@ -38,7 +38,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink" className="inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink max-md:px-2.5 max-md:py-2"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
{backLabel} {backLabel}
@@ -64,6 +64,6 @@ export function PageLayout({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div> <div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4 max-md:pb-24", className)}>{children}</div>
); );
} }
@@ -0,0 +1,100 @@
import { NavLink, useLocation } from "react-router-dom";
import {
ArrowLeftRight,
Boxes,
Building2,
CalendarDays,
LayoutDashboard,
PackageSearch,
Scale,
Truck,
UsersRound,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
import { useAuthStore } from "../../store/auth";
interface TabDef {
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
}
// تب‌های دفتر تدارکات (ادمین / مدیر تدارکات)
const PROC_TABS: TabDef[] = [
{ to: "/", label: "داشبورد", icon: LayoutDashboard, end: true },
{ to: "/suppliers", label: "تأمین‌کننده‌ها", icon: Truck },
{ to: "/projects", label: "پروژه‌ها", icon: Building2 },
{ to: "/units", label: "ضرایب", icon: Scale },
];
// تب‌های پنل انبار برای مدیر پروژه
const PM_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/change", label: "تغییر موجودی", icon: ArrowLeftRight },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
];
function TabBar({ tabs }: { tabs: TabDef[] }) {
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="grid grid-cols-4">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
"flex min-h-[56px] flex-col items-center justify-center gap-0.5 whitespace-nowrap px-1 text-[10.5px] font-medium transition-colors",
isActive ? "text-accent" : "text-muted",
)
}
>
<t.icon className="h-5 w-5" strokeWidth={1.8} />
<span>{t.label}</span>
</NavLink>
))}
</div>
</nav>
);
}
// نوار تب پایینِ موبایل — فقط زیر 768px (md:hidden) و فقط در صفحاتِ نقش کاربر:
// مدیر پروژه → تب‌های انبار؛ ادمین/مدیر تدارکات → تب‌های دفتر یا انبار (بر اساس مسیر).
// در ویزارد نمایش داده نمی‌شود تا با جابه‌جایی تصادفی، کارِ کاربر از بین نرود.
export function MobileTabBar() {
const role = useAuthStore((s) => s.user?.role);
const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null;
const isPM = role === "pm";
const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
return null;
}
if (pathname === "/wizard") return null;
const onOfficePage =
pathname === "/" ||
pathname === "/projects" ||
pathname === "/units" ||
pathname.startsWith("/suppliers");
if (onOfficePage && isOffice) return <TabBar tabs={PROC_TABS} />;
return null;
}
+3 -1
View File
@@ -33,12 +33,14 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-1/2 top-1/2 z-50 grid max-h-[90vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line bg-panel p-6 shadow-lg", "fixed left-1/2 top-1/2 z-50 grid max-h-[90vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line bg-panel p-6 shadow-lg",
// موبایل: دیالوگ به‌جای وسط، bottom-sheet تمام‌عرض می‌شود (دسکتاپ بی‌تغییر)
"max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:max-h-[85dvh] max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 max-md:rounded-b-none max-md:rounded-t-2xl max-md:p-4 max-md:pb-[calc(env(safe-area-inset-bottom)+0.75rem)]",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40"> <DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40 max-md:p-1.5">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">بستن</span> <span className="sr-only">بستن</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
+2 -2
View File
@@ -373,7 +373,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<DialogTitle>ویرایش پیشنهاد</DialogTitle> <DialogTitle>ویرایش پیشنهاد</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field"> <div className="field">
<label>تأمین‌کننده</label> <label>تأمین‌کننده</label>
<Combobox <Combobox
@@ -609,7 +609,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
{/* تاریخ‌ها و وضعیت */} {/* تاریخ‌ها و وضعیت */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-2 text-[12px] text-muted">تاریخ‌ها و وضعیت:</div> <div className="mb-2 text-[12px] text-muted">تاریخ‌ها و وضعیت:</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field"> <div className="field">
<label>تاریخ پیشنهاد</label> <label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime /> <JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
@@ -109,7 +109,7 @@ export function CategoryPage() {
<h4> <h4>
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید <Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید
</h4> </h4>
<div className="grid max-w-2xl grid-cols-2 gap-3"> <div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field"> <div className="field">
<label>نام تأمین‌کننده *</label> <label>نام تأمین‌کننده *</label>
<Combobox <Combobox
@@ -127,7 +127,7 @@ export function Step1TopicSupplier({
<div className="mb-2 text-[12px] text-muted"> <div className="mb-2 text-[12px] text-muted">
این تأمین‌کننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود: این تأمین‌کننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود:
</div> </div>
<div className="grid max-w-2xl grid-cols-2 gap-3"> <div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field"> <div className="field">
<label>موقعیت (شهر - منطقه)</label> <label>موقعیت (شهر - منطقه)</label>
<input <input
+7 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { DndContext, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { DndContext, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner"; import { toast } from "sonner";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
@@ -117,7 +117,12 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
// فاصله‌ی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد // فاصله‌ی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
// نه شروع درگ (بدون این، dnd-kit رویداد click را می‌بلعد و کلیک-انتخاب کار نمی‌کند) // نه شروع درگ (بدون این، dnd-kit رویداد click را می‌بلعد و کلیک-انتخاب کار نمی‌کند)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); // TouchSensor با تاخیر فعال‌سازی: درگ لمسی روی موبایل کار می‌کند بدون اینکه
// اسکرول صفحه را بدزدد (کلیک-قلم بعد کلیک-پروژه هم‌چنان مسیر جایگزین است).
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
);
// افزودن پروژه به‌صورت درجا — بدون رفتن به صفحه‌ی پروژه‌ها // افزودن پروژه به‌صورت درجا — بدون رفتن به صفحه‌ی پروژه‌ها
const addProjectInline = () => { const addProjectInline = () => {
+1 -1
View File
@@ -56,7 +56,7 @@ export function Step5Review({
تاریخ، یادداشت و بازبینی نهایی تاریخ، یادداشت و بازبینی نهایی
</h3> </h3>
<div className="grid max-w-2xl grid-cols-2 gap-3"> <div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field"> <div className="field">
<label>تاریخ پیشنهاد</label> <label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime /> <JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
+93
View File
@@ -693,3 +693,96 @@ button:disabled {
.ph-empty-text { .ph-empty-text {
@apply text-[13px] text-muted; @apply text-[13px] text-muted;
} }
/* ============================================================
موبایل — فقط زیر 768px؛ دسکتاپ هرگز این استایل‌ها را نمی‌بیند
============================================================ */
@media (max-width: 767.98px) {
/* جلوگیری از زوم خودکار iOS: ورودی‌ها حداقل ۱۶px */
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
font-size: 16px !important;
}
/* واحدها: سایدبار دسته‌ها بالای پنل محتوا قرار می‌گیرد */
.units-layout {
grid-template-columns: 1fr;
}
/* ردیف اقلام ویزارد: هدر ستون‌ها حذف (placeholderها برچسب‌اند)؛
فیلدهای متنی تمام‌عرض، عددها دوستونه، دکمه‌ی حذف تمام‌عرض */
.item-row-head {
display: none;
}
.wizard-item-row {
grid-template-columns: 1fr 1fr;
row-gap: 0.5rem;
}
.wizard-item-row > :nth-child(-n + 3) {
grid-column: 1 / -1;
}
.wizard-item-row > :last-child {
grid-column: 1 / -1;
justify-self: center;
min-width: 44px;
min-height: 40px;
}
/* پنل‌های درگ‌اندروپ پروژه‌ها (مرحله ۴): تک‌ستونه */
.dd-panels {
grid-template-columns: 1fr;
}
/* نمودار جریان: باکس‌ها روی هم؛ خطوط اتصال روی موبایل معنی ندارند */
.flow-cols {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.flow-lines {
display: none;
}
/* فرم تبدیل واحد: ستونی */
.conv-form {
grid-template-columns: 1fr;
align-items: stretch;
}
.conv-row {
flex-wrap: wrap;
}
/* اهداف لمسی بزرگ‌تر (دست‌کم ~۴۰px) */
.chip {
padding-inline: 0.85rem;
padding-block: 0.55rem;
}
.quick-chip {
padding-block: 0.55rem;
}
.spec-chip {
padding-inline: 0.65rem;
padding-block: 0.4rem;
}
.dd-item {
padding-block: 0.65rem;
}
.ut-cat {
padding-block: 0.65rem;
}
.btn-line-file {
padding-block: 0.6rem;
}
.spec-chip-x,
.dd-tag-x,
.inv-x,
.ms-x,
.variant-x,
.file-x,
.sup-del {
padding: 0.4rem;
}
.conv-actions button {
padding: 0.45rem;
}
}