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:
@@ -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ها از منوی همبرگری به آن میرسند).
|
||||
@@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useAuthStore } from "./store/auth";
|
||||
import { MobileTabBar } from "./components/layout/MobileTabBar";
|
||||
|
||||
// هر صفحه با React.lazy لود میشود (code splitting)
|
||||
const DashboardPage = lazy(() =>
|
||||
@@ -65,6 +66,7 @@ function HomeRoute() {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
@@ -194,5 +196,7 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<MobileTabBar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,8 @@ export function JalaliDatePicker({
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
@@ -217,7 +219,7 @@ export function JalaliDatePicker({
|
||||
key={i}
|
||||
type="button"
|
||||
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 && "font-bold text-[#1d4ed8]",
|
||||
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>
|
||||
<input
|
||||
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"))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
@@ -247,7 +249,7 @@ export function JalaliDatePicker({
|
||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||
<input
|
||||
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"))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
||||
};
|
||||
|
||||
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>}
|
||||
{(icon || brand) && (
|
||||
<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
|
||||
type="button"
|
||||
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" />
|
||||
{backLabel}
|
||||
@@ -64,6 +64,6 @@ export function PageLayout({
|
||||
className?: string;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
@@ -33,12 +33,14 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
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",
|
||||
// موبایل: دیالوگ بهجای وسط، 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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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" />
|
||||
<span className="sr-only">بستن</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -373,7 +373,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
||||
</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">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
@@ -609,7 +609,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
{/* تاریخها و وضعیت */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<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">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||
|
||||
@@ -109,7 +109,7 @@ export function CategoryPage() {
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||
</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">
|
||||
<label>نام تأمینکننده *</label>
|
||||
<Combobox
|
||||
|
||||
@@ -127,7 +127,7 @@ export function Step1TopicSupplier({
|
||||
<div className="mb-2 text-[12px] text-muted">
|
||||
این تأمینکننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود:
|
||||
</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">
|
||||
<label>موقعیت (شهر - منطقه)</label>
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { toast } from "sonner";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
@@ -117,7 +117,12 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
||||
|
||||
// فاصلهی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
|
||||
// نه شروع درگ (بدون این، 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 = () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function Step5Review({
|
||||
تاریخ، یادداشت و بازبینی نهایی
|
||||
</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">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||
|
||||
@@ -693,3 +693,96 @@ button:disabled {
|
||||
.ph-empty-text {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user