diff --git a/PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (1).png b/PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (1).png new file mode 100644 index 0000000..8492dc1 Binary files /dev/null and b/PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (1).png differ diff --git a/PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (2).png b/PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (2).png new file mode 100644 index 0000000..bf0bab1 Binary files /dev/null and b/PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (2).png differ diff --git a/PNG/ChatGPT Image Aug 2, 2026, 05_49_36 PM (3).png b/PNG/ChatGPT Image Aug 2, 2026, 05_49_36 PM (3).png new file mode 100644 index 0000000..7cb425f Binary files /dev/null and b/PNG/ChatGPT Image Aug 2, 2026, 05_49_36 PM (3).png differ diff --git a/PNG/ChatGPT Image Aug 2, 2026, 05_49_41 PM (9).png b/PNG/ChatGPT Image Aug 2, 2026, 05_49_41 PM (9).png new file mode 100644 index 0000000..e974273 Binary files /dev/null and b/PNG/ChatGPT Image Aug 2, 2026, 05_49_41 PM (9).png differ diff --git a/PNG/ChatGPT Image Aug 2, 2026, 05_49_43 PM (10).png b/PNG/ChatGPT Image Aug 2, 2026, 05_49_43 PM (10).png new file mode 100644 index 0000000..7c433f9 Binary files /dev/null and b/PNG/ChatGPT Image Aug 2, 2026, 05_49_43 PM (10).png differ diff --git a/PNG/Notification.png b/PNG/Notification.png new file mode 100644 index 0000000..6dc32dd Binary files /dev/null and b/PNG/Notification.png differ diff --git a/PNG/Profile Pop Up.png b/PNG/Profile Pop Up.png new file mode 100644 index 0000000..0b8db68 Binary files /dev/null and b/PNG/Profile Pop Up.png differ diff --git a/PNG/Search.png b/PNG/Search.png new file mode 100644 index 0000000..6d95f81 Binary files /dev/null and b/PNG/Search.png differ diff --git a/PNG/Setting Pop Up.png b/PNG/Setting Pop Up.png new file mode 100644 index 0000000..fec79c3 Binary files /dev/null and b/PNG/Setting Pop Up.png differ diff --git a/PNG/Social Media.png b/PNG/Social Media.png new file mode 100644 index 0000000..53f972e Binary files /dev/null and b/PNG/Social Media.png differ diff --git a/artifacts-after-login.png b/artifacts-after-login.png new file mode 100644 index 0000000..45ac748 Binary files /dev/null and b/artifacts-after-login.png differ diff --git a/artifacts-current-status.png b/artifacts-current-status.png new file mode 100644 index 0000000..ac98f34 Binary files /dev/null and b/artifacts-current-status.png differ diff --git a/artifacts-dashboard-ready.png b/artifacts-dashboard-ready.png new file mode 100644 index 0000000..4fbcf1b Binary files /dev/null and b/artifacts-dashboard-ready.png differ diff --git a/artifacts-dock-menu-open.png b/artifacts-dock-menu-open.png new file mode 100644 index 0000000..492aa64 Binary files /dev/null and b/artifacts-dock-menu-open.png differ diff --git a/artifacts-dock-menu.png b/artifacts-dock-menu.png new file mode 100644 index 0000000..7a4245a Binary files /dev/null and b/artifacts-dock-menu.png differ diff --git a/artifacts-employee-dashboard.png b/artifacts-employee-dashboard.png new file mode 100644 index 0000000..fa541a4 Binary files /dev/null and b/artifacts-employee-dashboard.png differ diff --git a/artifacts-employee-ready.png b/artifacts-employee-ready.png new file mode 100644 index 0000000..a7b1ef6 Binary files /dev/null and b/artifacts-employee-ready.png differ diff --git a/artifacts-my-courses-dragged-2.png b/artifacts-my-courses-dragged-2.png new file mode 100644 index 0000000..831bf85 Binary files /dev/null and b/artifacts-my-courses-dragged-2.png differ diff --git a/artifacts-my-courses-dragged.png b/artifacts-my-courses-dragged.png new file mode 100644 index 0000000..831bf85 Binary files /dev/null and b/artifacts-my-courses-dragged.png differ diff --git a/artifacts-my-courses-window.png b/artifacts-my-courses-window.png new file mode 100644 index 0000000..08775cd Binary files /dev/null and b/artifacts-my-courses-window.png differ diff --git a/artifacts-social-desktop.png b/artifacts-social-desktop.png new file mode 100644 index 0000000..108cd92 Binary files /dev/null and b/artifacts-social-desktop.png differ diff --git a/artifacts-social-focus.png b/artifacts-social-focus.png new file mode 100644 index 0000000..dca6079 Binary files /dev/null and b/artifacts-social-focus.png differ diff --git a/artifacts-social-mobile.png b/artifacts-social-mobile.png new file mode 100644 index 0000000..3957341 Binary files /dev/null and b/artifacts-social-mobile.png differ diff --git a/artifacts-social-tablet.png b/artifacts-social-tablet.png new file mode 100644 index 0000000..5e38ece Binary files /dev/null and b/artifacts-social-tablet.png differ diff --git a/artifacts-switcher-open.png b/artifacts-switcher-open.png new file mode 100644 index 0000000..ef41897 Binary files /dev/null and b/artifacts-switcher-open.png differ diff --git a/artifacts-ui-login.png b/artifacts-ui-login.png new file mode 100644 index 0000000..5b14352 Binary files /dev/null and b/artifacts-ui-login.png differ diff --git a/artifacts-ui-status.png b/artifacts-ui-status.png new file mode 100644 index 0000000..aef48de Binary files /dev/null and b/artifacts-ui-status.png differ diff --git a/artifacts/arad-glass-dashboard-dark.png b/artifacts/arad-glass-dashboard-dark.png new file mode 100644 index 0000000..daa0bfb Binary files /dev/null and b/artifacts/arad-glass-dashboard-dark.png differ diff --git a/artifacts/arad-glass-dashboard-light.png b/artifacts/arad-glass-dashboard-light.png new file mode 100644 index 0000000..fcd1101 Binary files /dev/null and b/artifacts/arad-glass-dashboard-light.png differ diff --git a/artifacts/arad-glass-login-light-2.png b/artifacts/arad-glass-login-light-2.png new file mode 100644 index 0000000..a4ca965 Binary files /dev/null and b/artifacts/arad-glass-login-light-2.png differ diff --git a/artifacts/arad-glass-login-light.png b/artifacts/arad-glass-login-light.png new file mode 100644 index 0000000..e86f8f3 Binary files /dev/null and b/artifacts/arad-glass-login-light.png differ diff --git a/artifacts/arad-glass-login-mobile.png b/artifacts/arad-glass-login-mobile.png new file mode 100644 index 0000000..a102f75 Binary files /dev/null and b/artifacts/arad-glass-login-mobile.png differ diff --git a/artifacts/brightness-range-final.png b/artifacts/brightness-range-final.png new file mode 100644 index 0000000..50205be Binary files /dev/null and b/artifacts/brightness-range-final.png differ diff --git a/artifacts/dock-brightness-status.png b/artifacts/dock-brightness-status.png new file mode 100644 index 0000000..77edddb Binary files /dev/null and b/artifacts/dock-brightness-status.png differ diff --git a/artifacts/dock-desktop-return-final.png b/artifacts/dock-desktop-return-final.png new file mode 100644 index 0000000..3eb7cab Binary files /dev/null and b/artifacts/dock-desktop-return-final.png differ diff --git a/artifacts/dock-workspace-final.png b/artifacts/dock-workspace-final.png new file mode 100644 index 0000000..41049f8 Binary files /dev/null and b/artifacts/dock-workspace-final.png differ diff --git a/artifacts/icon-sharpness-launcher-final.png b/artifacts/icon-sharpness-launcher-final.png new file mode 100644 index 0000000..c69ba16 Binary files /dev/null and b/artifacts/icon-sharpness-launcher-final.png differ diff --git a/artifacts/icon-sharpness-launcher.png b/artifacts/icon-sharpness-launcher.png new file mode 100644 index 0000000..0dca3c3 Binary files /dev/null and b/artifacts/icon-sharpness-launcher.png differ diff --git a/artifacts/icon-sharpness-window.png b/artifacts/icon-sharpness-window.png new file mode 100644 index 0000000..e121f97 Binary files /dev/null and b/artifacts/icon-sharpness-window.png differ diff --git a/artifacts/icon-sharpness-workspace.png b/artifacts/icon-sharpness-workspace.png new file mode 100644 index 0000000..3bac867 Binary files /dev/null and b/artifacts/icon-sharpness-workspace.png differ diff --git a/artifacts/ui-audit-20260801/dashboard-1440-valid2.png b/artifacts/ui-audit-20260801/dashboard-1440-valid2.png new file mode 100644 index 0000000..6b3319d Binary files /dev/null and b/artifacts/ui-audit-20260801/dashboard-1440-valid2.png differ diff --git a/artifacts/ui-audit-20260801/licenses-1024-valid2.png b/artifacts/ui-audit-20260801/licenses-1024-valid2.png new file mode 100644 index 0000000..182a1f4 Binary files /dev/null and b/artifacts/ui-audit-20260801/licenses-1024-valid2.png differ diff --git a/docs/ARAD_LEARNING_GLASS_CHANGELOG.md b/docs/ARAD_LEARNING_GLASS_CHANGELOG.md new file mode 100644 index 0000000..ac56322 --- /dev/null +++ b/docs/ARAD_LEARNING_GLASS_CHANGELOG.md @@ -0,0 +1,62 @@ +# Arad Learning Glass — Color Edition + +## Audit + +- Stack: Next.js 15، React 19، TypeScript، Tailwind CSS و Lucide. +- Routing: App Router با مسیرهای مستقل Login، Dashboard، Calendar، Apps و System Settings. +- State: Hookهای Desktop/Window/Dock و Local Storage برای Appearance و Workspace state. +- Data: Service adapterهای جدا از UI؛ Analytics از API اختیاری و fallback محلی استفاده می‌کند. +- Theme: `AppearanceProvider`، semantic CSS variables، Light/Dark/System و accent themes. +- RTL: `lang="fa"` و `dir="rtl"` در Root و کنترل جهت مستقل برای داده‌های LTR. + +## Design system + +- سه سطح اصلی: Content Surface، Color Surface و Glass Surface. +- توکن‌های Brand، module colors، surfaces، radius، shadow، spacing و state colors. +- آیکون‌های یکپارچه Lucide از طریق `UiIcon`. +- Touch target حداقل ۴۴px، focus visible، reduced motion و dialogهای دارای focus trap. +- Glass محدود به Navigation، Dock، Dialog، Search، Quick Settings و لایه‌های شناور است. + +## تغییرات این مرحله + +- صفحه خانه از Launcher خالی به Learning Workspace سه‌ستونه تبدیل شد. +- Hero یادگیری، KPIهای نقش‌محور، ادامه یادگیری، پیشنهادها، برنامه امروز، اعلان‌ها و Quick Access اضافه شد. +- Launcher قبلی با تمام رفتارهای icon/folder/widget به نمای قابل‌تغییر «برنامه‌ها» منتقل شد. +- صفحه ورود به چیدمان کامل Desktop و فرم تمام‌عرض Mobile ارتقا یافت. +- ورود با کد سازمانی، Remember Me، راهنمای بازیابی رمز و لینک پشتیبانی اضافه شد. +- تصویرسازی Login با CSS سبک و بدون asset یا dependency اضافی ساخته شد. +- Audit browser برای Dark Mode و بستن Dialog گسترش یافت. + +## صفحات و سطوح موجود در سیستم یکپارچه + +- Dashboard / Learning Workspace +- Dock / Launcher / Quick actions +- Login +- Notification Center +- Profile Dashboard +- Global Search / Command Center +- Quick Settings +- Organizational Social Network +- Navigation / Window Workspace +- Course and Microlearning management + +## داده و محدودیت Backend + +- KPIها در صورت تنظیم `NEXT_PUBLIC_LMS_API_BASE_URL` از API دریافت می‌شوند؛ بدون آن از adapter محلی توسعه استفاده می‌شود. +- فهرست «دوره‌های من» در وضعیت فعلی پروژه به `myCoursesMockService` متصل است و Endpoint تولیدی در repository وجود ندارد. +- Authentication فعلی نمونه‌ای و Local Storage-based است؛ هیچ Token، Secret یا API key جدیدی اضافه نشده است. + +## Verification + +- `npm run lint`: موفق، بدون خطا. +- `npm run build`: موفق؛ TypeScript و ۴۵ route بررسی شدند. +- Login: ۱۴۴۰×۱۰۰۰ و ۳۷۵×۸۱۲. +- Dashboard: Light و Dark در ۱۴۴۰×۱۰۰۰. +- RTL، focus، Escape/dialog و touch targetهای اصلی در Audit مرورگر بررسی شدند. + +## Screenshots + +- `artifacts/arad-glass-login-light.png` +- `artifacts/arad-glass-login-mobile.png` +- `artifacts/arad-glass-dashboard-light.png` +- `artifacts/arad-glass-dashboard-dark.png` diff --git a/docs/UI_REFACTOR_CHECKLIST.md b/docs/UI_REFACTOR_CHECKLIST.md new file mode 100644 index 0000000..5b3cc64 --- /dev/null +++ b/docs/UI_REFACTOR_CHECKLIST.md @@ -0,0 +1,44 @@ +# چک‌لیست بازطراحی UI/UX + +این سند وضعیت اجرای ۱۹ تغییر تأییدشده را ثبت می‌کند. هر مورد فقط پس از پیاده‌سازی و راستی‌آزمایی تیک می‌خورد. + +- [x] 1. کاهش ۲۰ درصدی ارتفاع نوار بالای دسکتاپ +- [x] 2. یکپارچه‌سازی مسیر و کامپوننت آیکون‌های غیردسکتاپ +- [x] 3. بازشدن تنظیمات و اعلان‌ها در بالا-چپ با انیمیشن ۳۴۵ میلی‌ثانیه +- [x] 4. فشرده‌سازی محتوای اعلان‌ها +- [x] 5. حذف کامل دسته‌بندی اعلان‌ها +- [x] 6. افزودن Swipe چپ و راست به هر اعلان همراه با جایگزین قابل‌دسترسی +- [x] 7. حذف خروج‌های تکراری پروفایل و افزودن تب مستقل خروج +- [x] 8. محوشدن Dock هنگام هم‌پوشانی پنجره +- [x] 9. حفظ حالت شیشه‌ای آیکون‌ها با ظاهر مات و بدون برق +- [x] 10. افزودن منوی راست‌کلیک Dock و بستن پنجره‌های باز +- [x] 11. مخفی‌کردن نوار اسکرول popupها با حفظ اسکرول ورودی‌ها +- [x] 12. افزایش خوانایی نمای ماهانه تقویم +- [x] 13. تبدیل ایجاد رویداد به Wizard بدون overflow و اسکرول داخلی +- [x] 14. حذف نوار عنوان مستقل پنجره و انتقال کنترل‌ها به بالا-چپ +- [x] 15. یکسان‌سازی صفحات با کامپوننت‌ها و الگوهای مشترک +- [x] 16. بهبود کنتراست و کنترل شفافیت در تم روشن و تاریک +- [x] 17. اتصال داشبوردها به داده واقعی و به‌روزرسانی زنده +- [x] 18. حذف کامل حالت و کنترل تمام‌صفحه پنجره‌ها +- [x] 19. فشرده‌سازی هدر ایجاد کاربر و نگه‌داشتن اکشن‌های آیکونی + +## کنترل کیفیت نهایی + +- [x] اجرای موفق ESLint +- [x] اجرای موفق production build +- [x] بررسی تعامل موس، ترک‌پد، لمس و صفحه‌کلید +- [x] بررسی تم روشن و تاریک و `prefers-reduced-motion` +- [x] بررسی عرض‌های 375، 768، 1024 و 1440 پیکسل + +## نتیجه راستی‌آزمایی + +- نوار دسکتاپ: `44px` +- ورود پنجره‌های سمت چپ: `345ms` +- محل پنجره اعلان در دسکتاپ: `left: 16px` +- opacity داک هنگام هم‌پوشانی: `0.08` +- ارتفاع نوار حالت ایجاد کاربر: `52px` +- کنترل Fullscreen باقی‌مانده: `0` +- دسته‌بندی اعلان باقی‌مانده: `0` +- overflow افقی در viewport تست دسکتاپ: `0` +- ESLint: بدون خطا (هشدارهای غیرمسدودکننده قدیمی باقی است) +- Production build: موفق، ۴۵ مسیر diff --git a/docs/UI_UX_DOCK_SOCIAL_CHECKLIST.md b/docs/UI_UX_DOCK_SOCIAL_CHECKLIST.md new file mode 100644 index 0000000..8f0f3a6 --- /dev/null +++ b/docs/UI_UX_DOCK_SOCIAL_CHECKLIST.md @@ -0,0 +1,31 @@ +# چک‌لیست اصلاح Dock و شبکه سازمانی + +هر مورد فقط پس از مشاهده و تأیید در رابط اجراشده علامت می‌خورد. + +- [x] ۱. خنثی‌سازی رنگ گزینه‌های منوی راست‌کلیک Dock و ایجاد فاصله مستقل بین گزینه‌ها +- [x] ۲. نمایش منوی Dock در مختصات راست‌کلیک با اصلاح موقعیت فقط هنگام برخورد با لبه Viewport +- [x] ۳. بازطراحی شبکه سازمانی با ساختار سه‌ستونه مشابه LinkedIn و حفظ هویت بصری آراد + +## معیارهای پذیرش + +- [x] منوی Dock در حالت برنامه بسته شامل «باز کردن برنامه» و «حذف از داک» است +- [x] منوی Dock در حالت برنامه باز شامل «حذف از داک» و «بستن» است +- [x] هیچ گزینه‌ای در حالت عادی پس‌زمینه سبز یا قرمز ندارد +- [x] فاصله عمودی گزینه‌ها حداقل ۸ پیکسل است +- [x] نقطه شروع منو با مختصات راست‌کلیک منطبق است؛ فقط Overflow لبه‌ها Clamp می‌شود +- [x] شبکه در Desktop سه ستون، در Tablet دو ستون و در Mobile یک ستون دارد +- [x] Feed، ایجاد پست، پروفایل، میانبرها و پیشنهاد ارتباط در UI اجراشده قابل مشاهده‌اند +- [x] Light/Dark، Focus و Responsive در Chrome واقعی بررسی شده‌اند +- [x] TypeScript، ESLint و Build بدون خطا اجرا شده‌اند + +## شواهد اجرای واقعی + +- Dock بسته: گزینه‌ها در `artifacts-dock-menu.png`؛ فاصله اندازه‌گیری‌شده ۸px +- Dock باز: گزینه‌ها در `artifacts-dock-menu-open.png` +- شبکه Desktop: `artifacts-social-desktop.png` +- شبکه Tablet/Dark: `artifacts-social-tablet.png` +- شبکه Mobile/Light: `artifacts-social-mobile.png` +- Focus: حلقه فوکوس ۳px روی تب «فید سازمانی» در `artifacts-social-focus.png` +- `tsc --noEmit`: موفق +- `npm run lint`: بدون Error (۸ Warning قدیمی/غیرمسدودکننده) +- `npm run build`: موفق diff --git a/docs/UI_UX_FIXES_ROUND_2_CHECKLIST.md b/docs/UI_UX_FIXES_ROUND_2_CHECKLIST.md new file mode 100644 index 0000000..f44aa5e --- /dev/null +++ b/docs/UI_UX_FIXES_ROUND_2_CHECKLIST.md @@ -0,0 +1,52 @@ +# چک‌لیست اصلاحات UI/UX — دور دوم + +هر مورد فقط پس از پیاده‌سازی و عبور از بررسی‌های پذیرش علامت می‌خورد. + +- [x] ۱. ادغام کنترل‌های پنجره در هدر و قابل‌جابه‌جایی شدن تمام سطح غیرتعاملی هدر + - کنترل‌های بستن، مینیمایز و ماکسیمایز داخل هدر و در سمت چپ باشند. + - تب‌ها، ورودی‌ها و دکمه‌ها شروع‌کننده Drag نباشند. + - دوبار کلیک روی فضای خالی هدر، وضعیت Maximize را تغییر دهد. +- [x] ۲. ایجاد محدوده امن برای تب‌های شبکه اجتماعی سازمان + - تب‌ها در هیچ عرضی زیر کنترل‌های پنجره قرار نگیرند. +- [x] ۳. کاهش ۲۰ درصدی عناصر بصری نوار بالای دسکتاپ + - اندازه متن، آیکون و فاصله‌ها کاهش یابد؛ محدوده کلیک دسترس‌پذیر باقی بماند. +- [x] ۴. راست‌کلیک مستقل و قابل‌استفاده برای Dock + - منوی دسکتاپ رویداد Dock را دریافت نکند. + - بازکردن، تمرکز، بستن پنجره، بستن همه و Pin/Unpin کار کنند. +- [x] ۵. بازطراحی تقویم مطابق ساختار تصویر مرجع + - نوار ناوبری فشرده، شبکه روزهای تفکیک‌شده و پنل جزئیات روز انتخابی وجود داشته باشد. + - حالت‌های امروز، انتخاب‌شده و دارای رویداد در روز و شب خوانا باشند. +- [x] ۶. اصلاح کامل کنترل شفافیت + - درصد، Track و Thumb همگام باشند و مقدار ۳۵٪ در موقعیت واقعی ۳۵٪ نمایش داده شود. + - چیدمان عنوان، درصد، خورشید و ماه مطابق تصویر مرجع باشد. +- [x] ۷. افزایش خوانایی فیلدهای ساخت دوره + - Label، Border، Placeholder، Focus، Error، Disabled و Select واضح و یکپارچه باشند. +- [x] ۸. یکسان‌سازی تنظیمات سازمان و داشبورد تحلیل با پوسته مشترک پنجره‌ها + - هدر، فاصله‌ها، سطح، Breadcrumb و تب‌ها با سایر ماژول‌ها هماهنگ باشند. +- [x] ۹. فعال‌بودن جابه‌جایی برای همه پنجره‌ها + - تقویم و تنظیمات سامانه قابل حرکت باشند. + - کشیدن پنجره Maximize‌شده باعث Restore و ادامه حرکت شود. +- [x] ۱۰. اصلاح سراسری Popupها و فرم ایجاد رویداد + - Dialog داخل پنجره میزبان بماند، از قاب بیرون نزند و به F11 وابسته نباشد. + - محتوای بلند با چیدمان فشرده یا Wizard مدیریت شود. + - اسکرول‌بار بصری نمایش داده نشود و کنترل‌های نهایی همیشه در دسترس باشند. + +## کنترل کیفیت نهایی + +- [x] بررسی RTL، حالت روز و شب و شفافیت‌های مختلف +- [x] بررسی اندازه‌های مختلف پنجره و Viewport +- [x] بررسی حرکت پنجره، Dock و Dialogها با موس و صفحه‌کلید +- [x] اجرای ESLint بدون خطا +- [x] اجرای Build تولید بدون خطا + +## نتیجه کنترل کیفیت + +- هدر پنجره با ارتفاع ۴۸ پیکسل، سه کنترل داخلی و Drag از سطح خالی هدر در Chrome بررسی شد. +- Restore-on-drag پنجره Maximize‌شده با جابه‌جایی واقعی نشانگر آزمایش شد. +- منوی راست‌کلیک Dock در Portal مستقل باز شد و منوی دسکتاپ هم‌زمان فعال نشد. +- Dialog ایجاد رویداد با `position: absolute` داخل پنجره میزبان قرار گرفت و بدون Overflow عمودی نمایش داده شد. +- Slider شفافیت با بازه ۰ تا ۱۰۰ و مقدار/موقعیت واقعی ۳۵٪ بررسی شد. +- فیلد ساخت دوره با ارتفاع ۴۸ پیکسل، Border یک‌پیکسل و سطح ورودی خوانا در مرورگر اندازه‌گیری شد. +- نمای نهایی تقویم در اندازه ۱۴۴۰×۹۰۰ با Chrome بررسی شد. +- ESLint: صفر خطا؛ ۸ هشدار قدیمی و خارج از محدوده این تغییرات. +- Build تولید: موفق؛ ۴۵ Route بدون خطای TypeScript ساخته شد. diff --git a/docs/UI_UX_FIXES_ROUND_3_CHECKLIST.md b/docs/UI_UX_FIXES_ROUND_3_CHECKLIST.md new file mode 100644 index 0000000..02675ea --- /dev/null +++ b/docs/UI_UX_FIXES_ROUND_3_CHECKLIST.md @@ -0,0 +1,43 @@ +# چک‌لیست اصلاحات UI/UX — دور سوم + +هر مورد فقط پس از پیاده‌سازی و عبور از معیار پذیرش علامت می‌خورد. + +> اصلاح فرایند در ۳۰ ژوئیه ۲۰۲۶: تیک‌های قبلی پس از بازبینی واقعی در Chrome باطل شدند. +> از این مرحله، Build یا وجود کد به‌تنهایی معیار انجام نیست؛ هر مورد باید در UI اجراشده، در وضعیت‌های مرتبط، تأیید و مستند شود. + +- [ ] ۱. اتصال Popup پروفایل به Theme و شفافیت سراسری +- [ ] ۲. ساده‌سازی منوی راست‌کلیک Dock براساس وضعیت برنامه +- [ ] ۳. یکسان‌سازی اندازه و فاصله عمودی دکمه‌های نوار بالا +- [ ] ۴. یکپارچه‌کردن سطح ورودی و قاب Search Box +- [ ] ۵. حذف Border تزئینی آیکون‌های Desktop و Dock +- [ ] ۶. اعمال رنگ معنایی سراسری برای دکمه‌های مثبت، مخرب و خنثی +- [ ] ۷. خوانایی تمام Toggleها در روز، شب و همه سطوح شفافیت +- [ ] ۸. خوانایی Label آیکون‌های Desktop روی Wallpaper روشن و تیره +- [ ] ۹. کاهش حداکثری Scroll صفحات و حذف Scroll تو‌در‌تو +- [ ] ۱۰. افزودن، پیش‌نمایش و حذف تصویر پروفایل کاربر +- [ ] ۱۱. فشرده‌سازی جدول کاربران، حذف Scroll افقی و تفکیک ردیف‌ها +- [ ] ۱۲. کاهش حدود ۴۰ درصدی ابعاد کارت دوره با حفظ محتوا +- [ ] ۱۳. افزودن Wizard ورود SCORM، xAPI/cmi5، HTML5 ZIP و محتوای رایج +- [ ] ۱۴. جایگزینی ورودی نوشتنی با Dropdown/Combobox در انتخاب‌های محدود +- [ ] ۱۵. بازچینی منسجم ارزیابی، مخاطب و دسترسی +- [ ] ۱۶. افزودن Tag Input واقعی برای دوره‌ها +- [ ] ۱۷. افزودن موجودیت و مدیریت دسته‌بندی دوره‌ها +- [ ] ۱۸. ثبت دسته‌های عمومی، تخصصی، مهارت‌های نرم و مدیریتی و ایجاد دسته جدید +- [ ] ۱۹. کاهش ۴۰ درصدی اندازه بصری آیکون‌های داخل پنجره‌ها با حفظ Hit Area + +## کنترل کیفیت نهایی + +- [ ] بررسی Light/Dark و شفافیت‌های ۰، ۳۵، ۷۲ و ۱۰۰ درصد +- [ ] بررسی Wallpaper روشن و تیره +- [ ] بررسی Desktop، Tablet و Mobile +- [ ] بررسی صفحه‌کلید، Focus و خوانایی کنترل‌ها +- [ ] اجرای ESLint بدون خطا +- [ ] اجرای Build تولید بدون خطا + +## شواهد الزامی برای هر تیک + +- اسکرین‌شات قبل و بعد در اندازه ۱۶۰۰×۹۰۰ +- اندازه واقعی DOM برای موارد ابعادی +- آزمایش تعامل واقعی برای Dock، Search، Toggle، Upload و Wizard +- آزمایش Light/Dark و حداقل دو سطح شفافیت برای اجزای وابسته به Theme +- ثبت نتیجه در همین فایل؛ بدون شاهد، آیتم باز می‌ماند diff --git a/docs/UI_UX_IMPROVEMENT_CHECKLIST.md b/docs/UI_UX_IMPROVEMENT_CHECKLIST.md new file mode 100644 index 0000000..c655aba --- /dev/null +++ b/docs/UI_UX_IMPROVEMENT_CHECKLIST.md @@ -0,0 +1,101 @@ +# چک‌لیست بهبود UI/UX سامانه ARAD LMS OS + +آخرین به‌روزرسانی: ۱۴۰۵/۰۵/۰۸ + +## قواعد اجرا + +- هر آیتم فقط بعد از پیاده‌سازی، بررسی کد و تست مرتبط تیک می‌خورد. +- تغییرات باید در نمایش فارسی RTL، حالت روشن و تیره، و عرض‌های ۳۷۵، ۷۶۸ و ۱۴۴۰ پیکسل قابل استفاده باشند. +- کنترل‌های تعاملی باید با صفحه‌کلید و لمس قابل استفاده باشند. +- پایان کار منوط به موفقیت `lint`، `build` و بررسی بصری صفحات اصلی است. + +## فاز ۱ — بحرانی + +- [x] ۱. رفع شکست ریسپانسیو صفحه ورود و راهنمای شروع + - جلوگیری از overflow افقی در عرض ۳۲۰ تا ۳۹۰ پیکسل + - چیدمان تطبیقی دکمه‌ها و فیلدهای flex + +- [x] ۲. ایجاد تجربه تمام‌صفحه و بدون پنجره draggable برای ماژول‌ها در موبایل + - نوار بالای موبایل با بازگشت، عنوان و عملیات + - حذف محدودیت حداقل عرض ۴۲۰ پیکسل در موبایل + +- [x] ۳. افزایش کنتراست ورود و سطوح شیشه‌ای + - خوانایی مستقل از wallpaper + - استفاده از scrim و surface با کنتراست پایدار + +- [x] ۴. رساندن اهداف لمسی اصلی به حداقل ۴۴×۴۴ پیکسل + - دکمه‌های آیکونی، کنترل پنجره، Dock و نوار ابزار + +- [x] ۵. استانداردسازی مودال‌ها و Drawerها + - `role="dialog"`، `aria-modal`، نام قابل‌دسترسی و دکمه بستن + - focus trap، Escape و بازگردانی focus + +- [x] ۶. قابل‌دسترسی کردن پیام‌های خطا، موفقیت، loading و toast + - `aria-live` و `role="alert"` + - پیام دارای متن، آیکون و مسیر بازیابی + +## فاز ۲ — اولویت بالا + +- [x] ۷. کاهش اتکای وظایف اصلی به double-click، right-click و drag + - باز شدن با یک کلیک + - جایگزین قابل مشاهده و قابل استفاده با صفحه‌کلید + +- [x] ۸. همگام‌سازی ماژول فعال با URL و رفتار Back + - deep link برای برنامه‌ها + - حفظ مسیر قابل پیش‌بینی هنگام بستن یا بازگشت + +- [x] ۹. تطبیقی کردن جدول‌های عریض + - کارت موبایل یا نمایش خلاصه + - caption، سرستون مناسب، عملیات قابل دسترس و نمایش دسکتاپ بدون افت + +- [x] ۱۰. بهبود فرم کامل ایجاد کاربر + - progressive disclosure + - اعتبارسنجی نزدیک فیلد، تمرکز روی اولین خطا و ذخیره draft + +- [x] ۱۱. بهبود Wizard ساخت دوره + - وضعیت تکمیل مراحل، پیشرفت موبایل و جلوگیری از انتشار ناقص + - بازخورد ذخیره‌سازی و آخرین زمان ذخیره + +- [x] ۱۲. قابل‌اسکرول و دسته‌بندی کردن صفحه خانه موبایل + - فضای امن برای Dock و safe area + - جستجو و grid تطبیقی + +## فاز ۳ — کیفیت و یکپارچگی + +- [x] ۱۳. اصلاح مقیاس تایپوگرافی و خوانایی متن فارسی + - حداقل متن بدنه ۱۴ پیکسل و ورودی موبایل ۱۶ پیکسل + - line-height و اعداد tabular + +- [x] ۱۴. یکپارچه‌سازی معنای رنگ و سلسله‌مراتب دکمه‌ها + - یک CTA اصلی در هر سطح + - danger فقط برای عملیات مخرب + +- [x] ۱۵. پشتیبانی کامل از `prefers-reduced-motion` + - حذف حرکت‌های غیرضروری و scale شدید + +- [x] ۱۶. کاهش Glassmorphism تو‌در‌تو و هزینه blur + - surface مات برای محتوای داخلی + - blur قوی فقط برای لایه‌های شناور اصلی + +- [x] ۱۷. اصلاح ورودی‌های RTL/LTR و کیبورد موبایل + - email، URL و کدها با جهت، `inputMode` و `autocomplete` صحیح + - نمایش/مخفی‌سازی رمز عبور + +## کنترل نهایی + +- [x] `npm run lint` — بدون خطا؛ ۹ هشدار غیرمسدودکننده موجود +- [x] `npm run build` — build تولیدی و ۴۵ مسیر با موفقیت +- [x] تست بصری صفحه ورود در ۳۷۵، ۷۶۸ و ۱۴۴۰ پیکسل +- [x] تست بصری صفحه خانه و باز کردن ماژول در موبایل +- [x] تست صفحه‌کلید، Escape، focus و reduced motion +- [x] تأیید نبود overflow افقی ناخواسته + +## شواهد کنترل نهایی + +- viewport واقعی ۳۷۵px: + - `/login`: `clientWidth=375` و `scrollWidth=375` + - `/dashboard`: `clientWidth=375` و `scrollWidth=375` + - `/apps/catalog`: `clientWidth=375` و `scrollWidth=375` +- `prefers-reduced-motion: reduce` در هر سه مسیر شناسایی و اعمال شد. +- dialog تنظیمات با `role="dialog"` و `aria-modal="true"` باز شد، focus داخل dialog قرار گرفت و با Escape بسته شد. +- باز کردن برنامه در دسکتاپ URL را به `/dashboard?app=course-builder` تغییر داد و Back مسیر را به `/dashboard` بازگرداند. diff --git a/docs/UI_UX_WINDOW_CHROME_EXAMS_CHECKLIST.md b/docs/UI_UX_WINDOW_CHROME_EXAMS_CHECKLIST.md new file mode 100644 index 0000000..57bc113 --- /dev/null +++ b/docs/UI_UX_WINDOW_CHROME_EXAMS_CHECKLIST.md @@ -0,0 +1,32 @@ +# چک‌لیست یکپارچه‌سازی پنجره‌ها و آزمون‌ها + +هر مورد اصلی پس از پیاده‌سازی و عبور از کنترل فنی علامت می‌خورد. معیارهای پذیرش پایین، وضعیت تست‌های جزئی را جداگانه و بدون حدس نشان می‌دهند. + +- [x] ۱. حذف پیام ثابت «دوره جدید به دوره‌های من اضافه شد» +- [x] ۲. حذف نوار عنوان مستقل پنجره و انتقال کنترل‌ها به فضای هدر داخلی +- [x] ۳. یکسان‌سازی صفحه آزمون‌ها با ساختار Explorer سایر صفحات +- [x] ۴. اعمال اندازه اولیه ۲۲×۱۴ سانتی‌متر معادل 832×529px برای پنجره‌های جدید +- [x] ۵. ایجاد وضعیت رنگی، واضح و دسترس‌پذیر برای تب فعال + +## معیارهای پذیرش + +- [x] پیام ثابت دوره‌های من در DOM یا نمای اجراشده وجود ندارد +- [x] عنوان تکراری و ردیف ۴۸ پیکسلی مستقل بالای پنجره حذف شده است +- [x] کنترل‌های بستن، کوچک‌کردن و بزرگ‌کردن داخل محدوده هدر محتوایی دیده می‌شوند +- [ ] هدر مشترک همچنان ناحیه Drag دارد و کنترل‌ها/تب‌ها Drag را آغاز نمی‌کنند +- [x] نمای مدیریتی و کاربری آزمون‌ها از Chrome مشترک و تب‌های یکسان استفاده می‌کنند +- [x] پنجره جدید در نمایشگر کافی با ابعاد 832×529px باز می‌شود +- [x] ابعاد پنجره روی Viewport کوچک Clamp می‌شود و از صفحه بیرون نمی‌زند +- [x] تب فعال علاوه بر رنگ، با Ring/Indicator و `aria-selected` مشخص است +- [ ] Light، Dark و Focus در Chrome واقعی بررسی شده‌اند +- [x] TypeScript، ESLint و Build بدون Error اجرا شده‌اند + +## شواهد اجرای فعلی + +- Chrome، نقش «کارکنان»، پنجره «دوره‌های من»: `832×529px` +- Chrome: `hasLegacyTitleBar = false` و `hasRemovedMessage = false` +- Chrome: کنترل‌ها داخل همان محدوده هدر و تب فعال با رنگ، Ring و `aria-selected=true` +- TypeScript: `npx tsc --noEmit` بدون خطا +- ESLint: فایل‌های همین تغییر بدون خطا +- Build: `next build` موفق؛ هشدارهای موجود مربوط به فایل‌های خارج از این تغییر است +- تست بصری صفحه آزمون، Dark/Focus و تأیید نهایی Drag به‌علت توقف ابزار Chrome در سقف مصرف این نشست باز مانده‌اند و عمداً تیک نخورده‌اند diff --git a/docs/ui-system-checklist.md b/docs/ui-system-checklist.md new file mode 100644 index 0000000..dd1e32f --- /dev/null +++ b/docs/ui-system-checklist.md @@ -0,0 +1,22 @@ +# ARAD LMS OS — UI, Appearance and Licensing Checklist + +An item is checked only after implementation, migration and verification are complete. + +- [x] Audit existing icon, toggle, appearance, window and plan implementations. +- [x] Establish semantic design tokens and global appearance/theme contracts. +- [x] Replace every switch/toggle implementation with the shared Switch components. +- [x] Standardize explorer icons and migrate Exams and Roles & Access. +- [x] Make every system surface consume the global transparency setting. +- [x] Add system/light/dark modes and Windows-like accent themes. +- [x] Migrate feature UI to shared primitives, components and patterns. +- [x] Replace plan/subscription gating with an annual organization license. +- [x] Run source audit, accessibility checks, interaction/visual tests, TypeScript, lint and production build. + +## Contracts + +- Feature code owns domain data and workflows; the design system owns visual primitives. +- Feature modules must not implement their own switch, explorer icon tile, window shell or color palette. +- Explorer tiles use a 48px container and 24px Lucide glyph. Action glyphs use semantic 16/20/24px sizes. +- Transparency changes surface alpha and blur, never the opacity of text or interactive content. +- Accent colors are semantic tokens and must retain readable focus, selection and status states. +- License access is resolved by one entitlement service; feature components do not inspect dates or license status directly. diff --git a/public/arad-logo-dark.png b/public/arad-logo-dark.png new file mode 100644 index 0000000..d1d3658 Binary files /dev/null and b/public/arad-logo-dark.png differ diff --git a/public/arad-logo-light.png b/public/arad-logo-light.png new file mode 100644 index 0000000..5d33f7b Binary files /dev/null and b/public/arad-logo-light.png differ diff --git a/scripts/ui-audit-cdp.mjs b/scripts/ui-audit-cdp.mjs new file mode 100644 index 0000000..6b62282 --- /dev/null +++ b/scripts/ui-audit-cdp.mjs @@ -0,0 +1,425 @@ +import { writeFile } from "node:fs/promises"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const targets = await fetch("http://127.0.0.1:9222/json").then((response) => response.json()); +const target = targets.find((item) => item.type === "page" && item.url.startsWith("http://127.0.0.1:3000")); +if (!target) throw new Error("Local application tab was not found."); + +const socket = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); +}); + +let id = 0; +const pending = new Map(); +socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id || !pending.has(message.id)) return; + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result); +}); + +function send(method, params = {}) { + const messageId = ++id; + socket.send(JSON.stringify({ id: messageId, method, params })); + return new Promise((resolve, reject) => pending.set(messageId, { resolve, reject })); +} + +async function evaluate(expression) { + const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; +} + +await send("Page.enable"); +await send("Runtime.enable"); +await sleep(1500); + +const action = process.argv[2] ?? "status"; +const requestedViewport = process.argv[4]?.match(/^(\d+)x(\d+)$/); +if (requestedViewport) { + await send("Emulation.setDeviceMetricsOverride", { + width: Number(requestedViewport[1]), + height: Number(requestedViewport[2]), + deviceScaleFactor: 1, + mobile: Number(requestedViewport[1]) < 720, + }); + await send("Page.reload", { ignoreCache: true }); + await sleep(2200); +} +if (action === "go-login") { + await evaluate(`(() => { location.href = "/login"; return true; })()`); + await sleep(2200); +} +if (action === "login") { + await evaluate(`(() => { + const button = [...document.querySelectorAll("button")].find((item) => item.textContent.trim() === "ورود"); + if (!button) throw new Error("Login button not found"); + button.click(); + return true; + })()`); + await sleep(5000); +} + +if (action === "profile") { + await evaluate(`(() => { + const openDialogClose = [...document.querySelectorAll("button")].find((item) => (item.getAttribute("aria-label") || "").startsWith("بستن تغییر کاربر")); + if (openDialogClose) openDialogClose.click(); + const button = [...document.querySelectorAll("button")].find((item) => (item.title || "").includes(" - ") && !(item.title || "").includes("تغییر")); + if (!button) throw new Error("Profile button not found"); + button.click(); + return true; + })()`); + await sleep(1200); +} + +if (action === "dark-mode") { + await evaluate(`(() => { + const trigger = [...document.querySelectorAll("button")].find((item) => item.getAttribute("aria-label") === "تنظیمات سریع" || item.title === "تنظیمات سریع"); + if (!trigger) throw new Error("Quick settings trigger not found"); + trigger.click(); + return true; + })()`); + await sleep(700); + await evaluate(`(() => { + const dialog = document.querySelector("[role='dialog']") || document; + const button = [...dialog.querySelectorAll("button")].find((item) => item.textContent.trim() === "تاریک"); + if (!button) throw new Error("Dark theme button not found"); + button.click(); + return true; + })()`); + await sleep(1200); +} + +if (action === "close-dialog") { + await evaluate(`(() => { + const button = [...document.querySelectorAll("button")].find((item) => (item.getAttribute("aria-label") || "").startsWith("بستن ")); + if (!button) return false; + button.click(); + return true; + })()`); + await sleep(800); +} + +if (action === "dismiss-tour") { + await evaluate(`(() => { + const button = [...document.querySelectorAll("button")].find((item) => item.textContent.includes("دیگر نمایش نده")); + if (!button) return false; + button.click(); + return true; + })()`); + await sleep(800); +} + +if (action === "open-control-center") { + await evaluate(`(() => { + const tourButton = [...document.querySelectorAll("button")].find((item) => item.textContent.includes("دیگر نمایش نده")); + if (tourButton) tourButton.click(); + const trigger = [...document.querySelectorAll("button")].find((item) => item.getAttribute("aria-label") === "تنظیمات سریع" || item.title === "تنظیمات سریع"); + if (!trigger) throw new Error("Quick settings trigger not found"); + trigger.click(); + return true; + })()`); + await sleep(900); +} + +if (action === "toggle-workspace") { + await evaluate(`(() => { + const tourButton = [...document.querySelectorAll("button")].find((item) => item.textContent.includes("دیگر نمایش نده")); + if (tourButton) tourButton.click(); + const button = document.querySelector("[data-dock-workspace-toggle]"); + if (!button) throw new Error("Dock workspace toggle not found"); + button.click(); + return true; + })()`); + await sleep(1200); +} + +if (action === "switch-employee") { + await evaluate(`(() => { + const switcher = [...document.querySelectorAll("button")].find((item) => item.title === "تغییر کاربر تستی"); + if (!switcher) throw new Error("Test user switcher not found"); + switcher.click(); + return true; + })()`); + await sleep(800); + await evaluate(`(() => { + const dialog = document.querySelector("[role='dialog']"); + const button = [...(dialog?.querySelectorAll("button") ?? [])].find((item) => item.textContent.includes("کارکنان")); + if (!button) throw new Error("Employee test account not found"); + button.click(); + return true; + })()`); + await sleep(2600); +} + +if (action === "open-exams" || action === "open-my-courses") { + const appTitle = action === "open-exams" ? "آزمون‌ها" : "دوره‌های من"; + await evaluate(`(() => { + const title = ${JSON.stringify(appTitle)}; + const button = [...document.querySelectorAll("button")].find((item) => item.title === title && !item.closest("[data-window-id]")) + || [...document.querySelectorAll("button")].find((item) => item.textContent.trim() === title && !item.closest("[data-window-id]")); + if (!button) throw new Error(title + " app button not found"); + button.click(); + return true; + })()`); + await sleep(2200); +} + +if (action === "open-launcher") { + await evaluate(`(() => { + const button = [...document.querySelectorAll("button")].find((item) => item.getAttribute("aria-label") === "نمایش برنامه‌ها"); + if (!button) throw new Error("Launcher switch not found"); + button.click(); + return true; + })()`); + await sleep(900); +} + +if (action === "close-windows-and-open-launcher") { + await evaluate(`(() => { + const closeButtons = [...document.querySelectorAll("[data-window-id] button")].filter((item) => { + const label = item.getAttribute("aria-label") || item.title || ""; + return label.includes("بستن"); + }); + closeButtons.forEach((button) => button.click()); + return closeButtons.length; + })()`); + await sleep(900); + await evaluate(`(() => { + const button = [...document.querySelectorAll("button")].find((item) => item.getAttribute("aria-label") === "نمایش برنامه‌ها"); + if (!button) throw new Error("Launcher switch not found"); + button.click(); + return true; + })()`); + await sleep(900); +} + +if (action === "open-exam-tab") { + await evaluate(`(() => { + const windowItem = [...document.querySelectorAll("[data-window-id]")].at(-1); + const button = [...(windowItem?.querySelectorAll("main button") ?? [])].find((item) => item.textContent.trim().startsWith("آزمون‌های من")); + if (!button) throw new Error("Learner exam tile not found"); + button.click(); + return true; + })()`); + await sleep(1000); +} + +if (action === "drag-window") { + const point = await evaluate(`(() => { + const item = [...document.querySelectorAll("[data-window-id]")].at(-1); + const toolbar = item?.querySelector(".window-module-toolbar"); + if (!item || !toolbar) throw new Error("Draggable window toolbar not found"); + const box = toolbar.getBoundingClientRect(); + const point = { x: box.left + box.width * 0.48, y: box.top + 18 }; + const target = document.elementFromPoint(point.x, point.y); + window.__auditDragTarget = { + point, + tag: target?.tagName, + className: String(target?.className ?? ""), + cancel: Boolean(target?.closest(".mac-window-no-drag,button,input,select,textarea,a,[role='button']")), + handle: Boolean(target?.closest(".mac-window-handle")), + }; + return point; + })()`); + await send("Input.dispatchMouseEvent", { type: "mousePressed", button: "left", buttons: 1, clickCount: 1, x: point.x, y: point.y }); + await sleep(120); + await send("Input.dispatchMouseEvent", { type: "mouseMoved", button: "left", buttons: 1, x: point.x + 24, y: point.y + 16 }); + await sleep(120); + await send("Input.dispatchMouseEvent", { type: "mouseMoved", button: "left", buttons: 1, x: point.x + 64, y: point.y + 42 }); + await sleep(120); + await send("Input.dispatchMouseEvent", { type: "mouseReleased", button: "left", buttons: 0, clickCount: 1, x: point.x + 64, y: point.y + 42 }); + await sleep(900); +} + +if (action === "open-users") { + await evaluate(`(() => { + const profileClose = [...document.querySelectorAll("button")].find((item) => item.textContent.trim() === "بستن"); + if (profileClose) profileClose.click(); + const button = [...document.querySelectorAll("button")].find((item) => item.title === "کاربران سازمان"); + if (!button) throw new Error("Users app button not found"); + button.click(); + return true; + })()`); + await sleep(1800); +} + +if (action === "open-social") { + await evaluate(`(() => { + if (innerWidth < 720) { + location.href = "/apps/social"; + return true; + } + const profileClose = [...document.querySelectorAll("button")].find((item) => item.textContent.trim() === "بستن"); + if (profileClose) profileClose.click(); + const button = [...document.querySelectorAll("button")].find((item) => item.title === "شبکه سازمانی" || item.textContent.trim() === "شبکه سازمانی"); + if (!button) throw new Error("Social app button not found"); + button.click(); + return true; + })()`); + await sleep(2200); +} + +if (action === "dock-menu") { + const point = await evaluate(`(() => { + const item = document.querySelector("[data-dock-app-id]"); + if (!item) throw new Error("Dock item not found"); + const box = item.getBoundingClientRect(); + const point = { x: box.left + box.width / 2, y: box.top + box.height / 2 }; + window.__auditDockClick = point; + item.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + button: 2, + buttons: 2, + clientX: point.x, + clientY: point.y, + view: window, + })); + return point; + })()`); + await sleep(700); +} + +if (action === "dock-menu-open") { + const isDashboard = await evaluate(`location.pathname === "/dashboard"`); + if (!isDashboard) { + await evaluate(`(() => { location.href = "/dashboard"; return true; })()`); + await sleep(2600); + } + await evaluate(`(() => { + const item = document.querySelector("[data-dock-app-id]"); + const button = item?.querySelector("button"); + if (!item || !button) throw new Error("Dock item not found"); + button.click(); + return true; + })()`); + await sleep(1500); + await evaluate(`(() => { + const item = document.querySelector("[data-dock-app-id]"); + if (!item) throw new Error("Dock item not found"); + const box = item.getBoundingClientRect(); + const point = { x: box.left + box.width / 2, y: box.top + box.height / 2 }; + window.__auditDockClick = point; + item.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + button: 2, + buttons: 2, + clientX: point.x, + clientY: point.y, + view: window, + })); + return true; + })()`); + await sleep(700); +} + +if (action === "focus-check") { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }); + await evaluate(`(() => { + const target = [...document.querySelectorAll("button")].find((item) => item.textContent.trim() === "فید سازمانی") + || document.querySelector("button"); + if (!target) throw new Error("Focusable control not found"); + target.focus(); + return target === document.activeElement; + })()`); + await sleep(350); +} + +const state = await evaluate(`(() => ({ + url: location.href, + title: document.title, + body: document.body.innerText.slice(0, 1200), + buttons: [...document.querySelectorAll("button")].slice(0, 80).map((item) => ({ text: item.innerText.trim(), title: item.title, aria: item.getAttribute("aria-label"), box: item.getBoundingClientRect().toJSON() })), + localStorage: Object.keys(localStorage), + viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio }, + ranges: [...document.querySelectorAll('input[type="range"]')].map((item) => ({ + aria: item.getAttribute("aria-label"), + min: item.min, + max: item.max, + value: item.value, + rangeValue: getComputedStyle(item).getPropertyValue("--range-value").trim(), + box: item.getBoundingClientRect().toJSON(), + })), + focus: (() => { + const item = document.activeElement; + if (!item || item === document.body) return null; + const styles = getComputedStyle(item); + return { + tag: item.tagName, + text: item.textContent?.trim() || "", + aria: item.getAttribute("aria-label"), + outline: styles.outline, + boxShadow: styles.boxShadow, + box: item.getBoundingClientRect().toJSON(), + }; + })(), + dockMenu: (() => { + const panel = document.querySelector("[data-dock-context-menu]"); + if (!panel) return null; + const styles = getComputedStyle(panel); + return { + click: window.__auditDockClick || null, + box: panel.getBoundingClientRect().toJSON(), + rowGap: styles.rowGap, + items: [...panel.querySelectorAll("button")].map((item) => { + const itemStyles = getComputedStyle(item); + return { + text: item.innerText.trim(), + background: itemStyles.backgroundColor, + border: itemStyles.border, + color: itemStyles.color, + box: item.getBoundingClientRect().toJSON(), + }; + }), + }; + })(), + dragTarget: window.__auditDragTarget ?? null, + windows: [...document.querySelectorAll("[data-window-id]")].map((item) => { + const box = item.getBoundingClientRect(); + const controls = item.querySelector("[data-window-controls]"); + const toolbar = item.querySelector(".window-module-toolbar"); + const selectedTabs = [...item.querySelectorAll("[role='tab'][aria-selected='true']")]; + return { + id: item.getAttribute("data-window-id"), + box: box.toJSON(), + controls: controls?.getBoundingClientRect().toJSON() ?? null, + toolbar: toolbar?.getBoundingClientRect().toJSON() ?? null, + selectedTabs: selectedTabs.map((tab) => { + const styles = getComputedStyle(tab); + return { + text: tab.textContent.trim(), + color: styles.color, + background: styles.backgroundColor, + boxShadow: styles.boxShadow, + box: tab.getBoundingClientRect().toJSON(), + }; + }), + hasLegacyTitleBar: [...item.children].some((child) => child.tagName === "HEADER"), + hasRemovedMessage: item.textContent.includes("دوره جدید به دوره‌های من اضافه شد."), + }; + }), + iconTiles: [...document.querySelectorAll("[data-app-icon-tile]")].slice(0, 40).map((item) => { + const styles = getComputedStyle(item); + const button = item.closest("button"); + return { + box: item.getBoundingClientRect().toJSON(), + buttonBox: button?.getBoundingClientRect().toJSON() ?? null, + filter: styles.filter, + backdropFilter: styles.backdropFilter, + backgroundImage: styles.backgroundImage, + }; + }) +}))()`); + +const screenshot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }); +const fileName = process.argv[3] ?? "ui-audit.png"; +await writeFile(fileName, Buffer.from(screenshot.data, "base64")); +console.log(JSON.stringify({ ...state, screenshot: fileName }, null, 2)); +socket.close(); diff --git a/src/app/apps/[appId]/page.tsx b/src/app/apps/[appId]/page.tsx index c069905..381392c 100644 --- a/src/app/apps/[appId]/page.tsx +++ b/src/app/apps/[appId]/page.tsx @@ -1,42 +1,55 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { homeAppsConfig, isAppDisabledByPlan, type UserRole } from "@/config/homeAppsConfig"; +import { notFound, useParams, useRouter } from "next/navigation"; +import { ArrowRight } from "@/components/shared/ui-icons"; +import { homeAppsConfig, type UserRole } from "@/config/homeAppsConfig"; import { getCurrentUser } from "@/services/api/lmsApi"; import { MacModuleContent } from "@/components/mac/MacModuleContent"; +import { getDesktopState, type DesktopState } from "@/lib/macDesktopStorage"; +import { AppearanceProvider } from "@/components/shared/AppearanceProvider"; +import { FeatureShell } from "@/components/shared/FeatureShell"; +import { LicenseAccessProvider } from "@/components/licenses/LicenseAccessProvider"; +import { licenseService } from "@/services/licenses/licenseService"; +import type { LicenseAccess } from "@/types/license"; export default function AppRoutePage() { const params = useParams<{ appId: string }>(); + const router = useRouter(); const appId = params.appId; const [role, setRole] = useState(null); + const [appearance, setAppearance] = useState(null); + const [licenseAccess, setLicenseAccess] = useState(null); + const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light"); useEffect(() => { - getCurrentUser().then((user) => setRole(user.role)); + getCurrentUser().then((user) => { setRole(user.role); setAppearance(getDesktopState(user.id, user.role)); setLicenseAccess(user.role === "super_admin" ? { status: "active", mode: "full" } : licenseService.accessForOrganization(user.organizationFa)); }); + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const sync = () => setSystemTheme(media.matches ? "dark" : "light"); + sync(); + media.addEventListener("change", sync); + return () => media.removeEventListener("change", sync); }, []); - if (!role) return
در حال بارگذاری...
; + if (!role || !appearance || !licenseAccess) return
در حال بارگذاری...
; const app = homeAppsConfig.find((item) => item.id === appId); - if (!app || !app.roleAccess.includes(role)) return ; - if (isAppDisabledByPlan(role, appId)) return ; + if (!app) notFound(); + if (!app.roleAccess.includes(role)) return ; + const resolvedTheme = appearance.theme === "system" ? systemTheme : appearance.theme; return ( -
-
- +
+
+ +
+

سامانه آموزش سازمانی آراد

+

{app.labelFa}

+
+
+
+
-
- ); -} - -function DisabledByPlanPage() { - return ( -
-
-

این بخش در طرح فعلی سازمان فعال نیست.

-

برای فعال‌سازی، با مدیر آموزش سازمان یا پشتیبانی سامانه تماس بگیرید.

-
-
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index cedfc00..548caf8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -7,10 +7,10 @@ --font-vazirmatn: Vazirmatn, Tahoma, sans-serif; --color-bg: #eef4fb; --color-bg-soft: #f8fafc; - --color-surface: rgba(255, 255, 255, 0.78); - --color-surface-strong: rgba(255, 255, 255, 0.92); - --color-card: rgba(255, 255, 255, 0.72); - --color-elevated: rgba(255, 255, 255, 0.9); + --color-surface: rgba(255, 255, 255, 0.9); + --color-surface-strong: rgba(255, 255, 255, 0.96); + --color-card: rgba(255, 255, 255, 0.88); + --color-elevated: rgba(255, 255, 255, 0.96); --color-text: #0f172a; --color-text-secondary: #475569; --color-text-muted: #64748b; @@ -29,8 +29,10 @@ --color-table-header: rgba(226, 232, 240, 0.7); --color-table-row-hover: rgba(14, 165, 233, 0.1); --color-selected: rgba(14, 165, 233, 0.16); + --color-hover: rgba(14, 165, 233, 0.1); --color-focus-ring: rgba(14, 165, 233, 0.32); --color-overlay: rgba(15, 23, 42, 0.28); + --color-overlay-strong: rgba(15, 23, 42, 0.56); --shadow-soft: 0 18px 54px rgba(15, 23, 42, 0.14); --shadow-elevated: 0 28px 90px rgba(15, 23, 42, 0.18); --glass-opacity: 0.72; @@ -38,16 +40,35 @@ --glass-light-alpha-soft: calc(0.18 + (var(--glass-opacity) * 0.2)); --glass-dark-alpha-strong: calc(var(--glass-opacity) * 0.86); --glass-dark-alpha-soft: calc(var(--glass-opacity) * 0.42); + --color-brand-600: #079bb8; + --color-brand-500: #19b7d1; + --color-brand-400: #36beea; + --color-indigo: #4666db; + --color-violet: #7256e8; + --color-purple: #9333ea; + --color-green: #19ae83; + --color-amber: #f59c35; + --color-coral: #f15f6d; + --color-blue: #2684ff; + --color-cyan: #16b8c8; + --color-muted: rgba(148, 163, 184, 0.28); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; + --radius-xl: 24px; + --radius-modal: 28px; + --shadow-card: 0 8px 30px rgba(31, 50, 81, 0.08); + --shadow-floating: 0 18px 60px rgba(31, 50, 81, 0.16); } .dark { color-scheme: dark; --color-bg: #07111f; --color-bg-soft: #0f172a; - --color-surface: rgba(15, 23, 42, 0.78); - --color-surface-strong: rgba(15, 23, 42, 0.92); - --color-card: rgba(30, 41, 59, 0.66); - --color-elevated: rgba(15, 23, 42, 0.9); + --color-surface: rgba(15, 23, 42, 0.9); + --color-surface-strong: rgba(15, 23, 42, 0.96); + --color-card: rgba(30, 41, 59, 0.88); + --color-elevated: rgba(15, 23, 42, 0.96); --color-text: #f8fafc; --color-text-secondary: #cbd5e1; --color-text-muted: #94a3b8; @@ -66,10 +87,112 @@ --color-table-header: rgba(30, 41, 59, 0.82); --color-table-row-hover: rgba(34, 211, 238, 0.1); --color-selected: rgba(34, 211, 238, 0.16); + --color-hover: rgba(34, 211, 238, 0.1); --color-focus-ring: rgba(34, 211, 238, 0.32); --color-overlay: rgba(2, 6, 23, 0.58); + --color-overlay-strong: rgba(2, 6, 23, 0.72); --shadow-soft: 0 22px 70px rgba(0, 0, 0, 0.36); --shadow-elevated: 0 30px 100px rgba(0, 0, 0, 0.48); + --color-muted: rgba(148, 163, 184, 0.18); + --shadow-card: 0 10px 34px rgba(0, 0, 0, 0.24); + --shadow-floating: 0 22px 68px rgba(0, 0, 0, 0.38); +} + +.learning-workspace { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--color-accent) 36%, transparent) transparent; + background: + radial-gradient(circle at 18% 12%, color-mix(in srgb, var(--color-violet) 10%, transparent), transparent 27rem), + radial-gradient(circle at 84% 18%, color-mix(in srgb, var(--color-cyan) 12%, transparent), transparent 30rem); +} + +.content-surface { + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + color: var(--color-text); + background: color-mix(in srgb, var(--color-surface-strong) 94%, transparent); + box-shadow: var(--shadow-card); +} + +.section-heading { display: flex; align-items: center; gap: .55rem; } +.section-heading h2 { font-size: 1rem; font-weight: 900; } + +.workspace-hero { + position: relative; + border: 1px solid rgba(255,255,255,.48); + border-radius: var(--radius-xl); + background: + radial-gradient(circle at 74% 18%, rgba(255,255,255,.28), transparent 19rem), + radial-gradient(circle at 74% 100%, rgba(244,114,182,.38), transparent 20rem), + linear-gradient(118deg, #17b9d2 0%, #3187ed 43%, #7654e8 72%, #b454e7 100%); + box-shadow: 0 18px 50px rgba(71, 85, 199, .22); +} +.workspace-hero::after { position: absolute; inset: auto -8% -75% 42%; height: 120%; border: 2px solid rgba(255,255,255,.18); border-radius: 50%; content: ""; transform: rotate(-12deg); } +.hero-primary,.hero-secondary { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; gap: .5rem; border-radius: 14px; padding: .65rem 1rem; font-size: .875rem; font-weight: 800; } +.hero-primary { color: #334155; background: #fff; box-shadow: 0 8px 24px rgba(15,23,42,.14); } +.hero-secondary { border: 1px solid rgba(255,255,255,.36); color: #fff; background: rgba(255,255,255,.14); } +.hero-stat { display: grid; min-height: 100px; place-items: center; gap: .2rem; border: 1px solid rgba(255,255,255,.28); border-radius: 18px; padding: .65rem; color: #fff; background: rgba(255,255,255,.14); backdrop-filter: blur(12px); } +.hero-stat span { font-size: .72rem; color: rgba(255,255,255,.8); } +.hero-stat strong { font-size: .95rem; } +.hero-progress { border: 1px solid rgba(255,255,255,.28); border-radius: 16px; padding: .75rem; color: #fff; background: rgba(255,255,255,.13); } +.hero-progress span,.hero-progress b { font-size: .75rem; } +.hero-progress b { float: left; } +.hero-progress div { height: 6px; margin-top: .55rem; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.22); } +.hero-progress i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#fff,#67e8f9); } + +.metric-tile { display: flex; min-height: 92px; flex-direction: column; justify-content: space-between; border: 1px solid var(--color-border); border-radius: 16px; padding: .8rem; background: var(--color-card); } +.metric-tile span { font-size: .72rem; color: var(--color-text-secondary); } +.metric-tile strong { font-size: 1.25rem; } +.metric-tile-1 { border-color: color-mix(in srgb,var(--color-violet) 26%,var(--color-border)); background: color-mix(in srgb,var(--color-violet) 7%,var(--color-card)); } +.metric-tile-2 { border-color: color-mix(in srgb,var(--color-amber) 30%,var(--color-border)); background: color-mix(in srgb,var(--color-amber) 8%,var(--color-card)); } +.metric-tile-3 { border-color: color-mix(in srgb,var(--color-green) 28%,var(--color-border)); background: color-mix(in srgb,var(--color-green) 7%,var(--color-card)); } +.metric-tile-4 { border-color: color-mix(in srgb,var(--color-blue) 26%,var(--color-border)); background: color-mix(in srgb,var(--color-blue) 7%,var(--color-card)); } + +.status-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 999px; box-shadow: 0 0 0 4px color-mix(in srgb,currentColor 12%,transparent); } +.secondary-action { min-height: 44px; border: 1px solid color-mix(in srgb,var(--color-accent) 32%,var(--color-border)); border-radius: 14px; padding: .55rem 1rem; color: var(--color-accent); background: color-mix(in srgb,var(--color-accent) 5%,var(--color-card)); font-size: .82rem; font-weight: 800; } + +.course-progress-card { overflow: hidden; border: 1px solid var(--color-border); border-radius: 18px; background: var(--color-card); transition: transform 180ms ease, box-shadow 180ms ease; } +.course-progress-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-card); } +.course-cover { display: grid; height: 86px; place-items: center; color: white; } +.course-cover-1 { background: linear-gradient(135deg,#7256e8,#3b82f6); } +.course-cover-2 { background: linear-gradient(135deg,#fb923c,#f59e0b); } +.course-cover-3 { background: linear-gradient(135deg,#14b8a6,#36beea); } + +.recommendation-card { display: flex; min-height: 72px; align-items: center; gap: .7rem; border: 1px solid var(--color-border); border-radius: 16px; padding: .65rem; text-align: right; background: var(--color-card); } +.recommendation-card span:last-child { min-width: 0; } +.recommendation-card b,.recommendation-card small { display: block; } +.recommendation-card b { overflow: hidden; font-size: .8rem; text-overflow: ellipsis; white-space: nowrap; } +.recommendation-card small { margin-top: .25rem; color: var(--color-text-muted); font-size: .68rem; } +.recommendation-icon { display: grid; width: 52px; height: 52px; flex: 0 0 52px; place-items: center; border-radius: 14px; } +.recommendation-icon-1 { color: #b45309; background: #fef3c7; }.recommendation-icon-2 { color: #1d4ed8; background: #dbeafe; }.recommendation-icon-3 { color: #7e22ce; background: #f3e8ff; } +.dark .recommendation-icon-1 { color:#fde68a;background:rgba(245,158,11,.18) }.dark .recommendation-icon-2 { color:#bfdbfe;background:rgba(59,130,246,.18) }.dark .recommendation-icon-3 { color:#e9d5ff;background:rgba(168,85,247,.18) } + +.quick-app { position: relative; display: grid; min-width: 0; place-items: center; gap: .45rem; border-radius: 16px; padding: .35rem; font-size: .68rem; font-weight: 800; transition: background-color 180ms ease, transform 180ms ease; } +.quick-app:hover { transform: translateY(-2px); background: var(--color-hover); } +.quick-app-icon { display: grid; width: 52px; height: 52px; place-items: center; border-radius: 15px; color: white; box-shadow: 0 10px 24px rgba(15,23,42,.16), inset 0 1px 0 rgba(255,255,255,.28); } +[data-app-icon-tile] { isolation: isolate; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; filter: none !important; } +[data-app-icon-tile] svg { shape-rendering: geometricPrecision; filter: none !important; } +.quick-app-badge { position: absolute; inset-block-start: 0; inset-inline-start: 8%; min-width: 18px; border-radius: 99px; padding: 1px 4px; color: #fff; background: var(--color-coral); font-size: .6rem; } + +.schedule-row { display: flex; min-height: 68px; width: 100%; align-items: center; gap: .7rem; border: 1px solid var(--color-border); border-radius: 16px; padding: .65rem; text-align: right; background: var(--color-card); } +.schedule-row b,.schedule-row small { display: block; }.schedule-row b { font-size: .8rem; }.schedule-row small { margin-top: .2rem; color: var(--color-text-muted); font-size: .68rem; }.schedule-row time { font-size: .75rem; font-weight: 900; font-variant-numeric: tabular-nums; } +.schedule-icon { display:grid; width:42px; height:42px; flex:0 0 42px; place-items:center; border-radius:13px; }.schedule-session{color:#059669;background:#d1fae5}.schedule-exam{color:#e11d48;background:#ffe4e6}.schedule-study{color:#2563eb;background:#dbeafe}.dark .schedule-session{color:#6ee7b7;background:rgba(16,185,129,.16)}.dark .schedule-exam{color:#fda4af;background:rgba(244,63,94,.16)}.dark .schedule-study{color:#93c5fd;background:rgba(59,130,246,.16)} + +.login-shell { background: rgba(8, 20, 54, .78); } +.login-form-panel { background: linear-gradient(145deg,rgba(15,34,78,.86),rgba(21,29,70,.78)); } +.login-secondary-button { display:flex; min-height:48px; align-items:center; justify-content:center; gap:.55rem; border:1px solid rgba(255,255,255,.24); border-radius:16px; padding:.7rem 1rem; color:rgba(255,255,255,.9); background:rgba(255,255,255,.06); font-size:.85rem; font-weight:700; transition:background-color 180ms ease,border-color 180ms ease; } +.login-secondary-button:hover { border-color:rgba(103,232,249,.6); background:rgba(34,211,238,.1); } +.login-visual { border-inline-start:1px solid rgba(255,255,255,.16); background:radial-gradient(circle at 20% 18%,rgba(34,211,238,.35),transparent 16rem),radial-gradient(circle at 82% 76%,rgba(124,58,237,.46),transparent 19rem),linear-gradient(145deg,#061947,#082b75 54%,#25105f); } +.login-visual-copy { position:relative; z-index:3; max-width:470px; }.login-visual-copy>span{display:inline-flex;align-items:center;gap:.45rem;border:1px solid rgba(255,255,255,.2);border-radius:99px;padding:.45rem .75rem;color:#a5f3fc;background:rgba(255,255,255,.08);font-size:.75rem;font-weight:800}.login-visual-copy h2{margin-top:1.2rem;font-size:2rem;line-height:1.5;font-weight:900}.login-visual-copy p{margin-top:.7rem;max-width:420px;color:rgba(255,255,255,.7);font-size:.9rem;line-height:1.9} +.login-orbit{position:absolute;border:1px solid rgba(103,232,249,.23);border-radius:50%;}.login-orbit-one{width:440px;height:440px;inset:auto -90px -130px}.login-orbit-two{width:310px;height:310px;inset:190px auto auto -120px} +.learning-illustration{position:absolute;inset:auto 8% 5% 8%;height:420px;perspective:800px}.illustration-screen{position:absolute;inset:70px 16% auto 16%;height:200px;border:1px solid rgba(147,197,253,.4);border-radius:24px;background:linear-gradient(145deg,rgba(59,130,246,.35),rgba(30,64,175,.18));box-shadow:0 30px 80px rgba(2,6,23,.42),inset 0 1px 0 rgba(255,255,255,.22);transform:rotateX(8deg) rotateY(-6deg);backdrop-filter:blur(12px)}.illustration-screen span{display:block;width:58%;height:9px;margin:30px 28px -15px;border-radius:99px;background:linear-gradient(90deg,#67e8f9,rgba(99,102,241,.5))}.illustration-card{position:absolute;z-index:2;inset:25px auto auto 7%;display:grid;width:100px;height:100px;place-items:center;border:1px solid rgba(255,255,255,.3);border-radius:28px;color:white;background:linear-gradient(145deg,rgba(168,85,247,.8),rgba(37,99,235,.7));box-shadow:0 18px 45px rgba(37,99,235,.35);transform:rotate(-8deg)}.illustration-cap{position:absolute;z-index:3;inset:auto 18% 20px auto;display:grid;width:180px;height:125px;place-items:center;border-radius:50%;color:#a5f3fc;background:radial-gradient(circle,rgba(14,165,233,.38),rgba(37,99,235,.12) 60%,transparent 61%);filter:drop-shadow(0 22px 30px rgba(6,182,212,.24))} + +@media (max-width: 1279px) { + .learning-workspace { padding-inline: 1rem; } +} + +@media (max-width: 767px) { + .learning-workspace { bottom: 70px; } } * { @@ -85,7 +208,10 @@ body { body { overflow: hidden; + min-width: 320px; font-family: var(--font-vazirmatn); + font-size: 16px; + line-height: 1.6; color: var(--color-text); background: var(--color-bg); } @@ -95,6 +221,300 @@ input, textarea, select { font: inherit; + min-width: 0; +} + +button, +[role="button"], +input, +select, +textarea { + touch-action: manipulation; +} + +button { + cursor: pointer; +} + +input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"]), +textarea { + color: var(--color-text); + background-color: var(--color-input-bg); + border: 1px solid var(--color-input-border); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 6px 18px rgba(15, 23, 42, 0.06); +} + +input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"])::placeholder, +textarea::placeholder { + color: var(--color-text-muted); + opacity: 1; +} + +input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"]):focus, +textarea:focus { + border-color: var(--color-accent); + outline: none; + box-shadow: 0 0 0 3px var(--color-focus-ring), inset 0 1px 0 rgba(255, 255, 255, 0.4); +} + +input:disabled, +textarea:disabled, +select:disabled { + cursor: not-allowed; + color: var(--color-disabled-text); + background: var(--color-disabled-bg) !important; + opacity: 0.72; +} + +:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.ui-icon-button { + display: inline-flex; + width: 44px; + height: 44px; + flex: 0 0 44px; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 14px; + background: var(--color-card); + color: var(--color-text); + transition: background-color 180ms ease, opacity 180ms ease; +} + +.ui-icon-button:hover { + background: var(--color-selected); +} + +.ui-icon { + vector-effect: non-scaling-stroke; +} + +.popup-scroll { + overscroll-behavior: contain; + scrollbar-width: none; +} + +.popup-scroll::-webkit-scrollbar { + width: 0; + height: 0; +} + +.popup-scroll-fade { + mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%); +} + +.ui-event-wizard, +.ui-event-wizard > *, +.ui-event-wizard label, +.ui-event-wizard input, +.ui-event-wizard select, +.ui-event-wizard textarea { + min-width: 0; + max-width: 100%; +} + +.ui-dialog-overlay { + opacity: 1; + transition: opacity 260ms cubic-bezier(.16, 1, .3, 1); +} + +.ui-dialog-panel { + opacity: 1; + transform: translate3d(0, 0, 0); + transition: transform 280ms cubic-bezier(.16, 1, .3, 1), opacity 220ms ease-out; + will-change: transform, opacity; + scrollbar-width: none; +} + +.ui-dialog-panel::-webkit-scrollbar { + width: 0; + height: 0; +} + +.ui-dialog-overlay[data-state="closed"] { + opacity: 0; +} + +.ui-dialog-overlay[data-state="closed"] .ui-dialog-panel { + opacity: 0; + transform: translate3d(0, -28px, 0); + transition-duration: 190ms; + transition-timing-function: cubic-bezier(.4, 0, 1, 1); +} + +.ui-dialog-overlay[data-side="left"] { + align-items: flex-start; + justify-content: flex-start; + direction: ltr; +} + +.ui-dialog-overlay[data-side="left"] .ui-dialog-panel { + direction: rtl; +} + +.mac-window-shell .ui-dialog-overlay, +.mac-window-shell .window-modal-layer, +.mac-window-shell .fixed.inset-0 { + position: absolute; + inset: 0; +} + +.mac-window-shell .fixed.inset-0 > section, +.mac-window-shell .fixed.inset-0 > aside, +.mac-window-shell .fixed.inset-0 > div { + max-width: calc(100% - 16px); + max-height: calc(100% - 16px); +} + +.mac-window-shell .fixed.inset-0 :is(section, aside, div) { + scrollbar-width: none; +} + +.mac-window-shell .fixed.inset-0 :is(section, aside, div)::-webkit-scrollbar { + width: 0; + height: 0; +} + +.mac-window-shell .ui-dialog-overlay { + padding: clamp(8px, 2vw, 20px); +} + +.mac-window-shell .ui-dialog-panel { + max-width: min(100%, 52rem); + max-height: calc(100% - 16px); + overflow: auto; +} + +.window-module-shell { + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; + overflow: hidden; + color: var(--color-text); + background: var(--color-surface); +} + +.window-module-toolbar { + flex: 0 0 auto; + min-width: 0; + border-bottom: 1px solid var(--color-divider); + background: var(--color-surface); + padding: 0.75rem 1rem; +} + +.window-controls-safe-row { + padding-left: 9.5rem; +} + +.window-toolbar-actions { + min-width: 0; + max-width: 100%; +} + +.window-top-slide-panel { + opacity: 1; + transform: translate3d(0, 0, 0); + transform-origin: top center; + transition: transform 260ms cubic-bezier(.16, 1, .3, 1), opacity 200ms ease-out; + will-change: transform, opacity; +} + +.window-top-slide-panel[data-state="closed"] { + pointer-events: none; + opacity: 0; + transform: translate3d(0, -14px, 0); + transition-duration: 180ms; + transition-timing-function: cubic-bezier(.4, 0, 1, 1); +} + +.window-split-view { + display: grid; + grid-template-columns: var(--window-sidebar-width) 6px minmax(0, 1fr); + height: 100%; + overflow: hidden; +} + +.window-split-view[data-sidebar-open="false"] { + grid-template-columns: 0 0 minmax(0, 1fr); +} + +.window-split-sidebar { + position: relative; + z-index: 3; + overflow: hidden; + background: var(--color-surface); + transition: transform 240ms cubic-bezier(.16, 1, .3, 1), opacity 180ms ease-out; +} + +.window-split-view[data-sidebar-open="false"] > .window-split-sidebar { + pointer-events: none; + opacity: 0; +} + +.window-split-divider { + position: relative; + z-index: 4; + width: 6px; + cursor: col-resize; + touch-action: none; + background: transparent; +} + +.window-split-divider::after { + position: absolute; + inset-block: 0; + left: 2px; + width: 2px; + content: ""; + background: var(--color-divider); + transition: background-color 180ms ease, width 180ms ease; +} + +.window-split-divider:is(:hover, :focus-visible)::after { + left: 1px; + width: 4px; + background: var(--color-accent); +} + +.window-split-toggle { + position: absolute; + inset-block-start: 8px; + inset-inline-start: 8px; + z-index: 8; + box-shadow: var(--shadow-soft); +} + +.window-split-view[data-compact="false"][data-sidebar-open="true"] > .window-split-toggle { + inset-inline-start: calc(var(--window-sidebar-width) + 10px); +} + +.window-split-backdrop { + position: absolute; + inset: 0; + z-index: 2; + border: 0; + background: var(--color-overlay); + backdrop-filter: blur(3px); +} + +.ui-status { + border: 1px solid currentColor; + border-radius: 16px; + padding: 0.75rem 1rem; + font-size: 0.875rem; + line-height: 1.6; +} + +.ui-data-card { + border: 1px solid var(--color-border); + background: var(--color-card); + border-radius: 18px; + padding: 1rem; } select { @@ -165,18 +585,31 @@ select option:checked { -webkit-backdrop-filter: blur(var(--glass-blur, 26px)) saturate(1.35); } +.content-surface { + border: 1px solid var(--color-border); + background: var(--color-surface-strong); + color: var(--color-text); +} + .dark .liquid-glass { border-color: var(--color-border); background: linear-gradient(135deg, rgba(16, 24, 39, var(--glass-dark-alpha-strong)), rgba(16, 24, 39, var(--glass-dark-alpha-soft))); box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12); } +.liquid-glass .liquid-glass { + background: var(--color-surface-strong); + box-shadow: 0 10px 32px rgba(15, 23, 42, 0.1); + backdrop-filter: blur(var(--glass-blur, 8px)) saturate(1.08); + -webkit-backdrop-filter: blur(var(--glass-blur, 8px)) saturate(1.08); +} + .ui-surface { border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); box-shadow: var(--shadow-soft); - backdrop-filter: blur(22px) saturate(1.24); + backdrop-filter: blur(var(--glass-blur, 22px)) saturate(1.24); -webkit-backdrop-filter: blur(22px) saturate(1.24); } @@ -232,8 +665,108 @@ select option:checked { background: rgba(148, 163, 184, 0.7); } +.fixed.inset-0 .mac-scrollbar { + scrollbar-width: none; +} + +.fixed.inset-0 .mac-scrollbar::-webkit-scrollbar { + width: 0; + height: 0; +} + .persian-digits { font-feature-settings: "ss01"; + font-variant-numeric: tabular-nums; +} + +input[type="email"], +input[type="url"], +input[type="tel"], +input[inputmode="email"], +input[inputmode="numeric"] { + direction: ltr; + text-align: left; + font-variant-numeric: tabular-nums; +} + +.skip-link { + position: fixed; + inset-inline-start: 1rem; + top: -5rem; + z-index: 100000; + border-radius: 12px; + background: var(--color-text); + color: var(--color-bg-soft); + padding: 0.75rem 1rem; + font-weight: 700; +} + +.skip-link:focus { + top: 1rem; +} + +@media (max-width: 719px) { + body { + overflow-x: hidden; + } + + input, + select, + textarea { + font-size: 16px; + } + + .text-xs, + .text-\[10px\], + .text-\[11px\], + .text-\[12px\] { + font-size: 0.875rem !important; + line-height: 1.5rem !important; + } + + .liquid-glass { + backdrop-filter: blur(min(var(--glass-blur, 18px), 18px)) saturate(1.12); + -webkit-backdrop-filter: blur(min(var(--glass-blur, 18px), 18px)) saturate(1.12); + } + + .ui-dialog-overlay { + align-items: flex-end; + padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom)); + } + + .ui-dialog-panel { + max-height: calc(100dvh - 24px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + overflow-y: auto; + border-radius: 24px; + } +} + +@media (prefers-contrast: more) { + .liquid-glass, + .ui-surface, + .ui-card { + background: var(--color-surface-strong); + border-color: currentColor; + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + [class*="hover:scale-"], + [class*="hover:-translate-"], + [class*="active:scale-"] { + transform: none !important; + } } .modern-range { @@ -249,9 +782,9 @@ select option:checked { .modern-range::-webkit-slider-runnable-track { height: 7px; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.36); + border: 1px solid var(--color-input-border); background: - linear-gradient(90deg, rgb(14 165 233) 0 var(--range-value), rgba(255, 255, 255, 0.36) var(--range-value) 100%); + linear-gradient(90deg, rgb(14 165 233) 0 var(--range-value), var(--color-disabled-bg) var(--range-value) 100%); box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.14), 0 5px 16px rgba(14, 165, 233, 0.12); } @@ -269,8 +802,8 @@ select option:checked { .modern-range::-moz-range-track { height: 7px; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.42); - background: rgba(255, 255, 255, 0.36); + border: 1px solid var(--color-input-border); + background: var(--color-disabled-bg); } .modern-range::-moz-range-progress { @@ -287,3 +820,367 @@ select option:checked { background: linear-gradient(135deg, #ffffff, #dff7ff); box-shadow: 0 5px 14px rgba(15, 23, 42, 0.2); } + +/* Shared density, semantics and accessibility rules for the desktop UI. */ +.ui-search-box { + cursor: text; +} + +.ui-search-box:focus-within { + outline: 3px solid var(--color-accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +:where(label, div):has(> input:is([type="search"], [placeholder*="جست"])):focus-within { + outline: 3px solid var(--color-accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.ui-search-box > input, +.ui-search-box input { + height: 100% !important; + min-height: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + padding: 0 !important; +} + +.profile-panel :is(article, .profile-surface), +.profile-panel main > div > div, +.profile-panel main > div > section { + border-color: var(--color-border); + background-color: var(--color-card); + color: var(--color-text); +} + +.profile-panel select { + background-color: var(--color-input-bg) !important; +} + +.profile-panel { + background: + linear-gradient(135deg, rgba(255, 255, 255, var(--glass-light-alpha-strong)), rgba(226, 238, 249, var(--glass-light-alpha-soft))) !important; +} + +.dark .profile-panel { + background: + linear-gradient(135deg, rgba(16, 24, 39, var(--glass-dark-alpha-strong)), rgba(16, 24, 39, var(--glass-dark-alpha-soft))) !important; +} + +.mac-window-shell { + container-name: app-window; + container-type: inline-size; +} + +.mac-window-shell :where(header, nav, main, section, aside, form, div) { + min-width: 0; +} + +.window-social-toolbar { + grid-template-columns: auto minmax(180px, 320px) minmax(0, 1fr); +} + +@container app-window (max-width: 1100px) { + .mac-window-shell [class*="max-xl:grid-cols-1"] { + grid-template-columns: minmax(0, 1fr) !important; + } + + .mac-window-shell [class*="xl:grid-cols-"] { + grid-template-columns: minmax(0, 1fr) !important; + } + + .window-social-layout > aside { + display: none !important; + } +} + +@container app-window (max-width: 820px) { + .window-social-toolbar { + grid-template-columns: auto 44px minmax(0, 1fr); + } + + .window-social-search { + width: 44px !important; + justify-content: center; + padding-inline: 0 !important; + } + + .window-social-search > span { + display: none; + } + + .mac-window-shell [class*="lg:grid-cols-"] { + grid-template-columns: minmax(0, 1fr) !important; + } + + .window-calendar-toolbar { + grid-template-columns: minmax(0, 1fr) !important; + } + + .window-calendar-toolbar > * { + width: 100%; + } + + .window-calendar-toolbar .window-calendar-search { + display: none !important; + } + + .window-toolbar-actions { + flex-wrap: nowrap !important; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; + } + + .window-toolbar-actions::-webkit-scrollbar { + display: none; + } + + .window-split-view[data-compact="true"] { + display: block; + } + + .window-split-view[data-compact="true"] > .window-split-sidebar { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: min(82cqw, var(--window-sidebar-width)); + max-width: calc(100% - 48px); + box-shadow: var(--shadow-elevated); + } + + .window-split-view[data-compact="true"][data-sidebar-open="false"] > .window-split-sidebar { + opacity: 0; + transform: translate3d(105%, 0, 0); + } + + .window-split-view[data-compact="true"] > .window-split-divider { + display: none; + } + + .window-split-view[data-compact="true"] > .window-split-main { + height: 100%; + } +} + +@container app-window (max-width: 680px) { + .mac-window-shell [class*="md:grid-cols-"], + .mac-window-shell [class*="grid-cols-["] { + grid-template-columns: minmax(0, 1fr) !important; + } + + .mac-window-shell [class*="min-w-[220px]"] { + min-width: 0 !important; + } + + .window-module-toolbar, + .ui-page-toolbar { + padding-block: .5rem; + padding-inline-end: .5rem; + } + + .window-toolbar-flow { + align-items: stretch !important; + } + + .window-toolbar-grow, + .window-toolbar-search { + width: 100% !important; + flex-basis: 100% !important; + } + + .window-explorer-navigation { + height: auto !important; + min-height: 2.25rem; + flex-wrap: wrap; + } + + .window-explorer-breadcrumb { + order: 3; + flex-basis: 100%; + } +} + +@container app-window (max-width: 520px) { + .window-social-title { + display: none; + } + + .mac-window-shell [class*="sm:grid-cols-"] { + grid-template-columns: minmax(0, 1fr) !important; + } + + .window-toolbar-action-label { + display: none !important; + } +} + +/* Visually reduce in-window glyphs while preserving their 44px interaction targets. */ +.mac-window-shell :where(svg.ui-icon, svg.lucide) { + scale: .6; + transform-origin: center; +} + +/* Scrolling remains available by wheel/trackpad, but no popup/window scrollbar is drawn. */ +.mac-window-shell *, +.popup-scroll, +.ui-dialog-panel { + scrollbar-width: none; +} + +.mac-window-shell *::-webkit-scrollbar, +.popup-scroll::-webkit-scrollbar, +.ui-dialog-panel::-webkit-scrollbar { + width: 0; + height: 0; +} + +.semantic-positive { + background: var(--color-success) !important; + color: white !important; +} + +.semantic-danger { + background: var(--color-danger) !important; + color: white !important; +} + +/* Every application window uses one material surface. Module roots must not + paint an opaque page over the shared liquid-glass shell. */ +.mac-window-shell.liquid-glass { + isolation: isolate; + background-color: rgb(var(--surface-rgb) / var(--surface-window-alpha)) !important; + background-image: + linear-gradient(145deg, color-mix(in srgb, white 20%, transparent), transparent 42%), + radial-gradient(circle at 82% 8%, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent 38%) !important; + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(155%) !important; + backdrop-filter: blur(var(--glass-blur)) saturate(155%) !important; +} + +.mac-window-shell > .mac-window-content, +.mac-window-shell > .mac-window-content > .window-module-shell, +.mac-window-shell > .mac-window-content > .ui-page-shell, +.mac-window-shell > .mac-window-content > .liquid-glass { + background: transparent !important; + background-color: transparent !important; +} + +/* Explorer/module icons follow the desktop Small icon contract: a borderless + 48px glass tile with a centered 24px glyph. */ +.mac-window-shell [data-glass-icon] { + width: 3rem !important; + height: 3rem !important; + min-width: 3rem !important; + min-height: 3rem !important; + border: 0 !important; + border-radius: 1rem !important; +} + +.mac-window-shell [data-glass-icon] :where(svg.ui-icon, svg.lucide), +.mac-window-shell [data-glass-icon] > :where(svg.ui-icon, svg.lucide) { + width: 1.5rem !important; + height: 1.5rem !important; + scale: 1 !important; +} + +/* Semantic appearance tokens: one source for every system surface. */ +:root { + --surface-rgb: 255 255 255; + --surface-window-alpha: calc(.22 + (var(--glass-opacity) * .58)); + --surface-panel-alpha: calc(.16 + (var(--glass-opacity) * .48)); + --surface-card-alpha: calc(.12 + (var(--glass-opacity) * .42)); + --surface-elevated-alpha: calc(.3 + (var(--glass-opacity) * .62)); + --surface-input-alpha: calc(.18 + (var(--glass-opacity) * .54)); + --color-surface: rgb(var(--surface-rgb) / var(--surface-window-alpha)); + --color-surface-strong: rgb(var(--surface-rgb) / var(--surface-elevated-alpha)); + --color-card: rgb(var(--surface-rgb) / var(--surface-card-alpha)); + --color-elevated: rgb(var(--surface-rgb) / var(--surface-elevated-alpha)); + --color-input-bg: rgb(var(--surface-rgb) / var(--surface-input-alpha)); + --color-selected: color-mix(in srgb, var(--color-accent) 16%, transparent); + --color-hover: color-mix(in srgb, var(--color-accent) 10%, transparent); + --color-focus-ring: color-mix(in srgb, var(--color-accent) 42%, transparent); +} + +.dark { + --surface-rgb: 15 23 42; +} + +.ui-surface, +[data-surface="card"] { background: rgb(var(--surface-rgb) / var(--surface-card-alpha)); } +[data-surface="window"] { background: rgb(var(--surface-rgb) / var(--surface-window-alpha)); } +[data-surface="panel"] { background: rgb(var(--surface-rgb) / var(--surface-panel-alpha)); } +[data-surface="elevated"] { background: rgb(var(--surface-rgb) / var(--surface-elevated-alpha)); } +[data-surface="input"] { background: rgb(var(--surface-rgb) / var(--surface-input-alpha)); } + +.liquid-glass { + background-color: rgb(var(--surface-rgb) / var(--surface-panel-alpha)) !important; + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%); + backdrop-filter: blur(var(--glass-blur)) saturate(150%); +} + +.ui-card { background-color: rgb(var(--surface-rgb) / var(--surface-card-alpha)) !important; } +.ui-input, +.mac-os-appearance :where(input, select, textarea) { background-color: var(--color-input-bg); } + +/* Bridge legacy translucent white surfaces until their feature markup is removed. */ +.mac-os-appearance [class*="bg-white/"] { + background-color: rgb(var(--surface-rgb) / var(--surface-card-alpha)) !important; +} + +/* Shared feature boundary normalizes interaction, tables and raw legacy controls. */ +.feature-shell { + container-name: feature-shell; + container-type: inline-size; + overflow: hidden; +} + +.feature-shell :where(button, a, input, select, textarea) { + font: inherit; +} + +.feature-shell :where(button, a[href], [role="button"], [role="tab"]) { + touch-action: manipulation; +} + +.feature-shell :where(button, a[href], [role="button"], [role="tab"]):focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.feature-shell :where(input:not([type="checkbox"]):not([type="radio"]), select, textarea) { + border: 1px solid var(--color-input-border); + color: var(--color-text); +} + +.feature-shell :where(table) { + border-color: var(--color-border); + color: var(--color-text); +} + +.feature-shell :where(thead) { + background: var(--color-table-header); +} + +.feature-shell :where(tbody tr) { + border-color: var(--color-divider); +} + +.feature-shell :where(tbody tr:hover) { + background: var(--color-table-row-hover); +} + +@media (prefers-reduced-motion: reduce) { + .mac-os-appearance *, + .mac-os-appearance *::before, + .mac-os-appearance *::after { + scroll-behavior: auto !important; + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 47bcf05..bb198a2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,13 +3,21 @@ import "./globals.css"; export const metadata: Metadata = { title: "سامانه آموزش سازمانی آراد", - description: "سیستم عامل یادگیری سازمانی فارسی" + description: "سیستم عامل یادگیری سازمانی فارسی", + icons: { + icon: [ + { url: "/arad-logo-light.png", type: "image/png", media: "(prefers-color-scheme: light)" }, + { url: "/arad-logo-dark.png", type: "image/png", media: "(prefers-color-scheme: dark)" } + ], + shortcut: "/arad-logo-light.png", + apple: "/arad-logo-light.png" + } }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + پرش به محتوای اصلی{children} ); } diff --git a/src/app/super-admin/plans/audit/page.tsx b/src/app/super-admin/plans/audit/page.tsx index 850d6b4..436bbe7 100644 --- a/src/app/super-admin/plans/audit/page.tsx +++ b/src/app/super-admin/plans/audit/page.tsx @@ -1,5 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function PlanAuditPage() { - return ; + redirect("/apps/licenses"); } diff --git a/src/app/super-admin/plans/gold/page.tsx b/src/app/super-admin/plans/gold/page.tsx index 8a01655..ab30123 100644 --- a/src/app/super-admin/plans/gold/page.tsx +++ b/src/app/super-admin/plans/gold/page.tsx @@ -1,6 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function GoldPlanPage() { - return ; + redirect("/apps/licenses"); } - diff --git a/src/app/super-admin/plans/modules/page.tsx b/src/app/super-admin/plans/modules/page.tsx index 64d20ee..1ffdca8 100644 --- a/src/app/super-admin/plans/modules/page.tsx +++ b/src/app/super-admin/plans/modules/page.tsx @@ -1,6 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function PlanModulesPage() { - return ; + redirect("/apps/licenses"); } - diff --git a/src/app/super-admin/plans/page.tsx b/src/app/super-admin/plans/page.tsx index afe219a..e610b19 100644 --- a/src/app/super-admin/plans/page.tsx +++ b/src/app/super-admin/plans/page.tsx @@ -1,6 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function SuperAdminPlansPage() { - return ; + redirect("/apps/licenses"); } - diff --git a/src/app/super-admin/plans/platinum/page.tsx b/src/app/super-admin/plans/platinum/page.tsx index 61342ff..10f3801 100644 --- a/src/app/super-admin/plans/platinum/page.tsx +++ b/src/app/super-admin/plans/platinum/page.tsx @@ -1,6 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function PlatinumPlanPage() { - return ; + redirect("/apps/licenses"); } - diff --git a/src/app/super-admin/plans/preview/page.tsx b/src/app/super-admin/plans/preview/page.tsx index 040ccd9..e372a75 100644 --- a/src/app/super-admin/plans/preview/page.tsx +++ b/src/app/super-admin/plans/preview/page.tsx @@ -1,6 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function PlanPreviewPage() { - return ; + redirect("/apps/licenses"); } - diff --git a/src/app/super-admin/plans/silver/page.tsx b/src/app/super-admin/plans/silver/page.tsx index bd639bc..2ca832c 100644 --- a/src/app/super-admin/plans/silver/page.tsx +++ b/src/app/super-admin/plans/silver/page.tsx @@ -1,6 +1,5 @@ -import { PlansPage } from "@/components/plans/PlansPage"; +import { redirect } from "next/navigation"; export default function SilverPlanPage() { - return ; + redirect("/apps/licenses"); } - diff --git a/src/components/analytics-dashboard/AnalyticsDashboardExplorer.tsx b/src/components/analytics-dashboard/AnalyticsDashboardExplorer.tsx index 2ae002d..ca2362c 100644 --- a/src/components/analytics-dashboard/AnalyticsDashboardExplorer.tsx +++ b/src/components/analytics-dashboard/AnalyticsDashboardExplorer.tsx @@ -10,6 +10,7 @@ import { AnalyticsDashboardTabContent } from "./AnalyticsDashboardTabContent"; import { AnalyticsDashboardTabs, type AnalyticsTab } from "./AnalyticsDashboardTabs"; import { AnalyticsFilterDrawer } from "./components/AnalyticsFilterDrawer"; import { AnalyticsToolbar } from "./components/AnalyticsToolbar"; +import { DashboardDataProvider, useDashboardData } from "./DashboardDataProvider"; const defaultFilters: AnalyticsFilterState = { dateRange: "this_month" }; @@ -48,32 +49,28 @@ export function AnalyticsDashboardExplorer({ role, onToast }: { role: UserRole; }; return ( -
- -
- setFilters((current) => ({ ...current, dateRange }))} - onFilter={() => setDrawerOpen(true)} - onRefresh={() => setRefreshKey((value) => value + 1)} - onExport={exportReport} - /> -
- setQuery(event.target.value)} placeholder="جستجو در گزارش‌ها..." className="h-9 w-full rounded-[14px] border border-white/18 bg-white/16 px-3 text-sm outline-none placeholder:text-current/45 focus:border-cyan-300/70" /> - -
-
-
- {activeTabId === "explorer-home" ? ( - - ) : ( -
- - -
- )} -
+ + { setQuery(""); setActiveTabId(overviewId); }} + onPickTab={setActiveTabId} + onCloseTab={closeTab} + onOpenItem={openItem} + onOpenTab={openTab} + onFilter={() => setDrawerOpen(true)} + onDateRange={(dateRange) => setFilters((current) => ({ ...current, dateRange }))} + onRefresh={() => setRefreshKey((value) => value + 1)} + onExport={exportReport} + onToast={onToast} + /> setFilters(defaultFilters)} onClose={() => setDrawerOpen(false)} /> + + ); +} + +function AnalyticsDashboardSurface(props: { + role: UserRole; + items: AnalyticsExplorerItem[]; + tabs: AnalyticsTab[]; + activeTabId: string; + query: string; + filters: AnalyticsFilterState; + overviewId: string; + filteredItems: AnalyticsExplorerItem[]; + onQuery: (value: string) => void; + onHome: () => void; + onPickTab: (value: string) => void; + onCloseTab: (value: string) => void; + onOpenItem: (item: AnalyticsExplorerItem) => void; + onOpenTab: (id: string, titleFa: string) => void; + onFilter: () => void; + onDateRange: (value: AnalyticsFilterState["dateRange"]) => void; + onRefresh: () => void; + onExport: (format: "excel" | "pdf" | "print") => void; + onToast?: (message: string, tone?: "success" | "error") => void; +}) { + const { data, loading, error, refresh } = useDashboardData(); + return ( +
+ +
+ { props.onRefresh(); refresh(); }} + onExport={props.onExport} + /> +
+ props.onQuery(event.target.value)} placeholder="جستجو در گزارش‌ها..." className="h-9 w-full rounded-[14px] border border-white/18 bg-white/16 px-3 text-sm outline-none placeholder:text-current/45 focus:border-cyan-300/70" /> + +
+
+ {loading ? "در حال دریافت داده زنده…" : error ? "اتصال زنده قطع است." : `آخرین بروزرسانی: ${new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(data?.updatedAt ?? Date.now()))}`} + {data?.source === "api" ? "داده زنده API" : "داده محلی توسعه"} + {error ? : null} +
+
+
+ {props.activeTabId === "explorer-home" ? ( + + ) : ( +
+ + +
+ )} +
); } diff --git a/src/components/analytics-dashboard/AnalyticsDashboardTabContent.tsx b/src/components/analytics-dashboard/AnalyticsDashboardTabContent.tsx index 85369ef..05dd5e4 100644 --- a/src/components/analytics-dashboard/AnalyticsDashboardTabContent.tsx +++ b/src/components/analytics-dashboard/AnalyticsDashboardTabContent.tsx @@ -1,7 +1,6 @@ "use client"; import type { AnalyticsExplorerItem, AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; -import { getAnalyticsCharts, getAnalyticsRows } from "@/services/mock/analyticsDashboardMockService"; import { AnalyticsChartCard } from "./components/AnalyticsChartCard"; import { AnalyticsDataTable } from "./components/AnalyticsDataTable"; import { ReadyReportsSection } from "./sections/ReadyReportsSection"; @@ -9,6 +8,7 @@ import { SystemOverviewSection } from "./sections/SystemOverviewSection"; import { OrganizationOverviewSection } from "./sections/OrganizationOverviewSection"; import { TeamOverviewSection } from "./sections/TeamOverviewSection"; import { PersonalOverviewSection } from "./sections/PersonalOverviewSection"; +import { useDashboardData } from "./DashboardDataProvider"; export function AnalyticsDashboardTabContent({ role, @@ -25,6 +25,7 @@ export function AnalyticsDashboardTabContent({ onOpenTab: (id: string, titleFa: string) => void; onToast?: (message: string, tone?: "success" | "error") => void; }) { + const { data } = useDashboardData(); if (tabId === "system-overview") return ; if (tabId === "organization-overview") return ; if (tabId === "team-overview") return ; @@ -32,8 +33,8 @@ export function AnalyticsDashboardTabContent({ if (tabId === "ready-reports") return onToast?.(message)} onOpenTab={onOpenTab} />; const item = items.find((candidate) => candidate.id === tabId); - const rows = getAnalyticsRows(role, tabId); - const charts = getAnalyticsCharts(role, filters); + const rows = data?.rows ?? []; + const charts = data?.charts ?? { line: [], bar: [], stacked: [], donut: [], heatmap: [], timeline: [], titleFa: "" }; const detailTitle = item?.titleFa ?? titleFromDetail(tabId); return (
@@ -58,4 +59,3 @@ function titleFromDetail(tabId: string) { if (tabId.includes("trend")) return "روند"; return "جزئیات"; } - diff --git a/src/components/analytics-dashboard/AnalyticsDashboardTabs.tsx b/src/components/analytics-dashboard/AnalyticsDashboardTabs.tsx index 2221f27..97013de 100644 --- a/src/components/analytics-dashboard/AnalyticsDashboardTabs.tsx +++ b/src/components/analytics-dashboard/AnalyticsDashboardTabs.tsx @@ -1,6 +1,6 @@ "use client"; -import { X } from "lucide-react"; +import { X } from "@/components/shared/ui-icons"; export type AnalyticsTab = { id: string; titleFa: string }; diff --git a/src/components/analytics-dashboard/DashboardDataProvider.tsx b/src/components/analytics-dashboard/DashboardDataProvider.tsx new file mode 100644 index 0000000..8ebab4c --- /dev/null +++ b/src/components/analytics-dashboard/DashboardDataProvider.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; +import { getAnalyticsDashboard, getAnalyticsStreamUrl, type AnalyticsDashboardData } from "@/services/api/analyticsDashboardService"; + +type DashboardDataContextValue = { + data: AnalyticsDashboardData | null; + loading: boolean; + error: string; + refresh: () => void; +}; + +const DashboardDataContext = createContext(null); + +export function DashboardDataProvider({ role, filters, refreshToken, children }: { role: AnalyticsRole; filters: AnalyticsFilterState; refreshToken: number; children: ReactNode }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [manualRefresh, setManualRefresh] = useState(0); + const filterKey = JSON.stringify(filters); + + const load = useCallback(async (signal?: AbortSignal) => { + setLoading((current) => current || !data); + try { + const next = await getAnalyticsDashboard(role, filters, signal); + setData(next); + setError(""); + } catch (reason) { + if (signal?.aborted) return; + setError(reason instanceof Error ? reason.message : "دریافت داده داشبورد ناموفق بود."); + } finally { + if (!signal?.aborted) setLoading(false); + } + // filters is stabilized through filterKey. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [role, filterKey]); + + useEffect(() => { + const controller = new AbortController(); + load(controller.signal); + const interval = window.setInterval(() => { + if (document.visibilityState === "visible" && navigator.onLine) load(); + }, 30_000); + const onVisible = () => { + if (document.visibilityState === "visible") load(); + }; + const onOnline = () => load(); + document.addEventListener("visibilitychange", onVisible); + window.addEventListener("online", onOnline); + return () => { + controller.abort(); + window.clearInterval(interval); + document.removeEventListener("visibilitychange", onVisible); + window.removeEventListener("online", onOnline); + }; + }, [load, manualRefresh, refreshToken]); + + useEffect(() => { + const streamUrl = getAnalyticsStreamUrl(role, filters); + if (!streamUrl || typeof EventSource === "undefined") return; + const stream = new EventSource(streamUrl, { withCredentials: true }); + stream.onmessage = (event) => { + try { + const next = JSON.parse(event.data); + setData({ ...(next.data ?? next), source: "api", updatedAt: next.updatedAt ?? new Date().toISOString() }); + setError(""); + } catch { + setError("پاسخ زنده داشبورد قابل خواندن نبود."); + } + }; + return () => stream.close(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [role, filterKey]); + + const value = useMemo(() => ({ data, loading, error, refresh: () => setManualRefresh((value) => value + 1) }), [data, error, loading]); + return {children}; +} + +export function useDashboardData() { + const value = useContext(DashboardDataContext); + if (!value) throw new Error("useDashboardData must be used inside DashboardDataProvider"); + return value; +} + diff --git a/src/components/analytics-dashboard/components/AnalyticsDataTable.tsx b/src/components/analytics-dashboard/components/AnalyticsDataTable.tsx index c50ddec..bc0655e 100644 --- a/src/components/analytics-dashboard/components/AnalyticsDataTable.tsx +++ b/src/components/analytics-dashboard/components/AnalyticsDataTable.tsx @@ -4,8 +4,22 @@ import type { AnalyticsTableRow } from "@/types/analyticsDashboard"; export function AnalyticsDataTable({ rows }: { rows: AnalyticsTableRow[] }) { return ( -
+
+
+ {rows.map((row) => ( +
+

{row.titleFa}

{row.statusFa}
+
+
مالک
{row.ownerFa}
+
شاخص
{row.valueFa}
+
روند
{row.trendFa}
+
+
+ ))} +
+
+ {["عنوان", "مالک", "وضعیت", "شاخص", "روند"].map((header) => )} @@ -21,7 +35,8 @@ export function AnalyticsDataTable({ rows }: { rows: AnalyticsTableRow[] }) { ))}
جدول شاخص‌ها و روند گزارش تحلیلی
{header}
+
+ {!rows.length ?

داده‌ای برای نمایش وجود ندارد.

: null}
); } - diff --git a/src/components/analytics-dashboard/components/AnalyticsEmptyState.tsx b/src/components/analytics-dashboard/components/AnalyticsEmptyState.tsx index 9fd2b87..f0a4594 100644 --- a/src/components/analytics-dashboard/components/AnalyticsEmptyState.tsx +++ b/src/components/analytics-dashboard/components/AnalyticsEmptyState.tsx @@ -1,6 +1,6 @@ "use client"; -import { SearchX } from "lucide-react"; +import { SearchX } from "@/components/shared/ui-icons"; import { MacButton } from "@/components/mac/MacUI"; export function AnalyticsEmptyState({ onReset }: { onReset?: () => void }) { diff --git a/src/components/analytics-dashboard/components/AnalyticsFilterDrawer.tsx b/src/components/analytics-dashboard/components/AnalyticsFilterDrawer.tsx index 984ed2d..98cf596 100644 --- a/src/components/analytics-dashboard/components/AnalyticsFilterDrawer.tsx +++ b/src/components/analytics-dashboard/components/AnalyticsFilterDrawer.tsx @@ -2,6 +2,7 @@ import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; import { MacButton } from "@/components/mac/MacUI"; +import { AccessibleDialog } from "@/components/shared/AccessibleDialog"; const choices = { organizationId: ["همه سازمان‌ها", "وزارت نیرو", "هلدینگ پارس"], @@ -18,7 +19,6 @@ const choices = { }; export function AnalyticsFilterDrawer({ open, role, filters, onChange, onApply, onReset, onClose }: { open: boolean; role: AnalyticsRole; filters: AnalyticsFilterState; onChange: (patch: Partial) => void; onApply: () => void; onReset: () => void; onClose: () => void }) { - if (!open) return null; const fields = role === "super_admin" ? ["organizationId", "status", "courseId", "examId", "classId", "learningPathId", "userId"] : role === "org_admin" @@ -27,21 +27,15 @@ export function AnalyticsFilterDrawer({ open, role, filters, onChange, onApply, ? ["userId", "courseId", "examId", "classId", "learningPathId", "status"] : ["courseId", "examId", "learningPathId", "status"]; return ( -
- -
+ ); } @@ -56,4 +50,3 @@ function FilterSelect({ field, value, onChange }: { field: keyof typeof choices; ); } - diff --git a/src/components/analytics-dashboard/components/AnalyticsToolbar.tsx b/src/components/analytics-dashboard/components/AnalyticsToolbar.tsx index fee5022..0b4d5d8 100644 --- a/src/components/analytics-dashboard/components/AnalyticsToolbar.tsx +++ b/src/components/analytics-dashboard/components/AnalyticsToolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { Download, FileSpreadsheet, Filter, Printer, RefreshCw } from "lucide-react"; +import { Download, FileSpreadsheet, Filter, Printer, RefreshCw } from "@/components/shared/ui-icons"; import { MacButton, MacIconButton } from "@/components/mac/MacUI"; import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; import { analyticsDateRangeLabels } from "@/config/analyticsDashboardConfig"; diff --git a/src/components/analytics-dashboard/components/KpiCard.tsx b/src/components/analytics-dashboard/components/KpiCard.tsx index 8fbe4ab..5b0a20b 100644 --- a/src/components/analytics-dashboard/components/KpiCard.tsx +++ b/src/components/analytics-dashboard/components/KpiCard.tsx @@ -2,7 +2,7 @@ import { toPersianDigits } from "@/lib/persianFormat"; import type { AnalyticsKpiCard } from "@/types/analyticsDashboard"; -import { ArrowDown, ArrowUp, Minus } from "lucide-react"; +import { ArrowDown, ArrowUp, Minus } from "@/components/shared/ui-icons"; export function KpiCard({ item, onOpen }: { item: AnalyticsKpiCard; onOpen: (tabId: string, titleFa: string) => void }) { const tone = item.severity === "danger" ? "border-rose-400/35 bg-rose-500/12" : item.severity === "warning" ? "border-amber-400/35 bg-amber-500/12" : item.severity === "success" ? "border-emerald-400/35 bg-emerald-500/12" : "border-white/22 bg-white/16"; diff --git a/src/components/analytics-dashboard/sections/ReadyReportsSection.tsx b/src/components/analytics-dashboard/sections/ReadyReportsSection.tsx index 2785922..4466398 100644 --- a/src/components/analytics-dashboard/sections/ReadyReportsSection.tsx +++ b/src/components/analytics-dashboard/sections/ReadyReportsSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileText } from "lucide-react"; +import { FileText } from "@/components/shared/ui-icons"; import { MacButton } from "@/components/mac/MacUI"; import { getReadyReports } from "@/services/mock/analyticsDashboardMockService"; import type { AnalyticsRole } from "@/types/analyticsDashboard"; diff --git a/src/components/analytics-dashboard/sections/shared.tsx b/src/components/analytics-dashboard/sections/shared.tsx index 66b3743..124007a 100644 --- a/src/components/analytics-dashboard/sections/shared.tsx +++ b/src/components/analytics-dashboard/sections/shared.tsx @@ -1,15 +1,16 @@ "use client"; import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; -import { getAnalyticsCharts, getAnalyticsKpis, getAnalyticsRows } from "@/services/mock/analyticsDashboardMockService"; import { KpiCard } from "../components/KpiCard"; import { AnalyticsChartCard } from "../components/AnalyticsChartCard"; import { AnalyticsDataTable } from "../components/AnalyticsDataTable"; +import { useDashboardData } from "../DashboardDataProvider"; export function AnalyticsOverviewCanvas({ role, filters, onOpenTab }: { role: AnalyticsRole; filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) { - const charts = getAnalyticsCharts(role, filters); - const rows = getAnalyticsRows(role, "overview"); - const kpis = getAnalyticsKpis(role); + const { data } = useDashboardData(); + const charts = data?.charts ?? { line: [], bar: [], stacked: [], donut: [], heatmap: [], timeline: [], titleFa: "" }; + const rows = data?.rows ?? []; + const kpis = data?.kpis ?? []; return (
@@ -27,4 +28,3 @@ export function AnalyticsOverviewCanvas({ role, filters, onOpenTab }: { role: An
); } - diff --git a/src/components/calendar/EducationalCalendarPage.tsx b/src/components/calendar/EducationalCalendarPage.tsx index 97517c4..7ed5020 100644 --- a/src/components/calendar/EducationalCalendarPage.tsx +++ b/src/components/calendar/EducationalCalendarPage.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Bell, CalendarDays, ChevronLeft, ChevronRight, Download, Filter, Plus, Search, Settings, X } from "lucide-react"; +import { Bell, CalendarDays, ChevronLeft, ChevronRight, Download, Filter, Plus, Search, Settings, X } from "@/components/shared/ui-icons"; import type { UserRole } from "@/config/homeAppsConfig"; import { formatJalaliDate, formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat"; import type { CalendarConnectedItemType, CalendarEvent, CalendarEventStatus, CalendarEventType, CalendarSettings, CalendarViewMode } from "@/types/calendar"; @@ -9,6 +9,8 @@ import { calendarEventTypeDefinitions } from "@/services/mock/calendar/calendarM import { createEvent as storeCreateEvent, getEventById as storeGetEventById, getEvents, getSelectedDate, getSelectedView, getSettings, saveEvents, saveSelectedDate, saveSelectedView, saveSettings } from "@/services/calendar/calendarStorageService"; import { MacToggle } from "@/components/mac/MacToggle"; import { PersianDatePicker, PersianDateTimePicker, PersianTimePicker } from "./PersianDatePicker"; +import { AccessibleDialog } from "@/components/shared/AccessibleDialog"; +import { WindowSplitView } from "@/components/shared/WindowSplitView"; type CalendarRouteMode = "main" | "day" | "my" | "team" | "organization" | "system" | "events" | "create" | "detail" | "edit" | "attendance" | "participants" | "reminders" | "audit" | "reports" | "settings"; @@ -120,24 +122,29 @@ export function EducationalCalendarPage({ role = "org_admin", mode = "main", eve onSettings={() => setSettingsOpen(true)} />
- {view === "month" && ( - { - setAnchorDate(date); - setPrefillDate(toYmd(date)); - setView("day"); - }} - onQuickCreate={(date) => { - setPrefillDate(toYmd(date)); - setCreateOpen(true); - }} - onSelectEvent={setSelectedEvent} - onEventsChange={persistEvents} - /> + isSameDay(event.startAt, anchorDate))} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />} + initialSidebarWidth={280} + > + { + setAnchorDate(date); + setPrefillDate(toYmd(date)); + }} + onQuickCreate={(date) => { + setPrefillDate(toYmd(date)); + setCreateOpen(true); + }} + onSelectEvent={setSelectedEvent} + onEventsChange={persistEvents} + /> + )} {view === "week" && } {view === "day" && isSameDay(event.startAt, anchorDate))} selectedDate={anchorDate} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />} @@ -156,36 +163,34 @@ export function EducationalCalendarPage({ role = "org_admin", mode = "main", eve } export function CalendarShell({ children }: { children: React.ReactNode }) { - return
{children}
; + return
تقویم آموزشی
{children}
; } export function CalendarToolbar({ role, mode, anchorDate, view, query, onQuery, onView, onToday, onNavigate, onCreate, onSettings }: { role: UserRole; mode: CalendarRouteMode; anchorDate: Date; view: CalendarViewMode; query: string; onQuery: (value: string) => void; onView: (value: CalendarViewMode) => void; onToday: () => void; onNavigate: (direction: -1 | 1) => void; onCreate: () => void; onSettings: () => void }) { const title = mode === "my" ? "تقویم من" : mode === "team" ? "تقویم تیم" : mode === "organization" ? "تقویم سازمان" : mode === "system" ? "تقویم کل سامانه" : "تقویم آموزشی"; return ( -
-
-
-
-

{title}

-

{roleLabels[role]} · {formatJalaliDate(anchorDate)}

-
-
-
- - - -
-