update project files

مخزن کامیت contained در
مسعود نیک زاد 2026-08-17 15:05:43 +03:30
والد 4603ae22b7
کامیت 212f1da4fd
207فایلهای تغییر یافته به همراه4772 افزوده شده و 1782 حذف شده

دودویی (BIN)
PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (1).png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.4 MiB

دودویی (BIN)
PNG/ChatGPT Image Aug 2, 2026, 05_49_35 PM (2).png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.2 MiB

دودویی (BIN)
PNG/ChatGPT Image Aug 2, 2026, 05_49_36 PM (3).png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.4 MiB

دودویی (BIN)
PNG/ChatGPT Image Aug 2, 2026, 05_49_41 PM (9).png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.1 MiB

دودویی (BIN)
PNG/ChatGPT Image Aug 2, 2026, 05_49_43 PM (10).png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.0 MiB

دودویی (BIN)
PNG/Notification.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.2 MiB

دودویی (BIN)
PNG/Profile Pop Up.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.1 MiB

دودویی (BIN)
PNG/Search.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.3 MiB

دودویی (BIN)
PNG/Setting Pop Up.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.1 MiB

دودویی (BIN)
PNG/Social Media.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.3 MiB

دودویی (BIN)
artifacts-after-login.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 211 KiB

دودویی (BIN)
artifacts-current-status.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 115 KiB

دودویی (BIN)
artifacts-dashboard-ready.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 723 KiB

دودویی (BIN)
artifacts-dock-menu-open.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 487 KiB

دودویی (BIN)
artifacts-dock-menu.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 721 KiB

دودویی (BIN)
artifacts-employee-dashboard.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 226 KiB

دودویی (BIN)
artifacts-employee-ready.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 979 KiB

دودویی (BIN)
artifacts-my-courses-dragged-2.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.1 MiB

دودویی (BIN)
artifacts-my-courses-dragged.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.1 MiB

دودویی (BIN)
artifacts-my-courses-window.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.1 MiB

دودویی (BIN)
artifacts-social-desktop.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 534 KiB

دودویی (BIN)
artifacts-social-focus.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 76 KiB

دودویی (BIN)
artifacts-social-mobile.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 76 KiB

دودویی (BIN)
artifacts-social-tablet.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 209 KiB

دودویی (BIN)
artifacts-switcher-open.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 664 KiB

دودویی (BIN)
artifacts-ui-login.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 703 KiB

دودویی (BIN)
artifacts-ui-status.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 112 KiB

دودویی (BIN)
artifacts/arad-glass-dashboard-dark.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 775 KiB

دودویی (BIN)
artifacts/arad-glass-dashboard-light.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 831 KiB

دودویی (BIN)
artifacts/arad-glass-login-light-2.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 419 KiB

دودویی (BIN)
artifacts/arad-glass-login-light.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 558 KiB

دودویی (BIN)
artifacts/arad-glass-login-mobile.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 106 KiB

دودویی (BIN)
artifacts/brightness-range-final.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 143 KiB

دودویی (BIN)
artifacts/dock-brightness-status.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 207 KiB

دودویی (BIN)
artifacts/dock-desktop-return-final.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 875 KiB

دودویی (BIN)
artifacts/dock-workspace-final.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 872 KiB

دودویی (BIN)
artifacts/icon-sharpness-launcher-final.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 780 KiB

دودویی (BIN)
artifacts/icon-sharpness-launcher.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 780 KiB

دودویی (BIN)
artifacts/icon-sharpness-window.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 780 KiB

دودویی (BIN)
artifacts/icon-sharpness-workspace.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 839 KiB

دودویی (BIN)
artifacts/ui-audit-20260801/dashboard-1440-valid2.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 182 KiB

دودویی (BIN)
artifacts/ui-audit-20260801/licenses-1024-valid2.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 73 KiB

مشاهده پرونده

@ -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`

44
docs/UI_REFACTOR_CHECKLIST.md normal فایل
مشاهده پرونده

@ -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: موفق، ۴۵ مسیر

مشاهده پرونده

@ -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`: موفق

مشاهده پرونده

@ -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 ساخته شد.

مشاهده پرونده

@ -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
- ثبت نتیجه در همین فایل؛ بدون شاهد، آیتم باز می‌ماند

مشاهده پرونده

@ -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` بازگرداند.

مشاهده پرونده

@ -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 در سقف مصرف این نشست باز مانده‌اند و عمداً تیک نخورده‌اند

22
docs/ui-system-checklist.md normal فایل
مشاهده پرونده

@ -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.

دودویی (BIN)
public/arad-logo-dark.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 988 KiB

دودویی (BIN)
public/arad-logo-light.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 980 KiB

425
scripts/ui-audit-cdp.mjs normal فایل
مشاهده پرونده

@ -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();

مشاهده پرونده

@ -1,42 +1,55 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { notFound, useParams, useRouter } from "next/navigation";
import { homeAppsConfig, isAppDisabledByPlan, type UserRole } from "@/config/homeAppsConfig"; import { ArrowRight } from "@/components/shared/ui-icons";
import { homeAppsConfig, type UserRole } from "@/config/homeAppsConfig";
import { getCurrentUser } from "@/services/api/lmsApi"; import { getCurrentUser } from "@/services/api/lmsApi";
import { MacModuleContent } from "@/components/mac/MacModuleContent"; 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() { export default function AppRoutePage() {
const params = useParams<{ appId: string }>(); const params = useParams<{ appId: string }>();
const router = useRouter();
const appId = params.appId; const appId = params.appId;
const [role, setRole] = useState<UserRole | null>(null); const [role, setRole] = useState<UserRole | null>(null);
const [appearance, setAppearance] = useState<DesktopState | null>(null);
const [licenseAccess, setLicenseAccess] = useState<LicenseAccess | null>(null);
const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
useEffect(() => { 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 <div className="grid min-h-screen place-items-center bg-slate-950 text-white" dir="rtl">در حال بارگذاری...</div>; if (!role || !appearance || !licenseAccess) return <div className="grid min-h-screen place-items-center bg-slate-950 text-white" dir="rtl">در حال بارگذاری...</div>;
const app = homeAppsConfig.find((item) => item.id === appId); const app = homeAppsConfig.find((item) => item.id === appId);
if (!app || !app.roleAccess.includes(role)) return <ForbiddenPage />; if (!app) notFound();
if (isAppDisabledByPlan(role, appId)) return <DisabledByPlanPage />; if (!app.roleAccess.includes(role)) return <ForbiddenPage />;
const resolvedTheme = appearance.theme === "system" ? systemTheme : appearance.theme;
return ( return (
<main className="min-h-screen bg-slate-950 p-4 text-slate-900 dark:text-white" dir="rtl"> <LicenseAccessProvider access={licenseAccess}><AppearanceProvider state={appearance} resolvedTheme={resolvedTheme} className="min-h-dvh"><main className="flex min-h-dvh flex-col bg-[var(--color-bg)] text-[var(--color-text)]" dir="rtl">
<div className="mx-auto h-[calc(100vh-32px)] max-w-7xl"> <header className="sticky top-0 z-40 flex min-h-14 items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 backdrop-blur-xl">
<MacModuleContent appId={appId} role={role} /> <button type="button" onClick={() => router.back()} className="ui-icon-button" aria-label="بازگشت به میز کار"><ArrowRight className="h-5 w-5" /></button>
<div className="min-w-0">
<p className="text-xs text-[var(--color-text-secondary)]">سامانه آموزش سازمانی آراد</p>
<h1 className="truncate font-black">{app.labelFa}</h1>
</div>
</header>
<div id="main-content" className="min-h-0 flex-1 overflow-auto p-3 sm:p-4">
<FeatureShell featureId={appId}><MacModuleContent appId={appId} role={role} /></FeatureShell>
</div> </div>
</main> </main></AppearanceProvider></LicenseAccessProvider>
);
}
function DisabledByPlanPage() {
return (
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
<section className="liquid-glass max-w-md rounded-[28px] p-6 text-center">
<h1 className="text-xl font-black">این بخش در طرح فعلی سازمان فعال نیست.</h1>
<p className="mt-3">برای فعالسازی، با مدیر آموزش سازمان یا پشتیبانی سامانه تماس بگیرید.</p>
</section>
</main>
); );
} }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است Diff را بارگزاری کن

مشاهده پرونده

@ -3,13 +3,21 @@ import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "سامانه آموزش سازمانی آراد", 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 }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="fa" dir="rtl" suppressHydrationWarning> <html lang="fa" dir="rtl" suppressHydrationWarning>
<body>{children}</body> <body><a className="skip-link" href="#main-content">پرش به محتوای اصلی</a>{children}</body>
</html> </html>
); );
} }

مشاهده پرونده

@ -1,5 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function PlanAuditPage() { export default function PlanAuditPage() {
return <PlansPage mode="audit" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -1,6 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function GoldPlanPage() { export default function GoldPlanPage() {
return <PlansPage mode="gold" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -1,6 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function PlanModulesPage() { export default function PlanModulesPage() {
return <PlansPage mode="modules" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -1,6 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function SuperAdminPlansPage() { export default function SuperAdminPlansPage() {
return <PlansPage mode="overview" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -1,6 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function PlatinumPlanPage() { export default function PlatinumPlanPage() {
return <PlansPage mode="platinum" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -1,6 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function PlanPreviewPage() { export default function PlanPreviewPage() {
return <PlansPage mode="preview" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -1,6 +1,5 @@
import { PlansPage } from "@/components/plans/PlansPage"; import { redirect } from "next/navigation";
export default function SilverPlanPage() { export default function SilverPlanPage() {
return <PlansPage mode="silver" />; redirect("/apps/licenses");
} }

مشاهده پرونده

@ -10,6 +10,7 @@ import { AnalyticsDashboardTabContent } from "./AnalyticsDashboardTabContent";
import { AnalyticsDashboardTabs, type AnalyticsTab } from "./AnalyticsDashboardTabs"; import { AnalyticsDashboardTabs, type AnalyticsTab } from "./AnalyticsDashboardTabs";
import { AnalyticsFilterDrawer } from "./components/AnalyticsFilterDrawer"; import { AnalyticsFilterDrawer } from "./components/AnalyticsFilterDrawer";
import { AnalyticsToolbar } from "./components/AnalyticsToolbar"; import { AnalyticsToolbar } from "./components/AnalyticsToolbar";
import { DashboardDataProvider, useDashboardData } from "./DashboardDataProvider";
const defaultFilters: AnalyticsFilterState = { dateRange: "this_month" }; const defaultFilters: AnalyticsFilterState = { dateRange: "this_month" };
@ -48,32 +49,28 @@ export function AnalyticsDashboardExplorer({ role, onToast }: { role: UserRole;
}; };
return ( return (
<div className="flex h-full min-h-0 flex-col text-slate-900 dark:text-white" dir="rtl"> <DashboardDataProvider role={role} filters={filters} refreshToken={refreshKey}>
<AnalyticsDashboardTabs tabs={tabs} activeId={activeTabId} onPick={setActiveTabId} onClose={closeTab} /> <AnalyticsDashboardSurface
<div className="space-y-2 p-3 pb-2"> role={role}
<AnalyticsToolbar items={items}
role={role} tabs={tabs}
filters={filters} activeTabId={activeTabId}
onDateRange={(dateRange) => setFilters((current) => ({ ...current, dateRange }))} query={query}
onFilter={() => setDrawerOpen(true)} filters={filters}
onRefresh={() => setRefreshKey((value) => value + 1)} overviewId={overviewId}
onExport={exportReport} filteredItems={filteredItems}
/> onQuery={setQuery}
<div className="flex items-center gap-2"> onHome={() => { setQuery(""); setActiveTabId(overviewId); }}
<input value={query} onChange={(event) => 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" /> onPickTab={setActiveTabId}
<button onClick={() => { setQuery(""); setActiveTabId(overviewId); }} className="h-9 shrink-0 rounded-[14px] bg-white/14 px-3 text-xs font-bold hover:bg-white/24">خانه</button> onCloseTab={closeTab}
</div> onOpenItem={openItem}
</div> onOpenTab={openTab}
<main key={refreshKey} className="mac-scrollbar min-h-0 flex-1 overflow-auto px-3 pb-4"> onFilter={() => setDrawerOpen(true)}
{activeTabId === "explorer-home" ? ( onDateRange={(dateRange) => setFilters((current) => ({ ...current, dateRange }))}
<AnalyticsDashboardExplorerGrid items={filteredItems} onOpen={openItem} /> onRefresh={() => setRefreshKey((value) => value + 1)}
) : ( onExport={exportReport}
<div className="space-y-4"> onToast={onToast}
<AnalyticsDashboardExplorerGrid items={filteredItems} onOpen={openItem} /> />
<AnalyticsDashboardTabContent role={role} tabId={activeTabId} items={items} filters={filters} onOpenTab={openTab} onToast={onToast} />
</div>
)}
</main>
<AnalyticsFilterDrawer <AnalyticsFilterDrawer
open={drawerOpen} open={drawerOpen}
role={role} role={role}
@ -83,6 +80,64 @@ export function AnalyticsDashboardExplorer({ role, onToast }: { role: UserRole;
onReset={() => setFilters(defaultFilters)} onReset={() => setFilters(defaultFilters)}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
/> />
</DashboardDataProvider>
);
}
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 (
<div className="window-module-shell text-slate-900 dark:text-white" dir="rtl">
<AnalyticsDashboardTabs tabs={props.tabs} activeId={props.activeTabId} onPick={props.onPickTab} onClose={props.onCloseTab} />
<div className="space-y-2 p-3 pb-2">
<AnalyticsToolbar
role={props.role}
filters={props.filters}
onDateRange={props.onDateRange}
onFilter={props.onFilter}
onRefresh={() => { props.onRefresh(); refresh(); }}
onExport={props.onExport}
/>
<div className="flex items-center gap-2">
<input value={props.query} onChange={(event) => 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" />
<button onClick={props.onHome} className="h-9 shrink-0 rounded-[14px] bg-white/14 px-3 text-xs font-bold hover:bg-white/24">خانه</button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[var(--color-text-secondary)]" aria-live="polite">
<span>{loading ? "در حال دریافت داده زنده…" : error ? "اتصال زنده قطع است." : `آخرین بروزرسانی: ${new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(data?.updatedAt ?? Date.now()))}`}</span>
<span className={data?.source === "api" ? "text-emerald-600 dark:text-emerald-400" : "text-amber-700 dark:text-amber-300"}>{data?.source === "api" ? "داده زنده API" : "داده محلی توسعه"}</span>
{error ? <button onClick={refresh} className="min-h-11 rounded-xl px-3 font-bold text-[var(--color-danger)]">تلاش دوباره</button> : null}
</div>
</div>
<main className="popup-scroll min-h-0 flex-1 overflow-auto px-3 pb-4">
{props.activeTabId === "explorer-home" ? (
<AnalyticsDashboardExplorerGrid items={props.filteredItems} onOpen={props.onOpenItem} />
) : (
<div className="space-y-4">
<AnalyticsDashboardExplorerGrid items={props.filteredItems} onOpen={props.onOpenItem} />
<AnalyticsDashboardTabContent role={props.role} tabId={props.activeTabId} items={props.items} filters={props.filters} onOpenTab={props.onOpenTab} onToast={props.onToast} />
</div>
)}
</main>
</div> </div>
); );
} }

مشاهده پرونده

@ -1,7 +1,6 @@
"use client"; "use client";
import type { AnalyticsExplorerItem, AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; import type { AnalyticsExplorerItem, AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
import { getAnalyticsCharts, getAnalyticsRows } from "@/services/mock/analyticsDashboardMockService";
import { AnalyticsChartCard } from "./components/AnalyticsChartCard"; import { AnalyticsChartCard } from "./components/AnalyticsChartCard";
import { AnalyticsDataTable } from "./components/AnalyticsDataTable"; import { AnalyticsDataTable } from "./components/AnalyticsDataTable";
import { ReadyReportsSection } from "./sections/ReadyReportsSection"; import { ReadyReportsSection } from "./sections/ReadyReportsSection";
@ -9,6 +8,7 @@ import { SystemOverviewSection } from "./sections/SystemOverviewSection";
import { OrganizationOverviewSection } from "./sections/OrganizationOverviewSection"; import { OrganizationOverviewSection } from "./sections/OrganizationOverviewSection";
import { TeamOverviewSection } from "./sections/TeamOverviewSection"; import { TeamOverviewSection } from "./sections/TeamOverviewSection";
import { PersonalOverviewSection } from "./sections/PersonalOverviewSection"; import { PersonalOverviewSection } from "./sections/PersonalOverviewSection";
import { useDashboardData } from "./DashboardDataProvider";
export function AnalyticsDashboardTabContent({ export function AnalyticsDashboardTabContent({
role, role,
@ -25,6 +25,7 @@ export function AnalyticsDashboardTabContent({
onOpenTab: (id: string, titleFa: string) => void; onOpenTab: (id: string, titleFa: string) => void;
onToast?: (message: string, tone?: "success" | "error") => void; onToast?: (message: string, tone?: "success" | "error") => void;
}) { }) {
const { data } = useDashboardData();
if (tabId === "system-overview") return <SystemOverviewSection filters={filters} onOpenTab={onOpenTab} />; if (tabId === "system-overview") return <SystemOverviewSection filters={filters} onOpenTab={onOpenTab} />;
if (tabId === "organization-overview") return <OrganizationOverviewSection filters={filters} onOpenTab={onOpenTab} />; if (tabId === "organization-overview") return <OrganizationOverviewSection filters={filters} onOpenTab={onOpenTab} />;
if (tabId === "team-overview") return <TeamOverviewSection filters={filters} onOpenTab={onOpenTab} />; if (tabId === "team-overview") return <TeamOverviewSection filters={filters} onOpenTab={onOpenTab} />;
@ -32,8 +33,8 @@ export function AnalyticsDashboardTabContent({
if (tabId === "ready-reports") return <ReadyReportsSection role={role} onReport={(message) => onToast?.(message)} onOpenTab={onOpenTab} />; if (tabId === "ready-reports") return <ReadyReportsSection role={role} onReport={(message) => onToast?.(message)} onOpenTab={onOpenTab} />;
const item = items.find((candidate) => candidate.id === tabId); const item = items.find((candidate) => candidate.id === tabId);
const rows = getAnalyticsRows(role, tabId); const rows = data?.rows ?? [];
const charts = getAnalyticsCharts(role, filters); const charts = data?.charts ?? { line: [], bar: [], stacked: [], donut: [], heatmap: [], timeline: [], titleFa: "" };
const detailTitle = item?.titleFa ?? titleFromDetail(tabId); const detailTitle = item?.titleFa ?? titleFromDetail(tabId);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -58,4 +59,3 @@ function titleFromDetail(tabId: string) {
if (tabId.includes("trend")) return "روند"; if (tabId.includes("trend")) return "روند";
return "جزئیات"; return "جزئیات";
} }

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "use client";
import { X } from "lucide-react"; import { X } from "@/components/shared/ui-icons";
export type AnalyticsTab = { id: string; titleFa: string }; export type AnalyticsTab = { id: string; titleFa: string };

مشاهده پرونده

@ -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<DashboardDataContextValue | null>(null);
export function DashboardDataProvider({ role, filters, refreshToken, children }: { role: AnalyticsRole; filters: AnalyticsFilterState; refreshToken: number; children: ReactNode }) {
const [data, setData] = useState<AnalyticsDashboardData | null>(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 <DashboardDataContext.Provider value={value}>{children}</DashboardDataContext.Provider>;
}
export function useDashboardData() {
const value = useContext(DashboardDataContext);
if (!value) throw new Error("useDashboardData must be used inside DashboardDataProvider");
return value;
}

مشاهده پرونده

@ -4,8 +4,22 @@ import type { AnalyticsTableRow } from "@/types/analyticsDashboard";
export function AnalyticsDataTable({ rows }: { rows: AnalyticsTableRow[] }) { export function AnalyticsDataTable({ rows }: { rows: AnalyticsTableRow[] }) {
return ( return (
<div className="liquid-glass overflow-auto rounded-[20px] mac-scrollbar"> <div>
<div className="grid gap-3 md:hidden" aria-label="داده‌های گزارش">
{rows.map((row) => (
<article key={row.id} className="ui-data-card">
<div className="flex items-start justify-between gap-3"><h3 className="font-black">{row.titleFa}</h3><span className={`rounded-full px-2 py-1 text-xs font-bold ${row.statusFa.includes("پیگیری") ? "bg-amber-500/18 text-amber-800 dark:text-amber-200" : "bg-emerald-500/18 text-emerald-800 dark:text-emerald-200"}`}>{row.statusFa}</span></div>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
<div><dt className="text-[var(--color-text-secondary)]">مالک</dt><dd className="font-bold">{row.ownerFa}</dd></div>
<div><dt className="text-[var(--color-text-secondary)]">شاخص</dt><dd className="font-black">{row.valueFa}</dd></div>
<div className="col-span-2"><dt className="text-[var(--color-text-secondary)]">روند</dt><dd>{row.trendFa}</dd></div>
</dl>
</article>
))}
</div>
<div className="content-surface hidden overflow-auto rounded-[20px] md:block mac-scrollbar">
<table className="w-full min-w-[680px] text-sm"> <table className="w-full min-w-[680px] text-sm">
<caption className="sr-only">جدول شاخصها و روند گزارش تحلیلی</caption>
<thead className="sticky top-0 z-10 bg-white/70 backdrop-blur-xl dark:bg-slate-900/78"> <thead className="sticky top-0 z-10 bg-white/70 backdrop-blur-xl dark:bg-slate-900/78">
<tr>{["عنوان", "مالک", "وضعیت", "شاخص", "روند"].map((header) => <th key={header} className="p-3 text-right text-xs font-black">{header}</th>)}</tr> <tr>{["عنوان", "مالک", "وضعیت", "شاخص", "روند"].map((header) => <th key={header} className="p-3 text-right text-xs font-black">{header}</th>)}</tr>
</thead> </thead>
@ -21,7 +35,8 @@ export function AnalyticsDataTable({ rows }: { rows: AnalyticsTableRow[] }) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
{!rows.length ? <p className="ui-data-card text-center text-sm">دادهای برای نمایش وجود ندارد.</p> : null}
</div> </div>
); );
} }

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "use client";
import { SearchX } from "lucide-react"; import { SearchX } from "@/components/shared/ui-icons";
import { MacButton } from "@/components/mac/MacUI"; import { MacButton } from "@/components/mac/MacUI";
export function AnalyticsEmptyState({ onReset }: { onReset?: () => void }) { export function AnalyticsEmptyState({ onReset }: { onReset?: () => void }) {

مشاهده پرونده

@ -2,6 +2,7 @@
import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
import { MacButton } from "@/components/mac/MacUI"; import { MacButton } from "@/components/mac/MacUI";
import { AccessibleDialog } from "@/components/shared/AccessibleDialog";
const choices = { const choices = {
organizationId: ["همه سازمان‌ها", "وزارت نیرو", "هلدینگ پارس"], 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<AnalyticsFilterState>) => void; onApply: () => void; onReset: () => void; onClose: () => void }) { export function AnalyticsFilterDrawer({ open, role, filters, onChange, onApply, onReset, onClose }: { open: boolean; role: AnalyticsRole; filters: AnalyticsFilterState; onChange: (patch: Partial<AnalyticsFilterState>) => void; onApply: () => void; onReset: () => void; onClose: () => void }) {
if (!open) return null;
const fields = role === "super_admin" const fields = role === "super_admin"
? ["organizationId", "status", "courseId", "examId", "classId", "learningPathId", "userId"] ? ["organizationId", "status", "courseId", "examId", "classId", "learningPathId", "userId"]
: role === "org_admin" : role === "org_admin"
@ -27,21 +27,15 @@ export function AnalyticsFilterDrawer({ open, role, filters, onChange, onApply,
? ["userId", "courseId", "examId", "classId", "learningPathId", "status"] ? ["userId", "courseId", "examId", "classId", "learningPathId", "status"]
: ["courseId", "examId", "learningPathId", "status"]; : ["courseId", "examId", "learningPathId", "status"];
return ( return (
<div className="fixed inset-0 z-[260] bg-black/25" onClick={onClose}> <AccessibleDialog open={open} title="فیلترهای گزارش" description="محدوده داده‌های داشبورد را مشخص کنید." onClose={onClose} as="aside" panelClassName="max-w-sm mac-scrollbar" overlayClassName="justify-start sm:p-4">
<aside className="liquid-glass absolute left-4 top-16 max-h-[calc(100vh-92px)] w-[min(360px,calc(100vw-32px))] overflow-auto rounded-[26px] p-4 text-slate-900 dark:text-white mac-scrollbar" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center justify-between">
<h3 className="font-black">فیلترها</h3>
<MacButton intent="negative" className="min-h-8 px-3 py-1 text-xs" onClick={onClose}>بستن</MacButton>
</div>
<div className="mt-4 grid gap-3"> <div className="mt-4 grid gap-3">
{fields.map((field) => <FilterSelect key={field} field={field as keyof typeof choices} value={String(filters[field as keyof AnalyticsFilterState] ?? "")} onChange={(value) => onChange({ [field]: value } as Partial<AnalyticsFilterState>)} />)} {fields.map((field) => <FilterSelect key={field} field={field as keyof typeof choices} value={String(filters[field as keyof AnalyticsFilterState] ?? "")} onChange={(value) => onChange({ [field]: value } as Partial<AnalyticsFilterState>)} />)}
</div> </div>
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<MacButton intent="positive" className="flex-1" onClick={onApply}>اعمال فیلتر</MacButton> <MacButton intent="positive" className="flex-1" onClick={onApply}>اعمال فیلتر</MacButton>
<MacButton intent="negative" className="flex-1" onClick={onReset}>پاک کردن فیلتر</MacButton> <MacButton intent="neutral" className="flex-1" onClick={onReset}>پاک کردن فیلتر</MacButton>
</div> </div>
</aside> </AccessibleDialog>
</div>
); );
} }
@ -56,4 +50,3 @@ function FilterSelect({ field, value, onChange }: { field: keyof typeof choices;
</label> </label>
); );
} }

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "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 { MacButton, MacIconButton } from "@/components/mac/MacUI";
import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
import { analyticsDateRangeLabels } from "@/config/analyticsDashboardConfig"; import { analyticsDateRangeLabels } from "@/config/analyticsDashboardConfig";

مشاهده پرونده

@ -2,7 +2,7 @@
import { toPersianDigits } from "@/lib/persianFormat"; import { toPersianDigits } from "@/lib/persianFormat";
import type { AnalyticsKpiCard } from "@/types/analyticsDashboard"; 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 }) { 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"; 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";

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "use client";
import { FileText } from "lucide-react"; import { FileText } from "@/components/shared/ui-icons";
import { MacButton } from "@/components/mac/MacUI"; import { MacButton } from "@/components/mac/MacUI";
import { getReadyReports } from "@/services/mock/analyticsDashboardMockService"; import { getReadyReports } from "@/services/mock/analyticsDashboardMockService";
import type { AnalyticsRole } from "@/types/analyticsDashboard"; import type { AnalyticsRole } from "@/types/analyticsDashboard";

مشاهده پرونده

@ -1,15 +1,16 @@
"use client"; "use client";
import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard"; import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
import { getAnalyticsCharts, getAnalyticsKpis, getAnalyticsRows } from "@/services/mock/analyticsDashboardMockService";
import { KpiCard } from "../components/KpiCard"; import { KpiCard } from "../components/KpiCard";
import { AnalyticsChartCard } from "../components/AnalyticsChartCard"; import { AnalyticsChartCard } from "../components/AnalyticsChartCard";
import { AnalyticsDataTable } from "../components/AnalyticsDataTable"; 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 }) { export function AnalyticsOverviewCanvas({ role, filters, onOpenTab }: { role: AnalyticsRole; filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) {
const charts = getAnalyticsCharts(role, filters); const { data } = useDashboardData();
const rows = getAnalyticsRows(role, "overview"); const charts = data?.charts ?? { line: [], bar: [], stacked: [], donut: [], heatmap: [], timeline: [], titleFa: "" };
const kpis = getAnalyticsKpis(role); const rows = data?.rows ?? [];
const kpis = data?.kpis ?? [];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
@ -27,4 +28,3 @@ export function AnalyticsOverviewCanvas({ role, filters, onOpenTab }: { role: An
</div> </div>
); );
} }

مشاهده پرونده

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; 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 type { UserRole } from "@/config/homeAppsConfig";
import { formatJalaliDate, formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat"; import { formatJalaliDate, formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat";
import type { CalendarConnectedItemType, CalendarEvent, CalendarEventStatus, CalendarEventType, CalendarSettings, CalendarViewMode } from "@/types/calendar"; 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 { 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 { MacToggle } from "@/components/mac/MacToggle";
import { PersianDatePicker, PersianDateTimePicker, PersianTimePicker } from "./PersianDatePicker"; 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"; 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)} onSettings={() => setSettingsOpen(true)}
/> />
<section className="min-h-0 flex-1 overflow-hidden rounded-[28px] border border-white/20 bg-white/20 p-3 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/30"> <section className="min-h-0 flex-1 overflow-hidden rounded-[28px] border border-white/20 bg-white/20 p-3 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/30">
<CalendarViewSwitcher view={view} onView={setView} />
{view === "month" && ( {view === "month" && (
<CalendarMonthView <WindowSplitView
events={visibleEvents} className="h-full"
anchorDate={anchorDate} sidebarLabel="جزئیات روز انتخاب‌شده"
selectedDate={anchorDate} sidebar={<CalendarSelectedDayPanel date={anchorDate} events={visibleEvents.filter((event) => isSameDay(event.startAt, anchorDate))} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />}
onSelectDay={(date) => { initialSidebarWidth={280}
setAnchorDate(date); >
setPrefillDate(toYmd(date)); <CalendarMonthView
setView("day"); events={visibleEvents}
}} anchorDate={anchorDate}
onQuickCreate={(date) => { selectedDate={anchorDate}
setPrefillDate(toYmd(date)); onSelectDay={(date) => {
setCreateOpen(true); setAnchorDate(date);
}} setPrefillDate(toYmd(date));
onSelectEvent={setSelectedEvent} }}
onEventsChange={persistEvents} onQuickCreate={(date) => {
/> setPrefillDate(toYmd(date));
setCreateOpen(true);
}}
onSelectEvent={setSelectedEvent}
onEventsChange={persistEvents}
/>
</WindowSplitView>
)} )}
{view === "week" && <CalendarWeekView events={visibleEvents} anchorDate={anchorDate} onSelectEvent={setSelectedEvent} />} {view === "week" && <CalendarWeekView events={visibleEvents} anchorDate={anchorDate} onSelectEvent={setSelectedEvent} />}
{view === "day" && <CalendarDayView events={visibleEvents.filter((event) => isSameDay(event.startAt, anchorDate))} selectedDate={anchorDate} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />} {view === "day" && <CalendarDayView events={visibleEvents.filter((event) => 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 }) { export function CalendarShell({ children }: { children: React.ReactNode }) {
return <main className="persian-digits flex h-full min-h-[720px] flex-col overflow-hidden rounded-[32px] bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,.22),transparent_32%),linear-gradient(135deg,rgba(248,250,252,.88),rgba(226,232,240,.6))] p-4 text-slate-900 dark:bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,.12),transparent_34%),linear-gradient(135deg,rgba(2,6,23,.92),rgba(15,23,42,.82))] dark:text-white" dir="rtl">{children}</main>; return <main className="window-module-shell persian-digits w-full max-w-full" dir="rtl"><header className="mac-window-handle window-module-toolbar window-controls-safe-row flex min-h-11 cursor-move items-center py-1.5"><span className="truncate text-sm font-black">تقویم آموزشی</span></header><div className="flex min-h-0 flex-1 flex-col p-3">{children}</div></main>;
} }
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 }) { 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" ? "تقویم کل سامانه" : "تقویم آموزشی"; const title = mode === "my" ? "تقویم من" : mode === "team" ? "تقویم تیم" : mode === "organization" ? "تقویم سازمان" : mode === "system" ? "تقویم کل سامانه" : "تقویم آموزشی";
return ( return (
<header className="mb-4 flex flex-wrap items-center gap-3 rounded-[28px] border border-white/30 bg-white/35 p-3 shadow-xl backdrop-blur-2xl dark:border-white/10 dark:bg-white/10"> <header className="window-calendar-toolbar mb-3 grid min-h-14 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-card)] px-3 py-2 shadow-sm">
<div className="flex min-w-[220px] flex-1 items-center gap-3"> <div className="flex min-w-0 items-center gap-2">
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-sky-500 text-white shadow-lg"><CalendarDays size={24} /></div> <button onClick={onToday} className="min-h-11 rounded-xl bg-[var(--color-selected)] px-3 text-sm font-black">امروز</button>
<div> <select aria-label="نوع نمایش تقویم" value={view} onChange={(event) => onView(event.target.value as CalendarViewMode)} className="h-11 min-h-11 w-28 rounded-xl px-3 text-sm">
<h1 className="text-xl font-black">{title}</h1> {(["day", "week", "month", "list", "timeline"] as CalendarViewMode[]).map((item) => <option key={item} value={item}>{viewLabels[item]}</option>)}
<p className="text-xs text-slate-600 dark:text-white/65">{roleLabels[role]} · {formatJalaliDate(anchorDate)}</p> </select>
</div> <label className="window-calendar-search ui-search-box relative hidden min-w-0 flex-1 lg:block">
</div>
<div className="flex items-center gap-1 rounded-2xl bg-white/35 p-1 dark:bg-white/10" aria-label="ناوبری تقویم">
<button aria-label="بعدی" onClick={() => onNavigate(1)} className="rounded-xl p-2 hover:bg-white/45"><ChevronRight size={18} /></button>
<button onClick={onToday} className="rounded-xl px-3 py-2 text-sm font-bold hover:bg-white/45">امروز</button>
<button aria-label="قبلی" onClick={() => onNavigate(-1)} className="rounded-xl p-2 hover:bg-white/45"><ChevronLeft size={18} /></button>
</div>
<label className="relative min-w-[220px] flex-1 md:max-w-xs">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500" size={17} /> <Search className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500" size={17} />
<input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="جستجو در رویداد، دوره، آزمون، مدرس..." className="w-full rounded-2xl border border-white/25 bg-white/40 py-3 pl-3 pr-10 text-sm outline-none focus:ring-2 focus:ring-sky-400/40 dark:bg-white/10" /> <input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="جستجوی رویداد..." className="h-11 w-full rounded-xl py-2 pl-3 pr-10 text-sm" />
</label> </label>
<button className="rounded-2xl bg-white/35 p-3 hover:bg-white/55 dark:bg-white/10" aria-label="فیلترها"><Filter size={18} /></button> </div>
<button className="rounded-2xl bg-white/35 p-3 hover:bg-white/55 dark:bg-white/10" aria-label="خروجی گرفتن"><Download size={18} /></button> <div className="flex items-center gap-1" aria-label="ناوبری تقویم">
<button onClick={onSettings} className="rounded-2xl bg-white/35 p-3 hover:bg-white/55 dark:bg-white/10" aria-label="تنظیمات"><Settings size={18} /></button> <button aria-label="ماه بعد" onClick={() => onNavigate(1)} className="ui-icon-button !h-11 !w-11"><ChevronRight size={18} /></button>
{role !== "employee" && <button onClick={onCreate} className="inline-flex items-center gap-2 rounded-2xl bg-slate-950 px-4 py-3 text-sm font-black text-white shadow-lg dark:bg-white dark:text-slate-950"><Plus size={18} />ایجاد رویداد</button>} <div className="min-w-36 text-center"><h1 className="text-base font-black">{new Intl.DateTimeFormat("fa-IR", { month: "long", year: "numeric" }).format(anchorDate)}</h1><span className="sr-only">{title}، {roleLabels[role]}</span></div>
<select value={view} onChange={(event) => onView(event.target.value as CalendarViewMode)} className="rounded-2xl px-3 py-2 text-sm"> <button aria-label="ماه قبل" onClick={() => onNavigate(-1)} className="ui-icon-button !h-11 !w-11"><ChevronLeft size={18} /></button>
{(["day", "week", "month", "list", "timeline", "today", "upcoming", "overdue"] as CalendarViewMode[]).map((item) => <option key={item} value={item}>{viewLabels[item]}</option>)} </div>
</select> <div className="window-toolbar-actions flex min-w-0 items-center justify-end gap-1">
<button className="ui-icon-button !h-11 !w-11" aria-label="فیلترها"><Filter size={17} /></button>
<button className="ui-icon-button !hidden !h-11 !w-11 sm:!inline-flex" aria-label="خروجی گرفتن"><Download size={17} /></button>
<button onClick={onSettings} className="ui-icon-button !h-11 !w-11" aria-label="تنظیمات"><Settings size={17} /></button>
{role !== "employee" && <button onClick={onCreate} className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-[var(--color-text)] px-3 text-sm font-black text-[var(--color-bg-soft)]"><Plus size={17} /><span className="window-toolbar-action-label hidden sm:inline">رویداد جدید</span></button>}
</div>
</header> </header>
); );
} }
@ -198,11 +203,13 @@ export function CalendarMonthView({ events, anchorDate, selectedDate, onSelectDa
const days = buildMonthDays(anchorDate); const days = buildMonthDays(anchorDate);
const [dragId, setDragId] = useState<string | null>(null); const [dragId, setDragId] = useState<string | null>(null);
return ( return (
<div className="grid h-[calc(100%-52px)] min-h-[520px] grid-rows-[auto_1fr] overflow-hidden rounded-3xl border border-white/25 bg-white/25 dark:bg-white/5"> <div className="grid h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-2">
<div className="grid grid-cols-7 border-b border-white/20">{weekDaysFa.map((day) => <div key={day} className="p-3 text-center text-xs font-black text-slate-600 dark:text-white/65">{day}</div>)}</div> <div className="grid grid-cols-7">{weekDaysFa.map((day) => <div key={day} title={day} className="p-2 text-center text-xs font-black text-[var(--color-text-muted)]">{day.slice(0, 1)}</div>)}</div>
<div className="grid min-h-0 grid-cols-7 grid-rows-6"> <div className="grid min-h-0 grid-cols-7 grid-rows-6 gap-1.5">
{days.map((day) => { {days.map((day) => {
const dayEvents = events.filter((event) => isSameDay(event.startAt, day.date)); const dayEvents = events.filter((event) => isSameDay(event.startAt, day.date));
const today = isSameDay(day.date, new Date());
const holiday = day.date.getDay() === 5;
return ( return (
<div <div
key={day.date.toISOString()} key={day.date.toISOString()}
@ -218,19 +225,19 @@ export function CalendarMonthView({ events, anchorDate, selectedDate, onSelectDa
onEventsChange(next); onEventsChange(next);
setDragId(null); setDragId(null);
}} }}
className={`flex min-h-0 flex-col overflow-hidden border-b border-l border-white/15 p-2 text-right outline-none transition hover:bg-white/25 focus:ring-2 focus:ring-sky-400/40 ${day.currentMonth ? "" : "opacity-45"} ${isSameDay(day.date, selectedDate) ? "bg-sky-400/15 ring-1 ring-sky-400/40" : ""}`} aria-label={`${day.currentMonth ? "" : "خارج از ماه، "}${holiday ? "تعطیل، " : ""}${dayEvents.length} رویداد`}
className={`group flex min-h-0 flex-col overflow-hidden rounded-[14px] border border-[var(--color-border)] p-2 text-right outline-none transition hover:bg-[var(--color-selected)] focus:ring-2 focus:ring-[var(--color-focus-ring)] ${day.currentMonth ? "bg-[var(--color-card)]" : "bg-[var(--color-bg-soft)] text-[var(--color-text-muted)] opacity-55"} ${holiday ? "border-t-rose-400/70" : ""} ${isSameDay(day.date, selectedDate) ? "border-emerald-500 bg-emerald-500/10 ring-1 ring-inset ring-emerald-500" : ""}`}
> >
<div className="flex shrink-0 items-center justify-between gap-2"> <div className="flex shrink-0 items-center justify-between gap-2">
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-white/45 px-2 text-sm font-black dark:bg-white/10">{toPersianDigits(day.date.getDate())}</span> <span className={`inline-flex h-7 min-w-7 items-center justify-center rounded-full px-2 text-sm font-black ${today ? "bg-sky-600 text-white" : holiday ? "bg-rose-500/15 text-rose-700 dark:text-rose-300" : "bg-[var(--color-surface-strong)]"}`}>{toPersianDigits(day.date.getDate())}</span>
<button onClick={(event) => { event.stopPropagation(); onQuickCreate(day.date); }} className="hidden rounded-xl bg-white/30 px-2 py-1 text-xs font-bold hover:bg-white/50 md:inline-flex">ایجاد</button> <button onClick={(event) => { event.stopPropagation(); onQuickCreate(day.date); }} className="hidden rounded-xl bg-[var(--color-selected)] px-2 py-1 text-xs font-bold text-[var(--color-accent)] opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100 md:inline-flex">ایجاد</button>
</div> </div>
<div className="mt-2 min-h-0 flex-1 space-y-1 overflow-y-auto rounded-2xl pr-1 mac-scrollbar"> <div className="mt-2 min-h-0 flex-1 space-y-1 overflow-hidden rounded-2xl">
{dayEvents.map((event, index) => ( {dayEvents.slice(0, 2).map((event) => (
<CalendarEventCard <CalendarEventCard
key={event.id} key={event.id}
event={event} event={event}
compact compact
className={index > 1 ? "hidden md:block" : ""}
onClick={(clickEvent) => { onClick={(clickEvent) => {
clickEvent.stopPropagation(); clickEvent.stopPropagation();
onSelectEvent(event); onSelectEvent(event);
@ -241,7 +248,7 @@ export function CalendarMonthView({ events, anchorDate, selectedDate, onSelectDa
}} }}
/> />
))} ))}
{dayEvents.length > 2 && <button onClick={(event) => { event.stopPropagation(); onSelectDay(day.date); }} className="rounded-xl bg-slate-950/80 px-2 py-1 text-xs font-bold text-white md:hidden">مشاهده همه</button>} {dayEvents.length > 2 && <button onClick={(event) => { event.stopPropagation(); onSelectDay(day.date); }} className="w-full rounded-xl bg-[var(--color-selected)] px-2 py-1 text-xs font-bold text-[var(--color-accent)]">+{toPersianDigits(dayEvents.length - 2)} مورد</button>}
</div> </div>
</div> </div>
); );
@ -251,9 +258,36 @@ export function CalendarMonthView({ events, anchorDate, selectedDate, onSelectDa
); );
} }
function CalendarSelectedDayPanel({ date, events, onCreate, onSelectEvent }: { date: Date; events: CalendarEvent[]; onCreate: () => void; onSelectEvent: (event: CalendarEvent) => void }) {
return (
<aside className="popup-scroll hidden min-h-0 overflow-auto rounded-[20px] border border-[var(--color-border)] bg-[var(--color-card)] xl:block">
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-[var(--color-divider)] bg-[var(--color-surface-strong)] p-4">
<div>
<p className="text-xs font-bold text-[var(--color-text-muted)]">روز انتخابشده</p>
<h2 className="mt-1 font-black">{formatJalaliDate(date)}</h2>
</div>
<button type="button" onClick={onCreate} className="ui-icon-button !h-11 !w-11" aria-label="ایجاد رویداد در این روز"><Plus size={17} /></button>
</div>
<div className="space-y-2 p-4">
{events.length ? events.map((event) => (
<CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />
)) : (
<div className="grid min-h-52 place-items-center rounded-[18px] border border-dashed border-[var(--color-border)] bg-[var(--color-bg-soft)] p-4 text-center">
<div>
<CalendarDays className="mx-auto text-[var(--color-text-muted)]" size={30} />
<p className="mt-3 text-sm font-bold text-[var(--color-text-secondary)]">برای این روز برنامهای ثبت نشده است.</p>
<button type="button" onClick={onCreate} className="mt-4 min-h-11 rounded-xl bg-[var(--color-selected)] px-4 text-sm font-black text-[var(--color-accent)]">ایجاد رویداد</button>
</div>
</div>
)}
</div>
</aside>
);
}
export function CalendarDayView({ events, selectedDate, onCreate, onSelectEvent }: { events: CalendarEvent[]; selectedDate: Date; onCreate: () => void; onSelectEvent: (event: CalendarEvent) => void }) { export function CalendarDayView({ events, selectedDate, onCreate, onSelectEvent }: { events: CalendarEvent[]; selectedDate: Date; onCreate: () => void; onSelectEvent: (event: CalendarEvent) => void }) {
return ( return (
<div className="h-[calc(100%-52px)] overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5 mac-scrollbar"> <div className="popup-scroll h-full overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5">
<div className="sticky top-0 z-10 -mx-4 -mt-4 mb-4 flex flex-wrap items-center justify-between gap-3 bg-white/70 p-4 backdrop-blur-xl dark:bg-slate-950/70"> <div className="sticky top-0 z-10 -mx-4 -mt-4 mb-4 flex flex-wrap items-center justify-between gap-3 bg-white/70 p-4 backdrop-blur-xl dark:bg-slate-950/70">
<div> <div>
<h2 className="text-xl font-black">{formatJalaliDate(selectedDate)}</h2> <h2 className="text-xl font-black">{formatJalaliDate(selectedDate)}</h2>
@ -268,16 +302,16 @@ export function CalendarDayView({ events, selectedDate, onCreate, onSelectEvent
export function CalendarWeekView({ events, anchorDate, onSelectEvent }: { events: CalendarEvent[]; anchorDate: Date; onSelectEvent: (event: CalendarEvent) => void }) { export function CalendarWeekView({ events, anchorDate, onSelectEvent }: { events: CalendarEvent[]; anchorDate: Date; onSelectEvent: (event: CalendarEvent) => void }) {
const week = buildWeekDays(anchorDate); const week = buildWeekDays(anchorDate);
return <div className="grid h-[calc(100%-52px)] min-h-[520px] grid-cols-7 overflow-auto rounded-3xl border border-white/20 bg-white/20 dark:bg-white/5 mac-scrollbar">{week.map((day) => <div key={day.toISOString()} className="border-l border-white/15 p-3"><h3 className="sticky top-0 rounded-2xl bg-white/45 p-2 text-center text-xs font-black backdrop-blur-xl dark:bg-slate-950/45">{formatJalaliDate(day)}</h3><div className="mt-3 space-y-2">{events.filter((event) => isSameDay(event.startAt, day)).map((event) => <CalendarEventCard key={event.id} event={event} compact onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>)}</div>; return <div className="popup-scroll grid h-full min-h-[520px] grid-cols-7 overflow-auto rounded-3xl border border-white/20 bg-white/20 dark:bg-white/5">{week.map((day) => <div key={day.toISOString()} className="border-l border-white/15 p-3"><h3 className="sticky top-0 rounded-2xl bg-white/45 p-2 text-center text-xs font-black backdrop-blur-xl dark:bg-slate-950/45">{formatJalaliDate(day)}</h3><div className="mt-3 space-y-2">{events.filter((event) => isSameDay(event.startAt, day)).map((event) => <CalendarEventCard key={event.id} event={event} compact onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>)}</div>;
} }
export function CalendarListView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) { export function CalendarListView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) {
if (!events.length) return <CalendarEmptyState />; if (!events.length) return <CalendarEmptyState />;
return <div className="h-[calc(100%-52px)] overflow-auto rounded-3xl border border-white/20 bg-white/20 p-3 dark:bg-white/5 mac-scrollbar"><div className="space-y-2">{events.map((event) => <CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>; return <div className="popup-scroll h-full overflow-auto rounded-3xl border border-white/20 bg-white/20 p-3 dark:bg-white/5"><div className="space-y-2">{events.map((event) => <CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>;
} }
export function CalendarTimelineView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) { export function CalendarTimelineView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) {
return <div className="h-[calc(100%-52px)] overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5 mac-scrollbar"><div className="relative space-y-4 before:absolute before:right-4 before:top-0 before:h-full before:w-px before:bg-slate-400/40">{events.map((event) => <button key={event.id} onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} className="relative block w-full pr-10 text-right"><span className="absolute right-2 top-4 h-4 w-4 rounded-full ring-4 ring-white/40" style={{ backgroundColor: event.color }} /><CalendarEventCard event={event} detailed /></button>)}</div></div>; return <div className="popup-scroll h-full overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5"><div className="relative space-y-4 before:absolute before:right-4 before:top-0 before:h-full before:w-px before:bg-slate-400/40">{events.map((event) => <button key={event.id} onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} className="relative block w-full pr-10 text-right"><span className="absolute right-2 top-4 h-4 w-4 rounded-full ring-4 ring-white/40" style={{ backgroundColor: event.color }} /><CalendarEventCard event={event} detailed /></button>)}</div></div>;
} }
export function CalendarEventCard({ event, compact, detailed, className = "", onClick, onDragStart }: { event: CalendarEvent; compact?: boolean; detailed?: boolean; className?: string; onClick?: (event: React.MouseEvent) => void; onDragStart?: (event: React.DragEvent) => void }) { export function CalendarEventCard({ event, compact, detailed, className = "", onClick, onDragStart }: { event: CalendarEvent; compact?: boolean; detailed?: boolean; className?: string; onClick?: (event: React.MouseEvent) => void; onDragStart?: (event: React.DragEvent) => void }) {
@ -305,8 +339,8 @@ export function CalendarEventDetail({ event, role, onClose }: { event: CalendarE
const type = getType(event.type); const type = getType(event.type);
const canEdit = role === "super_admin" || role === "org_admin" || (role === "manager" && event.type === "team_session"); const canEdit = role === "super_admin" || role === "org_admin" || (role === "manager" && event.type === "team_session");
return ( return (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/25 p-4 backdrop-blur-sm" role="dialog" aria-label="جزئیات رویداد"> <AccessibleDialog open title="جزئیات رویداد" onClose={onClose} showClose={false} panelClassName="popup-scroll max-w-2xl overflow-auto">
<section className="liquid-glass max-h-[92vh] w-full max-w-2xl overflow-auto rounded-[30px] p-5 shadow-2xl mac-scrollbar"> <section>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div><p className="text-sm font-bold" style={{ color: event.color }}>{type.labelFa}</p><h2 className="mt-1 text-2xl font-black">{event.title}</h2><p className="mt-2 text-sm opacity-70">{formatJalaliDateTime(event.startAt)} تا {formatJalaliDateTime(event.endAt)}</p></div> <div><p className="text-sm font-bold" style={{ color: event.color }}>{type.labelFa}</p><h2 className="mt-1 text-2xl font-black">{event.title}</h2><p className="mt-2 text-sm opacity-70">{formatJalaliDateTime(event.startAt)} تا {formatJalaliDateTime(event.endAt)}</p></div>
<button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button> <button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button>
@ -327,7 +361,7 @@ export function CalendarEventDetail({ event, role, onClose }: { event: CalendarE
{canEdit && <button className="rounded-2xl bg-white/25 px-4 py-2 text-sm font-bold">ویرایش</button>} {canEdit && <button className="rounded-2xl bg-white/25 px-4 py-2 text-sm font-bold">ویرایش</button>}
</div> </div>
</section> </section>
</div> </AccessibleDialog>
); );
} }
@ -346,7 +380,7 @@ export function CalendarEventActions({ event }: { event: CalendarEvent }) {
} }
export function CalendarEventCreateSheet({ role, initialDate, onClose, onCreate }: { role: UserRole; initialDate: string; onClose: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) { export function CalendarEventCreateSheet({ role, initialDate, onClose, onCreate }: { role: UserRole; initialDate: string; onClose: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) {
return <div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/25 p-4 backdrop-blur-sm"><section className="liquid-glass max-h-[92vh] w-full max-w-3xl overflow-auto rounded-[30px] p-5 mac-scrollbar"><div className="mb-4 flex items-center justify-between"><h2 className="text-2xl font-black">ایجاد رویداد</h2><button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button></div><CalendarEventCreateForm role={role} initialDate={initialDate} onCancel={onClose} onCreate={onCreate} /></section></div>; return <AccessibleDialog open title="ایجاد رویداد" description="اطلاعات رویداد را در چهار مرحله کوتاه تکمیل کنید." onClose={onClose} panelClassName="flex h-[min(620px,calc(100%-16px))] max-w-3xl flex-col overflow-hidden"><CalendarEventCreateForm role={role} initialDate={initialDate} onCancel={onClose} onCreate={onCreate} /></AccessibleDialog>;
} }
export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate }: { role: UserRole; initialDate: string; onCancel: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) { export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate }: { role: UserRole; initialDate: string; onCancel: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) {
@ -364,24 +398,40 @@ export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate
const [reminder, setReminder] = useState("۶۰"); const [reminder, setReminder] = useState("۶۰");
const [status, setStatus] = useState<CalendarEventStatus>("scheduled"); const [status, setStatus] = useState<CalendarEventStatus>("scheduled");
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const [step, setStep] = useState(0);
useEffect(() => { useEffect(() => {
setStartDate(initialDate); setStartDate(initialDate);
setEndDate(initialDate); setEndDate(initialDate);
}, [initialDate]); }, [initialDate]);
const submit = (event: React.FormEvent) => { const validate = (targetStep = step) => {
event.preventDefault();
const nextWarnings: string[] = []; const nextWarnings: string[] = [];
if (!title.trim()) nextWarnings.push("عنوان رویداد الزامی است."); if (targetStep === 0 && !title.trim()) nextWarnings.push("عنوان رویداد الزامی است.");
if (!startDate) nextWarnings.push("تاریخ شروع الزامی است."); if (targetStep === 1 && !startDate) nextWarnings.push("تاریخ شروع الزامی است.");
if (!startTime) nextWarnings.push("ساعت شروع الزامی است."); if (targetStep === 1 && !startTime) nextWarnings.push("ساعت شروع الزامی است.");
const startAt = toIsoFromParts(startDate, startTime); const startAt = toIsoFromParts(startDate, startTime);
const endAt = toIsoFromParts(endDate || startDate, endTime || startTime); const endAt = toIsoFromParts(endDate || startDate, endTime || startTime);
if (new Date(endAt) < new Date(startAt)) nextWarnings.push("تاریخ پایان نباید قبل از تاریخ شروع باشد."); if (targetStep === 1 && new Date(endAt) < new Date(startAt)) nextWarnings.push("تاریخ پایان نباید قبل از تاریخ شروع باشد.");
if (type === "online_class" && !onlineLink.trim()) nextWarnings.push("برای کلاس آنلاین، لینک ورود یا لینک جلسه را وارد کنید."); if (targetStep === 2 && type === "online_class" && !onlineLink.trim()) nextWarnings.push("برای کلاس آنلاین، لینک ورود یا لینک جلسه را وارد کنید.");
setWarnings(nextWarnings); setWarnings(nextWarnings);
if (nextWarnings.length) return; return { valid: nextWarnings.length === 0, startAt, endAt };
};
const submit = (event: React.FormEvent) => {
event.preventDefault();
if (step < 3) {
const result = validate(step);
if (result.valid) {
setWarnings([]);
setStep((value) => value + 1);
}
return;
}
const first = validate(0);
const timing = validate(1);
const connection = validate(2);
if (!first.valid || !timing.valid || !connection.valid) return;
const definition = getType(type); const definition = getType(type);
onCreate({ onCreate({
@ -389,8 +439,8 @@ export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate
description, description,
type, type,
status, status,
startAt, startAt: timing.startAt,
endAt, endAt: timing.endAt,
allDay: false, allDay: false,
color: definition.color, color: definition.color,
connectedItem: { type: connectedType as CalendarConnectedItemType, title: connectedType === "none" ? "هیچ‌کدام" : connectionLabel(connectedType) }, connectedItem: { type: connectedType as CalendarConnectedItemType, title: connectedType === "none" ? "هیچ‌کدام" : connectionLabel(connectedType) },
@ -405,30 +455,35 @@ export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate
}; };
return ( return (
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="ui-event-wizard flex min-h-0 w-full max-w-full flex-1 flex-col">
{!!warnings.length && <div className="rounded-2xl bg-rose-500/15 p-3 text-sm font-bold leading-7">{warnings.map((warning) => <p key={warning}>{warning}</p>)}</div>} <div className="mb-3 grid min-w-0 shrink-0 grid-cols-4 gap-1.5 sm:gap-2" aria-label="مراحل ایجاد رویداد">{["مشخصات", "زمان", "دسترسی", "مرور"].map((label, index) => <div key={label} title={label} className={`min-w-0 truncate rounded-xl px-1 py-2 text-center text-xs font-bold sm:px-2 ${index === step ? "bg-sky-600 text-white" : index < step ? "bg-emerald-600/15 text-emerald-700 dark:text-emerald-300" : "ui-card"}`} aria-current={index === step ? "step" : undefined}><span>{toPersianDigits(index + 1)}</span><span className="hidden sm:inline">. {label}</span><span className="sr-only">. {label}</span></div>)}</div>
<div className="grid gap-3 md:grid-cols-2"> {!!warnings.length && <div role="alert" className="mb-3 shrink-0 rounded-2xl bg-rose-500/15 p-3 text-sm font-bold leading-6">{warnings.map((warning) => <p key={warning}>{warning}</p>)}</div>}
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{step === 0 && <div className="grid gap-3 md:grid-cols-2">
<TextField label="عنوان رویداد" value={title} onChange={setTitle} required /> <TextField label="عنوان رویداد" value={title} onChange={setTitle} required />
<label className="text-sm font-bold">نوع رویداد<select value={type} onChange={(event) => setType(event.target.value as CalendarEventType)} className="mt-2 w-full rounded-2xl p-3">{calendarEventTypeDefinitions.map((item) => <option key={item.type} value={item.type}>{item.labelFa}</option>)}</select></label> <label className="text-sm font-bold">نوع رویداد<select value={type} onChange={(event) => setType(event.target.value as CalendarEventType)} className="mt-2 w-full rounded-2xl p-3">{calendarEventTypeDefinitions.map((item) => <option key={item.type} value={item.type}>{item.labelFa}</option>)}</select></label>
<label className="text-sm font-bold md:col-span-2">توضیحات<textarea value={description} onChange={(event) => setDescription(event.target.value)} className="mt-2 min-h-24 w-full rounded-2xl border border-white/20 bg-white/20 p-3 outline-none focus:ring-2 focus:ring-sky-400/40" /></label> <label className="text-sm font-bold md:col-span-2">توضیحات<textarea value={description} onChange={(event) => setDescription(event.target.value)} className="mt-2 min-h-20 w-full rounded-2xl border border-[var(--color-input-border)] bg-[var(--color-input-bg)] p-3 outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]" /></label>
</div> </div>}
<PersianDateTimePicker dateLabel="تاریخ شروع" timeLabel="ساعت شروع" date={startDate} time={startTime} onDateChange={setStartDate} onTimeChange={setStartTime} /> {step === 1 && <div className="space-y-3"><PersianDateTimePicker dateLabel="تاریخ شروع" timeLabel="ساعت شروع" date={startDate} time={startTime} onDateChange={setStartDate} onTimeChange={setStartTime} /><div className="grid gap-3 md:grid-cols-2"><PersianDatePicker label="تاریخ پایان" value={endDate} onChange={setEndDate} /><PersianTimePicker label="ساعت پایان" value={endTime} onChange={setEndTime} /></div></div>}
<div className="grid gap-3 md:grid-cols-2"><PersianDatePicker label="تاریخ پایان" value={endDate} onChange={setEndDate} /><PersianTimePicker label="ساعت پایان" value={endTime} onChange={setEndTime} /></div> {step === 2 && <div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm font-bold">اتصال آموزشی<select value={connectedType} onChange={(event) => setConnectedType(event.target.value)} className="mt-2 w-full rounded-2xl p-3">{["دوره", "میکروآموزش", "آزمون", "کلاس آنلاین", "بوتکمپ", "هیچ‌کدام"].map((item) => <option key={item} value={connectionValue(item)}>{item}</option>)}</select></label> <label className="text-sm font-bold">اتصال آموزشی<select value={connectedType} onChange={(event) => setConnectedType(event.target.value)} className="mt-2 w-full rounded-2xl p-3">{["دوره", "میکروآموزش", "آزمون", "کلاس آنلاین", "بوتکمپ", "هیچ‌کدام"].map((item) => <option key={item} value={connectionValue(item)}>{item}</option>)}</select></label>
<TextField label="مخاطبان" value={audience} onChange={setAudience} /> <TextField label="مخاطبان" value={audience} onChange={setAudience} />
<TextField label="لینک ورود به رویداد" value={onlineLink} onChange={setOnlineLink} /> <TextField label="لینک ورود به رویداد" value={onlineLink} onChange={setOnlineLink} />
<TextField label="لینک ضبط دوره" value={recordingLink} onChange={setRecordingLink} /> <TextField label="لینک ضبط دوره" value={recordingLink} onChange={setRecordingLink} />
</div>}
{step === 3 && <div className="grid gap-3 md:grid-cols-2">
<label className="text-sm font-bold">یادآوری<select value={reminder} onChange={(event) => setReminder(event.target.value)} className="mt-2 w-full rounded-2xl p-3"><option value="15">۱۵ دقیقه قبل</option><option value="60">۱ ساعت قبل</option><option value="1440">۱ روز قبل</option></select></label> <label className="text-sm font-bold">یادآوری<select value={reminder} onChange={(event) => setReminder(event.target.value)} className="mt-2 w-full rounded-2xl p-3"><option value="15">۱۵ دقیقه قبل</option><option value="60">۱ ساعت قبل</option><option value="1440">۱ روز قبل</option></select></label>
<label className="text-sm font-bold">وضعیت انتشار<select value={status} onChange={(event) => setStatus(event.target.value as CalendarEventStatus)} className="mt-2 w-full rounded-2xl p-3"><option value="draft">پیشنویس</option><option value="scheduled">زمانبندیشده</option><option value="published">منتشرشده</option></select></label> <label className="text-sm font-bold">وضعیت انتشار<select value={status} onChange={(event) => setStatus(event.target.value as CalendarEventStatus)} className="mt-2 w-full rounded-2xl p-3"><option value="draft">پیشنویس</option><option value="scheduled">زمانبندیشده</option><option value="published">منتشرشده</option></select></label>
<div className="ui-card rounded-2xl p-3 text-sm md:col-span-2"><b>{title || "بدون عنوان"}</b><p className="mt-1 text-[var(--color-text-secondary)]">{startDate}، {startTime} تا {endTime} · {audience}</p></div>
</div>}
</div> </div>
<div className="flex justify-end gap-2"><button type="button" onClick={onCancel} className="rounded-2xl bg-white/25 px-4 py-2 font-bold">انصراف</button><button className="rounded-2xl bg-slate-950 px-5 py-2 font-black text-white dark:bg-white dark:text-slate-950">ذخیره</button></div> <div className="mt-3 flex shrink-0 justify-between gap-2 border-t border-[var(--color-divider)] pt-3"><button type="button" onClick={step === 0 ? onCancel : () => { setWarnings([]); setStep((value) => value - 1); }} className="min-h-11 rounded-2xl bg-[var(--color-card)] px-4 font-bold">{step === 0 ? "انصراف" : "مرحله قبل"}</button><button className="min-h-11 rounded-2xl bg-slate-950 px-5 font-black text-white dark:bg-white dark:text-slate-950">{step === 3 ? "ذخیره رویداد" : "مرحله بعد"}</button></div>
</form> </form>
); );
} }
export function CalendarSettingsSheet({ role, onClose, onSaved }: { role: UserRole; onClose: () => void; onSaved: (settings: CalendarSettings) => void }) { export function CalendarSettingsSheet({ role, onClose, onSaved }: { role: UserRole; onClose: () => void; onSaved: (settings: CalendarSettings) => void }) {
return <div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/25 p-4 backdrop-blur-sm"><section className="liquid-glass max-h-[92vh] w-full max-w-4xl overflow-auto rounded-[30px] p-5 mac-scrollbar"><div className="mb-4 flex items-center justify-between"><h2 className="text-2xl font-black">تنظیمات</h2><button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button></div><CalendarSettingsForm role={role} onSaved={onSaved} /></section></div>; return <AccessibleDialog open title="تنظیمات تقویم" onClose={onClose} panelClassName="popup-scroll max-w-4xl overflow-auto"><CalendarSettingsForm role={role} onSaved={onSaved} /></AccessibleDialog>;
} }
export function CalendarSettingsPage({ role, embedded, onSaved }: { role: UserRole; embedded?: boolean; onSaved?: (settings: CalendarSettings) => void }) { export function CalendarSettingsPage({ role, embedded, onSaved }: { role: UserRole; embedded?: boolean; onSaved?: (settings: CalendarSettings) => void }) {

مشاهده پرونده

@ -2,7 +2,7 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Archive, BadgeCheck, Copy, Eye, Lock, Send, UploadCloud } from "lucide-react"; import { Archive, BadgeCheck, Copy, Eye, Lock, Send, UploadCloud } from "@/components/shared/ui-icons";
import type { UserRole } from "@/config/homeAppsConfig"; import type { UserRole } from "@/config/homeAppsConfig";
import { courseBuilderExplorerSections } from "@/config/courseBuilderExplorerConfig"; import { courseBuilderExplorerSections } from "@/config/courseBuilderExplorerConfig";
import { microBlockDefinitions, microLearningTemplatesFa } from "@/config/microLearningConfig"; import { microBlockDefinitions, microLearningTemplatesFa } from "@/config/microLearningConfig";
@ -12,6 +12,7 @@ import { microLearningService } from "@/services/api/microLearningService";
import type { Course, CourseBuilderRole, CourseWizardDraft } from "@/types/courseBuilder"; import type { Course, CourseBuilderRole, CourseWizardDraft } from "@/types/courseBuilder";
import type { MicroLearning, MicroLearningStatus, MicroLearningStep, MicroBlockType } from "@/types/microLearning"; import type { MicroLearning, MicroLearningStatus, MicroLearningStep, MicroBlockType } from "@/types/microLearning";
import { CompactExplorerChrome } from "@/components/shared/CompactExplorerChrome"; import { CompactExplorerChrome } from "@/components/shared/CompactExplorerChrome";
import { SwitchField } from "@/components/shared/Switch";
import { UploadDropzone } from "@/components/uploads/UploadDropzone"; import { UploadDropzone } from "@/components/uploads/UploadDropzone";
import { CourseBuilderExplorerGrid } from "./CourseBuilderExplorerGrid"; import { CourseBuilderExplorerGrid } from "./CourseBuilderExplorerGrid";
import type { UploadedFile } from "@/types/upload"; import type { UploadedFile } from "@/types/upload";
@ -76,7 +77,7 @@ function AuthorizedCourseBuilderExplorer({ role }: { role: CourseBuilderRole })
const activeTitle = courseBuilderExplorerSections.find((item) => item.id === activeTabId)?.labelFa ?? ""; const activeTitle = courseBuilderExplorerSections.find((item) => item.id === activeTabId)?.labelFa ?? "";
return ( return (
<div className="h-full overflow-hidden rounded-[28px] border border-white/15 bg-white/10 shadow-2xl backdrop-blur-2xl" dir="rtl"> <div className="window-module-shell" dir="rtl">
<CompactExplorerChrome titleFa="مدیریت دوره‌ها" tabs={tabs.map((id) => ({ id, titleFa: courseBuilderExplorerSections.find((item) => item.id === id)?.labelFa ?? id }))} activeTabId={activeTabId} query={query} searchPlaceholder="جستجو در بخش‌ها..." onQuery={setQuery} onHome={() => setActiveTabId(null)} onSelectTab={setActiveTabId} onCloseTab={closeTab}> <CompactExplorerChrome titleFa="مدیریت دوره‌ها" tabs={tabs.map((id) => ({ id, titleFa: courseBuilderExplorerSections.find((item) => item.id === id)?.labelFa ?? id }))} activeTabId={activeTabId} query={query} searchPlaceholder="جستجو در بخش‌ها..." onQuery={setQuery} onHome={() => setActiveTabId(null)} onSelectTab={setActiveTabId} onCloseTab={closeTab}>
{!data ? <LoadingGrid /> : null} {!data ? <LoadingGrid /> : null}
{data && !activeTabId ? <ManagementHome sections={filteredSections} role={role} onOpen={openTab} /> : null} {data && !activeTabId ? <ManagementHome sections={filteredSections} role={role} onOpen={openTab} /> : null}
@ -122,7 +123,7 @@ const CourseManagementList = memo(function CourseManagementList({ titleFa, cours
return ( return (
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
<ToolbarLine titleFa={titleFa || "لیست دوره‌ها"} actions={<><button onClick={() => setView(view === "card" ? "list" : "card")} className="rounded-2xl bg-white/20 px-3 py-2 text-xs">{view === "card" ? "نمای فهرست" : "نمای کارت"}</button>{filters.map((item) => <button key={item} onClick={() => setStatus(item)} className={`rounded-2xl px-3 py-2 text-xs ${status === item ? "bg-white/35 font-black" : "bg-white/15"}`}>{item}</button>)}</>} /> <ToolbarLine titleFa={titleFa || "لیست دوره‌ها"} actions={<><button onClick={() => setView(view === "card" ? "list" : "card")} className="rounded-2xl bg-white/20 px-3 py-2 text-xs">{view === "card" ? "نمای فهرست" : "نمای کارت"}</button>{filters.map((item) => <button key={item} onClick={() => setStatus(item)} className={`rounded-2xl px-3 py-2 text-xs ${status === item ? "bg-white/35 font-black" : "bg-white/15"}`}>{item}</button>)}</>} />
{view === "card" ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rows.map((course) => <CourseCard key={course.id} course={course} role={role} onCourses={onCourses} onToast={onToast} />)}</div> : <DataTable headers={["عنوان", "دسته", "سطح", "وضعیت", "نوع", "قیمت"]} rows={rows.map((course) => [course.titleFa, course.categoryFa ?? "عمومی", levelFa[course.level], statusFa[course.status], course.ownerType === "platform" ? "عمومی" : "سازمانی", course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"])} />} {view === "card" ? <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">{rows.map((course) => <CourseCard key={course.id} course={course} role={role} onCourses={onCourses} onToast={onToast} />)}</div> : <DataTable headers={["عنوان", "دسته", "سطح", "وضعیت", "نوع", "قیمت"]} rows={rows.map((course) => [course.titleFa, course.categoryFa ?? "عمومی", levelFa[course.level], statusFa[course.status], course.ownerType === "platform" ? "عمومی" : "سازمانی", course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"])} />}
</div> </div>
); );
}); });
@ -135,20 +136,20 @@ const CourseCard = memo(function CourseCard({ course, role, onCourses, onToast }
onToast(message); onToast(message);
}; };
return ( return (
<article className="liquid-glass rounded-[22px] p-4"> <article className="liquid-glass rounded-[18px] p-2.5 text-xs">
<div className="h-24 rounded-[18px] bg-gradient-to-l from-emerald-300/50 via-sky-300/45 to-fuchsia-400/35" /> <div className="h-14 rounded-[13px] bg-gradient-to-l from-emerald-300/50 via-sky-300/45 to-fuchsia-400/35" />
<h3 className="mt-3 font-black">{course.titleFa}</h3> <h3 className="mt-2 line-clamp-1 font-black">{course.titleFa}</h3>
<p className="mt-2 text-sm opacity-70">{course.categoryFa} {levelFa[course.level]} {statusFa[course.status]}</p> <p className="mt-1 line-clamp-1 opacity-70">{course.categoryFa} {levelFa[course.level]} {statusFa[course.status]}</p>
<div className="mt-2 flex flex-wrap gap-1">{course.tags.map((tag) => <span key={tag} className="rounded-full bg-white/15 px-2 py-1 text-xs">{tag}</span>)}</div> <div className="mt-2 flex flex-wrap gap-1">{course.tags.map((tag) => <span key={tag} className="rounded-full bg-white/15 px-2 py-1 text-xs">{tag}</span>)}</div>
<p className="mt-3 text-sm">{course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"} {course.ownerType === "platform" ? "عمومی سامانه" : "اختصاصی سازمان"}</p> <p className="mt-2">{course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"} {course.ownerType === "platform" ? "عمومی سامانه" : "اختصاصی سازمان"}</p>
{publicLocked ? <p className="mt-3 rounded-2xl bg-amber-400/20 p-3 text-xs text-amber-700 dark:text-amber-200">برای کپی این دوره عمومی، پرداخت سازمان لازم است.</p> : null} {publicLocked ? <p className="mt-3 rounded-2xl bg-amber-400/20 p-3 text-xs text-amber-700 dark:text-amber-200">برای کپی این دوره عمومی، پرداخت سازمان لازم است.</p> : null}
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-1">
<Action onClick={() => onToast("جزئیات دوره آماده نمایش است.")} icon={<Eye className="h-3.5 w-3.5" />} label="جزئیات" /> <Action onClick={() => onToast("جزئیات دوره آماده نمایش است.")} icon={<Eye className="h-3.5 w-3.5" />} label="جزئیات" />
<Action onClick={() => onToast(publicLocked ? "پرداخت سازمان برای کپی این دوره لازم است." : "دوره کپی شد.", publicLocked ? "error" : "success")} disabled={publicLocked} icon={publicLocked ? <Lock className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} label="کپی" /> <Action onClick={() => onToast(publicLocked ? "پرداخت سازمان برای کپی این دوره لازم است." : "دوره کپی شد.", publicLocked ? "error" : "success")} disabled={publicLocked} icon={publicLocked ? <Lock className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} label="کپی" />
<Action onClick={() => changeStatus("published", "دوره منتشر شد.")} icon={<UploadCloud className="h-3.5 w-3.5" />} label="انتشار" tone="green" /> <Action onClick={() => changeStatus("published", "دوره منتشر شد.")} icon={<UploadCloud className="h-3.5 w-3.5" />} label="انتشار" tone="green" />
<Action onClick={() => changeStatus("archived", "دوره آرشیو شد.")} icon={<Archive className="h-3.5 w-3.5" />} label="آرشیو" tone="red" /> <Action onClick={() => changeStatus("archived", "دوره آرشیو شد.")} icon={<Archive className="h-3.5 w-3.5" />} label="آرشیو" tone="red" />
</div> </div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs"><span className="rounded-xl bg-white/12 p-2">تکمیل ۷۶٪</span><span className="rounded-xl bg-white/12 p-2">نمره ۸۴٪</span><span className="rounded-xl bg-white/12 p-2">گزارش</span></div> <div className="mt-2 grid grid-cols-3 gap-1 text-center text-[10px]"><span className="rounded-lg bg-white/12 p-1">تکمیل ۷۶٪</span><span className="rounded-lg bg-white/12 p-1">نمره ۸۴٪</span><span className="rounded-lg bg-white/12 p-1">گزارش</span></div>
</article> </article>
); );
}); });
@ -288,7 +289,7 @@ const MicroStepCard = memo(function MicroStepCard({ step, index, selected, total
const MicroPreview = memo(function MicroPreview({ steps, onClose }: { steps: MicroLearningStep[]; onClose: () => void }) { const MicroPreview = memo(function MicroPreview({ steps, onClose }: { steps: MicroLearningStep[]; onClose: () => void }) {
const visibleSteps = useMemo(() => steps.filter((step) => step.payload.showInPreview !== false), [steps]); const visibleSteps = useMemo(() => steps.filter((step) => step.payload.showInPreview !== false), [steps]);
return <div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/50 p-5"><section className="w-full max-w-lg rounded-[28px] bg-white/90 p-5 text-slate-900 shadow-2xl dark:bg-slate-950/90 dark:text-white"><div className="flex items-center justify-between"><h3 className="font-black">پیشنمایش زنده</h3><button onClick={onClose} className="rounded-2xl bg-red-500 px-4 py-2 text-sm font-bold text-white">بستن</button></div><div className="mt-4 space-y-2">{visibleSteps.map((step, index) => <div key={step.id} className="rounded-2xl bg-white/15 p-3 text-sm">{toPersianDigits(index + 1)}. {step.titleFa}</div>)}</div></section></div>; return <div className="window-modal-layer fixed inset-0 z-50 grid place-items-center bg-slate-950/55 p-5 backdrop-blur-sm"><section className="w-full max-w-lg rounded-[28px] bg-[var(--color-elevated)] p-5 text-[var(--color-text)] shadow-2xl"><div className="flex items-center justify-between"><h3 className="font-black">پیشنمایش زنده</h3><button onClick={onClose} className="rounded-2xl bg-red-500 px-4 py-2 text-sm font-bold text-white">بستن</button></div><div className="mt-4 space-y-2">{visibleSteps.map((step, index) => <div key={step.id} className="rounded-2xl bg-[var(--color-card)] p-3 text-sm">{toPersianDigits(index + 1)}. {step.titleFa}</div>)}</div></section></div>;
}); });
function MicroLearningTemplates({ onUse }: { onUse: () => void }) { function MicroLearningTemplates({ onUse }: { onUse: () => void }) {
@ -301,11 +302,11 @@ function ToolbarLine({ titleFa, actions }: { titleFa: string; actions: React.Rea
function Action({ label, icon, onClick, tone = "neutral", disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone?: "neutral" | "green" | "red"; disabled?: boolean }) { function Action({ label, icon, onClick, tone = "neutral", disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone?: "neutral" | "green" | "red"; disabled?: boolean }) {
const cls = tone === "green" ? "bg-emerald-500 text-white" : tone === "red" ? "bg-red-500 text-white" : "bg-white/15"; const cls = tone === "green" ? "bg-emerald-500 text-white" : tone === "red" ? "bg-red-500 text-white" : "bg-white/15";
return <button disabled={disabled} onClick={onClick} className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-bold disabled:opacity-45 ${cls}`}>{icon}{label}</button>; return <button disabled={disabled} onClick={onClick} className={`inline-flex min-h-11 items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-bold disabled:opacity-45 ${cls}`}>{icon}{label}</button>;
} }
function DataTable({ headers, rows }: { headers: string[]; rows: string[][] }) { function DataTable({ headers, rows }: { headers: string[]; rows: string[][] }) {
return <div className="liquid-glass overflow-auto rounded-[22px]"><table className="w-full min-w-[720px] text-sm"><thead><tr className="bg-white/15">{headers.map((item) => <th key={item} className="p-3 text-right">{item}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={index} className="border-t border-white/10">{row.map((cell) => <td key={cell} className="p-3">{cell}</td>)}</tr>)}</tbody></table></div>; return <div><div className="grid gap-3 md:hidden">{rows.map((row, index) => <article key={index} className="ui-data-card"><dl className="grid grid-cols-2 gap-3">{row.map((cell, cellIndex) => <div key={`${headers[cellIndex]}-${cell}`} className={cellIndex === 0 ? "col-span-2" : ""}><dt className="text-sm text-[var(--color-text-secondary)]">{headers[cellIndex]}</dt><dd className={cellIndex === 0 ? "font-black" : "font-medium"}>{cell}</dd></div>)}</dl></article>)}</div><div className="content-surface hidden overflow-auto rounded-[22px] md:block"><table className="w-full min-w-[720px] text-sm"><caption className="sr-only">جدول اطلاعات دورهها</caption><thead><tr className="bg-[var(--color-table-header)]">{headers.map((item) => <th scope="col" key={item} className="p-3 text-right">{item}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={index} className="border-t border-[var(--color-divider)]">{row.map((cell, cellIndex) => <td key={`${cell}-${cellIndex}`} className="p-3">{cell}</td>)}</tr>)}</tbody></table></div></div>;
} }
function LoadingGrid() { function LoadingGrid() {
@ -472,7 +473,7 @@ function NumberMini({ label, suffixFa, value, min = 0, step = 1, onChange }: { l
} }
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <button onClick={() => onChange(!checked)} className="flex items-center justify-between gap-3 rounded-2xl bg-white/12 px-3 py-2 text-right text-xs font-bold transition hover:bg-white/18"><span>{label}</span><span className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-emerald-500" : "bg-white/25"}`}><span className={`absolute top-1 h-4 w-4 rounded-full bg-white transition ${checked ? "right-6" : "right-1"}`} /></span></button>; return <SwitchField label={label} checked={checked} onCheckedChange={onChange} size="sm" />;
} }
function SegmentMini({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) { function SegmentMini({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {

مشاهده پرونده

@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowRight, Home, Search } from "lucide-react"; import { ArrowRight, Home, Search } from "@/components/shared/ui-icons";
export function CourseBuilderExplorerToolbar({ title, query, onQuery, onBack, onHome }: { title?: string; query: string; onQuery: (v: string) => void; onBack: () => void; onHome: () => void }) { export function CourseBuilderExplorerToolbar({ title, query, onQuery, onBack, onHome }: { title?: string; query: string; onQuery: (v: string) => void; onBack: () => void; onHome: () => void }) {
return <div className="border-b border-white/15 p-4"><div className="flex flex-wrap items-center gap-2"><button onClick={onBack} className="rounded-2xl bg-white/15 p-2" title="بازگشت"><ArrowRight className="h-5 w-5" /></button><button onClick={onHome} className="rounded-2xl bg-white/15 p-2" title="خانه"><Home className="h-5 w-5" /></button><div className="min-w-[220px] flex-1 rounded-2xl bg-white/15 px-3 py-2 text-sm">{title ? `ساخت دوره / ${title}` : "ساخت دوره"}</div><label className="flex w-full items-center gap-2 rounded-2xl bg-white/15 px-3 py-2 sm:w-72"><Search className="h-4 w-4 opacity-70" /><input value={query} onChange={(e) => onQuery(e.target.value)} placeholder="جستجو در ساخت دوره..." className="w-full bg-transparent text-sm outline-none placeholder:text-current/45" /></label></div></div>; return <div className="mac-window-handle window-module-toolbar cursor-move"><div className="window-controls-safe-row window-toolbar-flow flex flex-wrap items-center gap-2"><button onClick={onBack} className="rounded-2xl bg-white/15 p-2" title="بازگشت"><ArrowRight className="h-5 w-5" /></button><button onClick={onHome} className="rounded-2xl bg-white/15 p-2" title="خانه"><Home className="h-5 w-5" /></button><div className="window-toolbar-grow min-w-[220px] flex-1 rounded-2xl bg-white/15 px-3 py-2 text-sm">{title ? `ساخت دوره / ${title}` : "ساخت دوره"}</div><label className="ui-search-box window-toolbar-search flex w-full items-center gap-2 rounded-2xl bg-white/15 px-3 py-2 sm:w-72"><Search className="h-4 w-4 opacity-70" /><input value={query} onChange={(e) => onQuery(e.target.value)} placeholder="جستجو در ساخت دوره..." className="w-full bg-transparent text-sm outline-none placeholder:text-current/45" /></label></div></div>;
} }

مشاهده پرونده

@ -13,25 +13,49 @@ export function CourseWizard({ initialDraft, role, onSaved }: { initialDraft: Co
const [step, setStep] = useState<CourseWizardStep>("basic_info"); const [step, setStep] = useState<CourseWizardStep>("basic_info");
const [publishOpen, setPublishOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState("");
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const index = courseWizardSteps.findIndex((item) => item.id === step); const index = courseWizardSteps.findIndex((item) => item.id === step);
const complete = Boolean(draft.course.titleFa && draft.course.categoryId && draft.course.level && draft.nodes.length); const complete = Boolean(draft.course.titleFa && draft.course.categoryId && draft.course.level && draft.nodes.length);
const stepComplete = (item: CourseWizardStep) => {
if (item === "basic_info") return Boolean(draft.course.titleFa && draft.course.categoryId && draft.course.level);
if (item === "studio_content") return draft.nodes.length > 0;
if (item === "assessment_access") return true;
return complete;
};
const go = (nextIndex: number) => setStep(courseWizardSteps[Math.max(0, Math.min(courseWizardSteps.length - 1, nextIndex))].id); const go = (nextIndex: number) => setStep(courseWizardSteps[Math.max(0, Math.min(courseWizardSteps.length - 1, nextIndex))].id);
const save = async (status: "draft" | "published") => { const save = async (status: "draft" | "published") => {
setSaving(true); setSaving(true);
const result = await saveCourseBuilderCourse(draft, status); setSaveMessage("");
setDraft(result.draft); try {
onSaved?.(result.draft, status === "published" ? "دوره منتشر شد و در لیست دوره‌ها قرار گرفت." : "دوره ذخیره شد و در پیش‌نویس‌ها قرار گرفت."); const result = await saveCourseBuilderCourse(draft, status);
setSaving(false); setDraft(result.draft);
setLastSavedAt(new Date());
const message = status === "published" ? "دوره منتشر شد و در لیست دوره‌ها قرار گرفت." : "دوره ذخیره شد و در پیش‌نویس‌ها قرار گرفت.";
setSaveMessage(message);
onSaved?.(result.draft, message);
} catch {
setSaveMessage("ذخیره دوره انجام نشد. اتصال را بررسی و دوباره تلاش کنید.");
} finally {
setSaving(false);
}
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="liquid-glass flex gap-2 overflow-x-auto rounded-[22px] p-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="content-surface rounded-[22px] p-3">
{courseWizardSteps.map((item, i) => <button key={item.id} onClick={() => setStep(item.id)} className={`min-w-36 rounded-2xl px-3 py-2 text-sm ${item.id === step ? "bg-white/30 font-black" : "bg-white/10"}`}>{(i + 1).toLocaleString("fa-IR")}، {item.labelFa}</button>)} <div className="flex items-center justify-between text-sm text-[var(--color-text-secondary)] md:hidden"><span>{courseWizardSteps[index].labelFa}</span><span>{(index + 1).toLocaleString("fa-IR")} از {courseWizardSteps.length.toLocaleString("fa-IR")}</span></div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-[var(--color-disabled-bg)] md:hidden"><span className="block h-full rounded-full bg-[var(--color-accent)] transition-[width]" style={{ width: `${((index + 1) / courseWizardSteps.length) * 100}%` }} /></div>
<div className="hidden gap-2 md:grid md:grid-cols-4">
{courseWizardSteps.map((item, i) => <button key={item.id} aria-current={item.id === step ? "step" : undefined} onClick={() => setStep(item.id)} className={`min-h-11 rounded-2xl px-3 py-2 text-sm ${item.id === step ? "bg-[var(--color-text)] font-black text-[var(--color-bg-soft)]" : stepComplete(item.id) ? "bg-emerald-500/15" : "bg-[var(--color-card)]"}`}>{stepComplete(item.id) && item.id !== step ? "✓ " : ""}{(i + 1).toLocaleString("fa-IR")}، {item.labelFa}</button>)}
</div>
</div> </div>
<div className="liquid-glass rounded-[24px] p-5"><CourseWizardStepContent step={step} draft={draft} role={role} onDraft={setDraft} /></div> <div className="content-surface rounded-[24px] p-4 sm:p-5"><CourseWizardStepContent step={step} draft={draft} role={role} onDraft={setDraft} /></div>
{saving ? <p className="rounded-2xl bg-emerald-500/15 p-3 text-sm text-emerald-700 dark:text-emerald-200">در حال ذخیره دوره...</p> : null} <div aria-live="polite">
<CourseWizardFooter canBack={index > 0} isLast={index === courseWizardSteps.length - 1} onBack={() => go(index - 1)} onNext={() => go(index + 1)} onSaveDraft={() => save("draft")} onPreview={() => setStep("preview_publish")} onPublish={() => complete && setPublishOpen(true)} /> {saving ? <p role="status" className="ui-status border-sky-500 bg-sky-500/10 text-sky-800 dark:text-sky-200">در حال ذخیره دوره</p> : null}
{!complete && step === "preview_publish" ? <p className="rounded-2xl bg-rose-500/20 p-3 text-sm">اطلاعات ضروری کامل نیست و انتشار انجام نمیشود.</p> : null} {!saving && saveMessage ? <p role={saveMessage.includes("انجام نشد") ? "alert" : "status"} className={`ui-status ${saveMessage.includes("انجام نشد") ? "border-rose-500 bg-rose-500/10 text-rose-800 dark:text-rose-200" : "border-emerald-500 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200"}`}>{saveMessage}{lastSavedAt ? ` آخرین ذخیره: ${lastSavedAt.toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}` : ""}</p> : null}
</div>
<CourseWizardFooter canBack={index > 0} isLast={index === courseWizardSteps.length - 1} saving={saving} canPublish={complete} onBack={() => go(index - 1)} onNext={() => go(index + 1)} onSaveDraft={() => save("draft")} onPreview={() => setStep("preview_publish")} onPublish={() => complete && setPublishOpen(true)} />
{!complete && step === "preview_publish" ? <p role="alert" className="ui-status border-rose-500 bg-rose-500/10 text-rose-800 dark:text-rose-200">برای انتشار، عنوان، دستهبندی، سطح و حداقل یک بخش را کامل کنید.</p> : null}
<CoursePublishConfirmModal open={publishOpen} onCancel={() => setPublishOpen(false)} onConfirm={() => { setPublishOpen(false); save("published"); }} /> <CoursePublishConfirmModal open={publishOpen} onCancel={() => setPublishOpen(false)} onConfirm={() => { setPublishOpen(false); save("published"); }} />
</div> </div>
); );

مشاهده پرونده

@ -1,5 +1,5 @@
"use client"; "use client";
export function CourseWizardFooter({ canBack, isLast, onBack, onNext, onSaveDraft, onPreview, onPublish }: { canBack: boolean; isLast: boolean; onBack: () => void; onNext: () => void; onSaveDraft: () => void; onPreview: () => void; onPublish: () => void }) { export function CourseWizardFooter({ canBack, isLast, saving, canPublish, onBack, onNext, onSaveDraft, onPreview, onPublish }: { canBack: boolean; isLast: boolean; saving: boolean; canPublish: boolean; onBack: () => void; onNext: () => void; onSaveDraft: () => void; onPreview: () => void; onPublish: () => void }) {
return <div className="flex flex-wrap justify-between gap-2 border-t border-white/10 pt-4"><button onClick={onSaveDraft} className="rounded-2xl bg-white/15 px-4 py-2">ذخیره پیشنویس</button><div className="flex flex-wrap gap-2"><button disabled={!canBack} onClick={onBack} className="rounded-2xl bg-white/15 px-4 py-2 disabled:opacity-40">مرحله قبل</button><button onClick={onPreview} className="rounded-2xl bg-white/15 px-4 py-2">پیشنمایش</button>{isLast ? <button onClick={onPublish} className="rounded-2xl bg-emerald-500 px-4 py-2 text-white">انتشار دوره</button> : <button onClick={onNext} className="rounded-2xl bg-emerald-500 px-4 py-2 text-white">مرحله بعد</button>}</div></div>; return <div className="sticky bottom-0 flex flex-wrap justify-between gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg backdrop-blur-xl"><button disabled={saving} onClick={onSaveDraft} className="min-h-11 rounded-2xl bg-[var(--color-card)] px-4 py-2 font-bold disabled:opacity-50">{saving ? "در حال ذخیره…" : "ذخیره پیش‌نویس"}</button><div className="flex flex-wrap gap-2"><button disabled={!canBack || saving} onClick={onBack} className="min-h-11 rounded-2xl bg-[var(--color-card)] px-4 py-2 disabled:opacity-40">مرحله قبل</button><button disabled={saving} onClick={onPreview} className="min-h-11 rounded-2xl bg-[var(--color-card)] px-4 py-2 disabled:opacity-50">پیشنمایش</button>{isLast ? <button disabled={!canPublish || saving} title={!canPublish ? "اطلاعات ضروری دوره کامل نیست." : undefined} onClick={onPublish} className="min-h-11 rounded-2xl bg-emerald-700 px-4 py-2 font-bold text-white disabled:cursor-not-allowed disabled:bg-[var(--color-disabled-bg)] disabled:text-[var(--color-disabled-text)]">انتشار دوره</button> : <button disabled={saving} onClick={onNext} className="min-h-11 rounded-2xl bg-emerald-700 px-4 py-2 font-bold text-white disabled:opacity-50">مرحله بعد</button>}</div></div>;
} }

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback, useMemo, useState } from "react"; import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { UploadDropzone } from "@/components/uploads/UploadDropzone"; import { UploadDropzone } from "@/components/uploads/UploadDropzone";
import type { CourseBuilderRole, CourseContentItem, CourseContentType, CourseFormat, CourseNode, CourseUploadItem, CourseWizardDraft, CourseWizardStep } from "@/types/courseBuilder"; import type { CourseBuilderRole, CourseContentItem, CourseContentType, CourseFormat, CourseNode, CourseUploadItem, CourseWizardDraft, CourseWizardStep } from "@/types/courseBuilder";
import type { UploadedFile } from "@/types/upload"; import type { UploadedFile } from "@/types/upload";
@ -15,6 +15,8 @@ export function CourseWizardStepContent({ step, draft, role, onDraft }: { step:
} }
function BasicInfo({ draft, role, patchCourse }: { draft: CourseWizardDraft; role: CourseBuilderRole; patchCourse: (patch: Partial<CourseWizardDraft["course"]>) => void }) { function BasicInfo({ draft, role, patchCourse }: { draft: CourseWizardDraft; role: CourseBuilderRole; patchCourse: (patch: Partial<CourseWizardDraft["course"]>) => void }) {
const [categories, setCategories] = useState(["عمومی", "تخصصی", "مهارت‌های نرم", "مدیریتی"]);
const [newCategory, setNewCategory] = useState("");
const [cover, setCover] = useState<UploadedFile | null>(null); const [cover, setCover] = useState<UploadedFile | null>(null);
const [provider, setProvider] = useState(""); const [provider, setProvider] = useState("");
const [skills, setSkills] = useState(""); const [skills, setSkills] = useState("");
@ -23,7 +25,25 @@ function BasicInfo({ draft, role, patchCourse }: { draft: CourseWizardDraft; rol
const levelValue = draft.course.level === "advanced" ? "پیشرفته" : draft.course.level === "intermediate" ? "متوسط" : "مقدماتی"; const levelValue = draft.course.level === "advanced" ? "پیشرفته" : draft.course.level === "intermediate" ? "متوسط" : "مقدماتی";
const pricingValue = draft.course.pricingType === "paid" ? "پولی" : "رایگان"; const pricingValue = draft.course.pricingType === "paid" ? "پولی" : "رایگان";
const ownerValue = draft.course.ownerType === "platform" ? "عمومی سامانه" : role === "super_admin" ? "اختصاصی سازمان" : "اختصاصی سازمان من"; const ownerValue = draft.course.ownerType === "platform" ? "عمومی سامانه" : role === "super_admin" ? "اختصاصی سازمان" : "اختصاصی سازمان من";
return <div className="grid gap-3 md:grid-cols-2"><Input label="عنوان دوره" value={draft.course.titleFa} onChange={(v) => patchCourse({ titleFa: v })} /><Input label="کد دوره" value={draft.course.code} onChange={(v) => patchCourse({ code: v })} /><Input label="توضیح کوتاه" value={draft.course.shortDescriptionFa} onChange={(v) => patchCourse({ shortDescriptionFa: v })} /><Select label="دسته‌بندی" value={draft.course.categoryFa ?? ""} options={["امنیت", "فروش", "منابع انسانی", "فناوری"]} onChange={(v) => patchCourse({ categoryId: v, categoryFa: v })} /><Select label="سطح دوره" value={levelValue} options={["مقدماتی", "متوسط", "پیشرفته"]} onChange={(v) => patchCourse({ level: v === "متوسط" ? "intermediate" : v === "پیشرفته" ? "advanced" : "beginner" })} /><Select label="نوع دوره" value={pricingValue} options={["رایگان", "پولی"]} onChange={(v) => patchCourse({ pricingType: v === "پولی" ? "paid" : "free" })} /><Select label="نوع مالکیت دوره" value={ownerValue} options={role === "super_admin" ? ["عمومی سامانه", "اختصاصی سازمان"] : ["اختصاصی سازمان من"]} onChange={(v) => patchCourse({ ownerType: v === "عمومی سامانه" ? "platform" : "organization" })} /><Input label="قیمت، در صورت پولی بودن" value={String(draft.course.priceToman ?? 0)} onChange={(v) => patchCourse({ priceToman: Number(v) || 0 })} /><div className="md:col-span-2"><UploadDropzone labelFa="آپلود تصویر کاور" accept="image/*" maxSizeMb={5} value={cover} onChange={(file) => { setCover(file); patchCourse({ coverImageUrl: file?.objectUrl }); }} /></div><Input label="مدرس یا ارائه‌دهنده" value={provider} onChange={setProvider} /><Input label="مدت زمان تقریبی" value={skills} onChange={setSkills} /><textarea placeholder="توضیح کامل" className="rounded-2xl bg-white/15 p-3 outline-none md:col-span-2" rows={4} value={draft.course.fullDescriptionFa} onChange={(e) => patchCourse({ fullDescriptionFa: e.target.value })} /><Input label="زیر‌دسته‌بندی" value={objectives} onChange={setObjectives} /><Input label="زبان دوره" value="فارسی" onChange={() => undefined} /><Input label="برچسب‌ها / تگ‌ها" value={draft.course.tags.join("، ")} onChange={(v) => patchCourse({ tags: v.split("،").map((tag) => tag.trim()).filter(Boolean) })} /><Input label="مخاطبان پیشنهادی" value={audience} onChange={setAudience} /><div className="rounded-2xl bg-white/10 p-3 text-sm">وضعیت اولیه: پیش‌نویس</div></div>; return <div className="course-form-grid grid gap-4 md:grid-cols-2">
<Input label="عنوان دوره" value={draft.course.titleFa} onChange={(v) => patchCourse({ titleFa: v })} />
<Input label="کد دوره" value={draft.course.code} onChange={(v) => patchCourse({ code: v })} />
<Input label="توضیح کوتاه" value={draft.course.shortDescriptionFa} onChange={(v) => patchCourse({ shortDescriptionFa: v })} />
<div><Select label="دسته‌بندی" value={draft.course.categoryFa ?? ""} options={categories} onChange={(v) => patchCourse({ categoryId: v, categoryFa: v })} /><div className="mt-2 flex gap-2"><input value={newCategory} onChange={(event) => setNewCategory(event.target.value)} placeholder="دسته‌بندی جدید" className="min-h-11 min-w-0 flex-1 rounded-2xl px-3" /><button type="button" className="semantic-positive rounded-2xl px-3 font-bold" onClick={() => { const value = newCategory.trim(); if (!value || categories.includes(value)) return; setCategories((items) => [...items, value]); setNewCategory(""); patchCourse({ categoryId: value, categoryFa: value }); }}>ایجاد</button></div></div>
<Select label="سطح دوره" value={levelValue} options={["مقدماتی", "متوسط", "پیشرفته"]} onChange={(v) => patchCourse({ level: v === "متوسط" ? "intermediate" : v === "پیشرفته" ? "advanced" : "beginner" })} />
<Select label="نوع دوره" value={pricingValue} options={["رایگان", "پولی"]} onChange={(v) => patchCourse({ pricingType: v === "پولی" ? "paid" : "free" })} />
<Select label="نوع مالکیت دوره" value={ownerValue} options={role === "super_admin" ? ["عمومی سامانه", "اختصاصی سازمان"] : ["اختصاصی سازمان من"]} onChange={(v) => patchCourse({ ownerType: v === "عمومی سامانه" ? "platform" : "organization" })} />
<Input label="قیمت، در صورت پولی بودن" value={String(draft.course.priceToman ?? 0)} onChange={(v) => patchCourse({ priceToman: Number(v) || 0 })} />
<Input label="مدرس یا ارائه‌دهنده" value={provider} onChange={setProvider} />
<Select label="مدت زمان تقریبی" value={skills || "کمتر از یک ساعت"} options={["کمتر از یک ساعت", "۱ تا ۳ ساعت", "۳ تا ۸ ساعت", "بیشتر از ۸ ساعت"]} onChange={setSkills} />
<label className="text-sm font-bold md:col-span-2">توضیح کامل<textarea placeholder="شرح کامل دوره را وارد کنید" className="mt-2 min-h-24 w-full rounded-2xl p-3" rows={3} value={draft.course.fullDescriptionFa} onChange={(e) => patchCourse({ fullDescriptionFa: e.target.value })} /></label>
<Select label="زیر‌دسته‌بندی" value={objectives || "بدون زیردسته"} options={["بدون زیردسته", ...categories]} onChange={setObjectives} />
<Select label="زبان دوره" value="فارسی" options={["فارسی", "انگلیسی", "عربی"]} onChange={() => undefined} />
<TagInput tags={draft.course.tags} onChange={(tags) => patchCourse({ tags })} />
<Select label="مخاطبان پیشنهادی" value={audience || "همه کارکنان"} options={["همه کارکنان", "مدیران", "کارشناسان", "کاربران منتخب"]} onChange={setAudience} />
<div className="md:col-span-2"><UploadDropzone labelFa="آپلود تصویر کاور" accept="image/*" maxSizeMb={5} value={cover} onChange={(file) => { setCover(file); patchCourse({ coverImageUrl: file?.objectUrl }); }} /></div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">وضعیت اولیه: پیش‌نویس</div>
</div>;
} }
function Structure({ draft, onDraft }: { draft: CourseWizardDraft; onDraft: (next: CourseWizardDraft) => void }) { function Structure({ draft, onDraft }: { draft: CourseWizardDraft; onDraft: (next: CourseWizardDraft) => void }) {
@ -99,6 +119,7 @@ function Structure({ draft, onDraft }: { draft: CourseWizardDraft; onDraft: (nex
return ( return (
<div className="p-1"> <div className="p-1">
<CourseImportPanel onImport={(item) => onDraft({ ...draft, uploads: [...draft.uploads, item], course: { ...draft.course, formats: Array.from(new Set([...draft.course.formats, item.format])) } })} />
<div className="grid min-h-[520px] gap-4 lg:grid-cols-[300px_minmax(0,1fr)]"> <div className="grid min-h-[520px] gap-4 lg:grid-cols-[300px_minmax(0,1fr)]">
<aside className="liquid-glass max-h-[620px] overflow-auto rounded-[24px] p-4"> <aside className="liquid-glass max-h-[620px] overflow-auto rounded-[24px] p-4">
<div className="flex items-center justify-between gap-3"><h3 className="font-black">ساختار دوره</h3><button onClick={() => addNode("section", null)} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-bold text-white">افزودن بخش</button></div> <div className="flex items-center justify-between gap-3"><h3 className="font-black">ساختار دوره</h3><button onClick={() => addNode("section", null)} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-bold text-white">افزودن بخش</button></div>
@ -137,8 +158,8 @@ function ContentTypePicker({ onPick, onClose }: { onPick: (content: { type: Cour
const [uploaded, setUploaded] = useState<UploadedFile | null>(null); const [uploaded, setUploaded] = useState<UploadedFile | null>(null);
const needsUpload = ["video", "pdf", "image", "word", "excel", "powerpoint", "scorm", "html5"].includes(type); const needsUpload = ["video", "pdf", "image", "word", "excel", "powerpoint", "scorm", "html5"].includes(type);
return ( return (
<div className="fixed inset-0 z-50 bg-slate-950/35" onClick={onClose}> <div className="window-modal-layer fixed inset-0 z-50 bg-slate-950/55 p-4 backdrop-blur-sm" onClick={onClose}>
<aside onClick={(event) => event.stopPropagation()} className="absolute left-4 top-4 h-[calc(100%-2rem)] w-full max-w-sm overflow-auto rounded-[28px] border border-white/20 bg-white/90 p-5 text-slate-900 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/90 dark:text-white"> <aside onClick={(event) => event.stopPropagation()} className="popup-scroll absolute left-4 top-4 h-[calc(100%-2rem)] w-[min(24rem,calc(100%-2rem))] overflow-auto rounded-[28px] border border-[var(--color-border)] bg-[var(--color-elevated)] p-5 text-[var(--color-text)] shadow-2xl">
<div className="flex items-center justify-between gap-3"><h3 className="font-black">افزودن محتوا</h3><button onClick={onClose} className="rounded-2xl bg-red-500 px-3 py-2 text-xs font-bold text-white">بستن</button></div> <div className="flex items-center justify-between gap-3"><h3 className="font-black">افزودن محتوا</h3><button onClick={onClose} className="rounded-2xl bg-red-500 px-3 py-2 text-xs font-bold text-white">بستن</button></div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<label className="block text-sm">نوع محتوا<select value={type} onChange={(event) => { const next = event.target.value as CourseContentType; setType(next); setTitleFa(contentTypeFa[next]); }} className="mt-2 w-full rounded-2xl bg-white/30 p-3 outline-none dark:bg-white/15">{(Object.keys(contentTypeFa) as CourseContentType[]).map((item) => <option key={item} value={item}>{contentTypeFa[item]}</option>)}</select></label> <label className="block text-sm">نوع محتوا<select value={type} onChange={(event) => { const next = event.target.value as CourseContentType; setType(next); setTitleFa(contentTypeFa[next]); }} className="mt-2 w-full rounded-2xl bg-white/30 p-3 outline-none dark:bg-white/15">{(Object.keys(contentTypeFa) as CourseContentType[]).map((item) => <option key={item} value={item}>{contentTypeFa[item]}</option>)}</select></label>
@ -191,7 +212,29 @@ function Pricing({ draft, role, patchCourse }: { draft: CourseWizardDraft; role:
} }
function AssessmentAccess({ role }: { role: CourseBuilderRole }) { function AssessmentAccess({ role }: { role: CourseBuilderRole }) {
return <div className="grid gap-4 lg:grid-cols-2"><ConnectedExamPanel /><Panel title="ارزیابی دوره" items={["حداقل نمره قبولی", "تعداد تلاش مجاز", "زمان آزمون", "نمایش پاسخ صحیح", "شرط تکمیل با قبولی آزمون"]} /><Panel title="گواهی پایان دوره" items={["فعال‌سازی گواهی", "انتخاب قالب گواهی", "متن گواهی", "پیش‌نمایش گواهی"]} /><Panel title="مخاطبان" items={role === "super_admin" ? ["کل سازمان", "دپارتمان", "بخش", "واحد", "تیم", "نقش", "کاربر خاص", "سازمان‌های انتخابی"] : ["کل سازمان", "دپارتمان", "بخش", "واحد", "تیم", "نقش", "کاربر خاص"]} /><Panel title="زمان‌بندی و دسترسی" items={["اجباری یا پیشنهادی", "تاریخ شروع", "تاریخ پایان", "مهلت تکمیل", "پیش‌نیاز اختیاری", "ارسال اعلان"]} /></div>; return <div className="grid gap-3 lg:grid-cols-3">
<CompactConfig title="ارزیابی و گواهی" description="قواعد قبولی، آزمون و صدور گواهی">
<ConnectedExamPanel />
<Select label="حداقل نمره قبولی" value="۷۰ درصد" options={["۵۰ درصد", "۶۰ درصد", "۷۰ درصد", "۸۰ درصد"]} onChange={() => undefined} />
<Select label="تعداد تلاش مجاز" value="۳ تلاش" options={["۱ تلاش", "۲ تلاش", "۳ تلاش", "نامحدود"]} onChange={() => undefined} />
<Select label="گواهی پایان دوره" value="فعال" options={["فعال", "غیرفعال"]} onChange={() => undefined} />
</CompactConfig>
<CompactConfig title="مخاطبان" description="گروه‌هایی که دوره را دریافت می‌کنند">
<Select label="دامنه مخاطب" value="کل سازمان" options={role === "super_admin" ? ["کل سازمان", "سازمان‌های انتخابی", "واحد یا تیم", "کاربر خاص"] : ["کل سازمان", "واحد یا تیم", "نقش", "کاربر خاص"]} onChange={() => undefined} />
<Select label="نقش سازمانی" value="همه نقش‌ها" options={["همه نقش‌ها", "مدیران", "کارشناسان", "کارکنان"]} onChange={() => undefined} />
<Select label="نوع تخصیص" value="پیشنهادی" options={["پیشنهادی", "اجباری"]} onChange={() => undefined} />
</CompactConfig>
<CompactConfig title="دسترسی و زمان‌بندی" description="بازه انتشار و شروط دسترسی">
<Select label="شروع دسترسی" value="بلافاصله پس از انتشار" options={["بلافاصله پس از انتشار", "در تاریخ انتخابی"]} onChange={() => undefined} />
<Select label="پایان دسترسی" value="بدون محدودیت" options={["بدون محدودیت", "در تاریخ انتخابی", "پس از مهلت تکمیل"]} onChange={() => undefined} />
<Select label="پیش‌نیاز" value="بدون پیش‌نیاز" options={["بدون پیش‌نیاز", "دوره انتخابی", "تأیید مدیر"]} onChange={() => undefined} />
<Select label="اعلان" value="ارسال شود" options={["ارسال شود", "ارسال نشود"]} onChange={() => undefined} />
</CompactConfig>
</div>;
}
function CompactConfig({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return <details open className="ui-card rounded-[22px] p-3"><summary className="cursor-pointer list-none"><h3 className="font-black">{title}</h3><p className="mt-1 text-xs text-[var(--color-text-secondary)]">{description}</p></summary><div className="mt-3 space-y-3">{children}</div></details>;
} }
function ConnectedExamPanel() { function ConnectedExamPanel() {
@ -226,12 +269,52 @@ function Panel({ title, items }: { title: string; items: string[] }) {
return <section><div className="flex flex-wrap items-center justify-between gap-3"><h3 className="font-black">{title}</h3><button onClick={addItem} className="rounded-2xl bg-emerald-500 px-4 py-2 text-sm text-white">افزودن آیتم</button></div><div className="mt-3 grid gap-3 md:grid-cols-3">{localItems.map((item) => <div key={item.id} className="liquid-glass rounded-[18px] p-4 text-sm"><div className="flex items-start justify-between gap-2"><span className={item.enabled ? "" : "opacity-45"}>{item.label}</span><button onClick={() => setLocalItems((current) => current.map((row) => row.id === item.id ? { ...row, enabled: !row.enabled } : row))} className={`rounded-full px-2 py-1 text-xs ${item.enabled ? "bg-emerald-500/80 text-white" : "bg-white/15"}`}>{item.enabled ? "فعال" : "غیرفعال"}</button></div><div className="mt-3 flex gap-2"><button onClick={() => setLocalItems((current) => current.map((row) => row.id === item.id ? { ...row, enabled: !row.enabled } : row))} className="rounded-xl bg-white/15 px-3 py-1 text-xs">تغییر وضعیت</button><button onClick={() => window.confirm("آیا از حذف این آیتم مطمئن هستید؟") && setLocalItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl bg-rose-500/70 px-3 py-1 text-xs text-white">حذف</button></div></div>)}</div></section>; return <section><div className="flex flex-wrap items-center justify-between gap-3"><h3 className="font-black">{title}</h3><button onClick={addItem} className="rounded-2xl bg-emerald-500 px-4 py-2 text-sm text-white">افزودن آیتم</button></div><div className="mt-3 grid gap-3 md:grid-cols-3">{localItems.map((item) => <div key={item.id} className="liquid-glass rounded-[18px] p-4 text-sm"><div className="flex items-start justify-between gap-2"><span className={item.enabled ? "" : "opacity-45"}>{item.label}</span><button onClick={() => setLocalItems((current) => current.map((row) => row.id === item.id ? { ...row, enabled: !row.enabled } : row))} className={`rounded-full px-2 py-1 text-xs ${item.enabled ? "bg-emerald-500/80 text-white" : "bg-white/15"}`}>{item.enabled ? "فعال" : "غیرفعال"}</button></div><div className="mt-3 flex gap-2"><button onClick={() => setLocalItems((current) => current.map((row) => row.id === item.id ? { ...row, enabled: !row.enabled } : row))} className="rounded-xl bg-white/15 px-3 py-1 text-xs">تغییر وضعیت</button><button onClick={() => window.confirm("آیا از حذف این آیتم مطمئن هستید؟") && setLocalItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl bg-rose-500/70 px-3 py-1 text-xs text-white">حذف</button></div></div>)}</div></section>;
} }
function CourseImportPanel({ onImport }: { onImport: (item: CourseUploadItem) => void }) {
const [format, setFormat] = useState<CourseFormat>("scorm");
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState("");
const packageFormats: Array<[CourseFormat, string]> = [["scorm", "SCORM 1.2 / 2004"], ["xapi", "xAPI (Tin Can)"], ["cmi5", "cmi5"], ["html5", "HTML5 ZIP"], ["file", "فایل محتوای معمولی"]];
const importFile = () => {
if (!file) return setStatus("ابتدا فایل بسته را انتخاب کنید.");
const needsZip = ["scorm", "xapi", "cmi5", "html5"].includes(format);
if (needsZip && !file.name.toLowerCase().endsWith(".zip")) return setStatus("برای این قالب باید بسته ZIP انتخاب شود.");
const item: CourseUploadItem = { id: `import-${Date.now()}`, titleFa: file.name.replace(/\.[^.]+$/, ""), fileName: file.name, format, sizeLabelFa: `${(file.size / 1024 / 1024).toFixed(1)} MB`, progress: 100, status: "ready", createdAt: new Date().toISOString() };
onImport(item);
setStatus("بسته اعتبارسنجی شد؛ فراداده آن برای بازبینی و نگاشت آماده است.");
};
return <section className="ui-card mb-3 rounded-[22px] p-3">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1"><Select label="ورود دوره آماده" value={packageFormats.find((item) => item[0] === format)?.[1] ?? "فایل محتوای معمولی"} options={packageFormats.map(([, label]) => label)} onChange={(label) => setFormat(packageFormats.find((item) => item[1] === label)?.[0] ?? "file")} /></div>
<label className="min-w-56 flex-1 text-sm font-bold">فایل بسته<input type="file" accept={format === "file" ? undefined : ".zip,application/zip"} onChange={(event) => { setFile(event.target.files?.[0] ?? null); setStatus(""); }} className="mt-2 min-h-12 w-full rounded-2xl p-2" /></label>
<button type="button" onClick={importFile} className="semantic-positive min-h-12 rounded-2xl px-5 font-black">اعتبارسنجی و ورود</button>
</div>
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-3"><span className="rounded-xl bg-[var(--color-surface)] p-2">۱. بررسی قالب و Manifest</span><span className="rounded-xl bg-[var(--color-surface)] p-2">۲. نگاشت عنوان و فراداده</span><span className="rounded-xl bg-[var(--color-surface)] p-2">۳. پیشنمایش و انتشار</span></div>
{status ? <p role="status" className={`mt-3 rounded-xl p-2 text-sm ${status.includes("آماده") ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-200" : "bg-rose-500/15 text-[var(--color-danger)]"}`}>{status}</p> : null}
</section>;
}
function TagInput({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) {
const [value, setValue] = useState("");
const add = () => {
const tag = value.trim().replace(/^#/, "");
if (!tag || tags.includes(tag)) return setValue("");
onChange([...tags, tag]);
setValue("");
};
return <label className="text-sm font-bold">برچسبها
<div className="ui-input mt-2 flex min-h-12 flex-wrap items-center gap-2 rounded-2xl p-2">
{tags.map((tag) => <span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--color-selected)] px-2 py-1 text-xs">#{tag}<button type="button" aria-label={`حذف برچسب ${tag}`} onClick={() => onChange(tags.filter((item) => item !== tag))} className="text-[var(--color-danger)]">×</button></span>)}
<input value={value} onChange={(event) => setValue(event.target.value)} onBlur={add} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); add(); } }} placeholder="تگ و Enter" className="min-h-8 min-w-28 flex-1 border-0 bg-transparent p-0 outline-none" />
</div>
</label>;
}
function Input({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { function Input({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return <label className="text-sm">{label}<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className="mt-2 w-full rounded-2xl bg-white/15 p-3 outline-none" /></label>; return <label className="text-sm font-bold text-[var(--color-text)]">{label}<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className="mt-2 min-h-12 w-full rounded-2xl px-3 py-2.5 font-normal" /></label>;
} }
function Select({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) { function Select({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
return <label className="text-sm">{label}<select value={value} onChange={(e) => onChange(e.target.value)} className="mt-2 w-full rounded-2xl bg-white/15 p-3 outline-none">{options.map((item) => <option key={item}>{item}</option>)}</select></label>; return <label className="text-sm font-bold text-[var(--color-text)]">{label}<select value={value} onChange={(e) => onChange(e.target.value)} className="mt-2 min-h-12 w-full rounded-2xl p-3 outline-none">{options.map((item) => <option key={item}>{item}</option>)}</select></label>;
} }
function toFaNumber(value: number) { function toFaNumber(value: number) {

مشاهده پرونده

@ -6,7 +6,7 @@ export function CourseCopyModal({ open, sourceTitleFa, onCancel, onConfirm }: {
const [titleFa, setTitleFa] = useState(sourceTitleFa ? `کپی ${sourceTitleFa}` : "کپی دوره"); const [titleFa, setTitleFa] = useState(sourceTitleFa ? `کپی ${sourceTitleFa}` : "کپی دوره");
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-[9998] grid place-items-center bg-black/35 p-4" dir="rtl"> <div className="window-modal-layer fixed inset-0 z-[9998] grid place-items-center bg-black/55 p-4 backdrop-blur-sm" dir="rtl">
<div className="liquid-glass w-full max-w-lg rounded-[28px] p-5"> <div className="liquid-glass w-full max-w-lg rounded-[28px] p-5">
<h3 className="text-lg font-black">کپی دوره</h3> <h3 className="text-lg font-black">کپی دوره</h3>
<p className="mt-2 text-sm opacity-75">ساختار، محتوا، آزمونها، گواهی و تنظیمات کپی میشوند و ثبتنامها، گزارشها و پرداختها منتقل نمیشوند.</p> <p className="mt-2 text-sm opacity-75">ساختار، محتوا، آزمونها، گواهی و تنظیمات کپی میشوند و ثبتنامها، گزارشها و پرداختها منتقل نمیشوند.</p>

مشاهده پرونده

@ -3,7 +3,7 @@
export function CourseDeleteConfirmModal({ open, titleFa, onCancel, onConfirm }: { open: boolean; titleFa?: string; onCancel: () => void; onConfirm: () => void }) { export function CourseDeleteConfirmModal({ open, titleFa, onCancel, onConfirm }: { open: boolean; titleFa?: string; onCancel: () => void; onConfirm: () => void }) {
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-[9998] grid place-items-center bg-black/35 p-4" dir="rtl"> <div className="window-modal-layer fixed inset-0 z-[9998] grid place-items-center bg-black/55 p-4 backdrop-blur-sm" dir="rtl">
<div className="liquid-glass w-full max-w-md rounded-[28px] p-5"> <div className="liquid-glass w-full max-w-md rounded-[28px] p-5">
<h3 className="text-lg font-black">حذف دوره</h3> <h3 className="text-lg font-black">حذف دوره</h3>
<p className="mt-3 text-sm opacity-75">آیا از حذف {titleFa ? `«${titleFa}»` : "این مورد"} مطمئن هستید؟</p> <p className="mt-3 text-sm opacity-75">آیا از حذف {titleFa ? `«${titleFa}»` : "این مورد"} مطمئن هستید؟</p>

مشاهده پرونده

@ -1,6 +1,7 @@
"use client"; "use client";
import { AccessibleDialog } from "@/components/shared/AccessibleDialog";
export function CoursePublishConfirmModal({ open, onCancel, onConfirm }: { open: boolean; onCancel: () => void; onConfirm: () => void }) { export function CoursePublishConfirmModal({ open, onCancel, onConfirm }: { open: boolean; onCancel: () => void; onConfirm: () => void }) {
if (!open) return null; return <AccessibleDialog open={open} title="انتشار دوره" description="پس از انتشار، دوره برای مخاطبان تعیین‌شده قابل مشاهده خواهد بود." onClose={onCancel} panelClassName="max-w-sm text-center"><p className="mt-4 leading-7">آیا از انتشار این دوره مطمئن هستید؟</p><div className="mt-5 flex flex-wrap justify-center gap-2"><button onClick={onCancel} className="min-h-11 rounded-2xl bg-[var(--color-card)] px-4 py-2">انصراف</button><button onClick={onConfirm} className="min-h-11 rounded-2xl bg-emerald-700 px-4 py-2 font-bold text-white">انتشار دوره</button></div></AccessibleDialog>;
return <div className="fixed inset-0 z-[320] flex items-center justify-center bg-black/35 p-5" dir="rtl"><div className="liquid-glass max-w-sm rounded-[24px] p-5 text-center text-slate-900 dark:text-white"><h3 className="font-black">انتشار دوره</h3><p className="mt-3 leading-7">آیا از انتشار این دوره مطمئن هستید؟</p><div className="mt-5 flex justify-center gap-2"><button onClick={onCancel} className="rounded-2xl bg-white/20 px-4 py-2">انصراف</button><button onClick={onConfirm} className="rounded-2xl bg-emerald-500 px-4 py-2 text-white">انتشار دوره</button></div></div></div>;
} }

مشاهده پرونده

@ -1,4 +1,4 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "@/components/shared/ui-icons";
import { GlassIconTile } from "@/components/shared/GlassIcon"; import { GlassIconTile } from "@/components/shared/GlassIcon";
export function CourseCatalogExplorerIcon({ titleFa, descriptionFa, Icon, selected, onOpen }: { titleFa: string; descriptionFa: string; Icon: LucideIcon; selected?: boolean; onOpen: () => void }) { export function CourseCatalogExplorerIcon({ titleFa, descriptionFa, Icon, selected, onOpen }: { titleFa: string; descriptionFa: string; Icon: LucideIcon; selected?: boolean; onOpen: () => void }) {

مشاهده پرونده

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Heart, Star } from "lucide-react"; import { Heart, Star } from "@/components/shared/ui-icons";
import { toPersianDigits } from "@/lib/persianFormat"; import { toPersianDigits } from "@/lib/persianFormat";
import type { CourseCatalogItem, CourseCategory, CourseReview } from "@/types/courseCatalog"; import type { CourseCatalogItem, CourseCategory, CourseReview } from "@/types/courseCatalog";
import { CourseActionButton, kindFa, levelFa, minutesFa, priceFa } from "./CourseShared"; import { CourseActionButton, kindFa, levelFa, minutesFa, priceFa } from "./CourseShared";

مشاهده پرونده

@ -1,9 +1,10 @@
"use client"; "use client";
import { CheckCircle2, Filter, Heart, List, ShoppingCart, Star, Table2, X } from "lucide-react"; import { CheckCircle2, Filter, Heart, List, ShoppingCart, Star, Table2 } from "@/components/shared/ui-icons";
import { formatToman, toPersianDigits } from "@/lib/persianFormat"; import { formatToman, toPersianDigits } from "@/lib/persianFormat";
import type { CourseCatalogItem, CourseCategory, CourseFilters, CourseLevel, CourseViewMode } from "@/types/courseCatalog"; import type { CourseCatalogItem, CourseCategory, CourseFilters, CourseLevel, CourseViewMode } from "@/types/courseCatalog";
import { ToggleControl } from "@/components/org-users/shared"; import { ToggleControl } from "@/components/org-users/shared";
import { AccessibleDialog } from "@/components/shared/AccessibleDialog";
export const kindFa = { micro: "میکروآموزش", public: "عمومی", exclusive: "اختصاصی" }; export const kindFa = { micro: "میکروآموزش", public: "عمومی", exclusive: "اختصاصی" };
export const levelFa: Record<CourseLevel, string> = { beginner: "مقدماتی", intermediate: "میانی", advanced: "پیشرفته" }; export const levelFa: Record<CourseLevel, string> = { beginner: "مقدماتی", intermediate: "میانی", advanced: "پیشرفته" };
@ -36,10 +37,10 @@ export function statusFa(course: CourseCatalogItem) {
export function CourseActionButton({ course, onAdd, onPay, onGoToMyCourses }: { course: CourseCatalogItem; onAdd: (id: string) => void; onPay: (course: CourseCatalogItem) => void; onGoToMyCourses?: () => void }) { export function CourseActionButton({ course, onAdd, onPay, onGoToMyCourses }: { course: CourseCatalogItem; onAdd: (id: string) => void; onPay: (course: CourseCatalogItem) => void; onGoToMyCourses?: () => void }) {
const added = course.userStatus === "in_my_courses"; const added = course.userStatus === "in_my_courses";
if (added) { if (added) {
return <button onClick={onGoToMyCourses} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-black text-white">در دورههای من</button>; return <button onClick={onGoToMyCourses} className="min-h-11 rounded-2xl bg-emerald-700 px-3 py-2 text-sm font-black text-white">در دورههای من</button>;
} }
return ( return (
<button onClick={() => course.priceType === "paid" && course.userStatus !== "paid" ? onPay(course) : onAdd(course.id)} className="inline-flex items-center gap-1 rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-black text-white"> <button onClick={() => course.priceType === "paid" && course.userStatus !== "paid" ? onPay(course) : onAdd(course.id)} className="inline-flex min-h-11 items-center gap-1 rounded-2xl bg-emerald-700 px-3 py-2 text-sm font-black text-white">
{course.priceType === "paid" ? <ShoppingCart className="h-3.5 w-3.5" /> : <CheckCircle2 className="h-3.5 w-3.5" />} {course.priceType === "paid" ? <ShoppingCart className="h-3.5 w-3.5" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
{course.priceType === "paid" && course.userStatus !== "paid" ? "خرید و افزودن" : "افزودن به دوره‌های من"} {course.priceType === "paid" && course.userStatus !== "paid" ? "خرید و افزودن" : "افزودن به دوره‌های من"}
</button> </button>
@ -48,11 +49,11 @@ export function CourseActionButton({ course, onAdd, onPay, onGoToMyCourses }: {
export function CourseCard({ course, categoryTitle, onOpen, onFavorite, onAdd, onPay }: { course: CourseCatalogItem; categoryTitle: string; onOpen: (course: CourseCatalogItem) => void; onFavorite: (id: string) => void; onAdd: (id: string) => void; onPay: (course: CourseCatalogItem) => void }) { export function CourseCard({ course, categoryTitle, onOpen, onFavorite, onAdd, onPay }: { course: CourseCatalogItem; categoryTitle: string; onOpen: (course: CourseCatalogItem) => void; onFavorite: (id: string) => void; onAdd: (id: string) => void; onPay: (course: CourseCatalogItem) => void }) {
return ( return (
<article className="liquid-glass overflow-hidden rounded-[22px] border border-white/15"> <article className="liquid-glass overflow-hidden rounded-[18px] border border-white/15 text-xs">
<button onClick={() => onOpen(course)} className="block h-28 w-full" style={{ background: course.coverImageUrl ?? "linear-gradient(135deg,#38bdf8,#6366f1)" }} aria-label={course.titleFa} /> <button onClick={() => onOpen(course)} className="block h-16 w-full" style={{ background: course.coverImageUrl ?? "linear-gradient(135deg,#38bdf8,#6366f1)" }} aria-label={course.titleFa} />
<div className="space-y-3 p-4"> <div className="space-y-2 p-2.5">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<button onClick={() => onOpen(course)} className="text-right text-base font-black leading-7">{course.titleFa}</button> <button onClick={() => onOpen(course)} className="line-clamp-1 text-right text-sm font-black">{course.titleFa}</button>
<button onClick={() => onFavorite(course.id)} className={`rounded-xl p-2 ${course.isFavorite ? "bg-rose-500 text-white" : "bg-white/15"}`} title={course.isFavorite ? "حذف از علاقه‌مندی‌ها" : "افزودن به علاقه‌مندی‌ها"}><Heart className="h-4 w-4" /></button> <button onClick={() => onFavorite(course.id)} className={`rounded-xl p-2 ${course.isFavorite ? "bg-rose-500 text-white" : "bg-white/15"}`} title={course.isFavorite ? "حذف از علاقه‌مندی‌ها" : "افزودن به علاقه‌مندی‌ها"}><Heart className="h-4 w-4" /></button>
</div> </div>
<p className="line-clamp-2 text-xs leading-6 opacity-70">{course.shortDescriptionFa}</p> <p className="line-clamp-2 text-xs leading-6 opacity-70">{course.shortDescriptionFa}</p>
@ -77,8 +78,13 @@ export function CourseCard({ course, categoryTitle, onOpen, onFavorite, onAdd, o
export function CourseListTable({ courses, categories, onOpen, onFavorite, onAdd, onPay }: { courses: CourseCatalogItem[]; categories: CourseCategory[]; onOpen: (course: CourseCatalogItem) => void; onFavorite: (id: string) => void; onAdd: (id: string) => void; onPay: (course: CourseCatalogItem) => void }) { export function CourseListTable({ courses, categories, onOpen, onFavorite, onAdd, onPay }: { courses: CourseCatalogItem[]; categories: CourseCategory[]; onOpen: (course: CourseCatalogItem) => void; onFavorite: (id: string) => void; onAdd: (id: string) => void; onPay: (course: CourseCatalogItem) => void }) {
const category = (id: string) => categories.find((item) => item.id === id)?.titleFa ?? "بدون دسته"; const category = (id: string) => categories.find((item) => item.id === id)?.titleFa ?? "بدون دسته";
return ( return (
<div className="liquid-glass overflow-auto rounded-[22px] mac-scrollbar"> <div>
<table className="w-full min-w-[980px] text-xs"> <div className="grid gap-3 md:hidden">
{courses.map((course) => <CourseCard key={course.id} course={course} categoryTitle={category(course.categoryId)} onOpen={onOpen} onFavorite={onFavorite} onAdd={onAdd} onPay={onPay} />)}
</div>
<div className="content-surface hidden overflow-auto rounded-[22px] md:block mac-scrollbar">
<table className="w-full min-w-[980px] text-sm">
<caption className="sr-only">فهرست دورههای کاتالوگ و عملیات هر دوره</caption>
<thead className="sticky top-0 z-10 bg-white/80 backdrop-blur-xl dark:bg-slate-950/80"> <thead className="sticky top-0 z-10 bg-white/80 backdrop-blur-xl dark:bg-slate-950/80">
<tr>{["عنوان دوره", "نوع دوره", "دسته‌بندی", "سطح", "مدت", "امتیاز", "قیمت", "گواهی", "وضعیت", "عملیات"].map((h) => <th key={h} className="p-3 text-right font-black">{h}</th>)}</tr> <tr>{["عنوان دوره", "نوع دوره", "دسته‌بندی", "سطح", "مدت", "امتیاز", "قیمت", "گواهی", "وضعیت", "عملیات"].map((h) => <th key={h} className="p-3 text-right font-black">{h}</th>)}</tr>
</thead> </thead>
@ -99,6 +105,8 @@ export function CourseListTable({ courses, categories, onOpen, onFavorite, onAdd
))} ))}
</tbody> </tbody>
</table> </table>
</div>
{!courses.length ? <p className="ui-data-card text-center text-sm">دورهای با فیلترهای انتخابشده پیدا نشد.</p> : null}
</div> </div>
); );
} }
@ -121,12 +129,9 @@ export function CourseCatalogToolbar({ query, viewMode, canManageCategories, onQ
} }
export function CourseFiltersDrawer({ open, filters, categories, onFilters, onClose }: { open: boolean; filters: CourseFilters; categories: CourseCategory[]; onFilters: (filters: CourseFilters) => void; onClose: () => void }) { export function CourseFiltersDrawer({ open, filters, categories, onFilters, onClose }: { open: boolean; filters: CourseFilters; categories: CourseCategory[]; onFilters: (filters: CourseFilters) => void; onClose: () => void }) {
if (!open) return null;
const patch = (value: Partial<CourseFilters>) => onFilters({ ...filters, ...value }); const patch = (value: Partial<CourseFilters>) => onFilters({ ...filters, ...value });
return ( return (
<div className="fixed inset-0 z-50 bg-slate-950/25" onClick={onClose}> <AccessibleDialog open={open} title="فیلتر دوره‌ها" description="نتایج کاتالوگ را بر اساس نیاز خود محدود کنید." onClose={onClose} as="aside" panelClassName="max-h-[calc(100dvh-32px)] max-w-sm overflow-auto" overlayClassName="justify-end sm:p-4">
<aside onClick={(event) => event.stopPropagation()} className="absolute bottom-0 right-0 top-0 w-full max-w-sm overflow-auto bg-white/90 p-4 text-slate-900 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/90 dark:text-white">
<div className="flex items-center justify-between"><h3 className="font-black">فیلتر دورهها</h3><button onClick={onClose} className="rounded-xl bg-red-500 p-2 text-white"><X className="h-4 w-4" /></button></div>
<div className="mt-4 space-y-3 text-sm"> <div className="mt-4 space-y-3 text-sm">
<Select label="دسته‌بندی اصلی" value={filters.categoryId} onChange={(v) => patch({ categoryId: v })} options={[["", "همه دسته‌ها"], ...categories.map((c) => [c.id, c.titleFa] as [string, string])]} /> <Select label="دسته‌بندی اصلی" value={filters.categoryId} onChange={(v) => patch({ categoryId: v })} options={[["", "همه دسته‌ها"], ...categories.map((c) => [c.id, c.titleFa] as [string, string])]} />
<Select label="سطح دوره" value={filters.level} onChange={(v) => patch({ level: v as CourseLevel | "" })} options={[["", "همه سطح‌ها"], ["beginner", "مقدماتی"], ["intermediate", "میانی"], ["advanced", "پیشرفته"]]} /> <Select label="سطح دوره" value={filters.level} onChange={(v) => patch({ level: v as CourseLevel | "" })} options={[["", "همه سطح‌ها"], ["beginner", "مقدماتی"], ["intermediate", "میانی"], ["advanced", "پیشرفته"]]} />
@ -136,10 +141,9 @@ export function CourseFiltersDrawer({ open, filters, categories, onFilters, onCl
<Toggle label="دارای گواهی" checked={filters.hasCertificate === true} onChange={(checked) => patch({ hasCertificate: checked ? true : null })} /> <Toggle label="دارای گواهی" checked={filters.hasCertificate === true} onChange={(checked) => patch({ hasCertificate: checked ? true : null })} />
<Toggle label="دوره‌های اضافه‌شده" checked={filters.onlyAdded} onChange={(checked) => patch({ onlyAdded: checked, onlyNotAdded: checked ? false : filters.onlyNotAdded })} /> <Toggle label="دوره‌های اضافه‌شده" checked={filters.onlyAdded} onChange={(checked) => patch({ onlyAdded: checked, onlyNotAdded: checked ? false : filters.onlyNotAdded })} />
<Toggle label="دوره‌های اضافه‌نشده" checked={filters.onlyNotAdded} onChange={(checked) => patch({ onlyNotAdded: checked, onlyAdded: checked ? false : filters.onlyAdded })} /> <Toggle label="دوره‌های اضافه‌نشده" checked={filters.onlyNotAdded} onChange={(checked) => patch({ onlyNotAdded: checked, onlyAdded: checked ? false : filters.onlyAdded })} />
<button onClick={onClose} className="w-full rounded-2xl bg-emerald-500 px-4 py-3 font-black text-white">اعمال فیلتر</button> <button onClick={onClose} className="min-h-12 w-full rounded-2xl bg-emerald-700 px-4 py-3 font-black text-white">اعمال فیلتر</button>
</div> </div>
</aside> </AccessibleDialog>
</div>
); );
} }

مشاهده پرونده

@ -0,0 +1,136 @@
"use client";
import { useMemo } from "react";
import type { HomeApp } from "@/config/homeAppsConfig";
import type { MockUser } from "@/services/mock/mockData";
import { myCoursesService } from "@/services/api/myCoursesService";
import { getDashboardQuickSummary } from "@/services/mock/dashboardMockService";
import { toPersianDigits } from "@/lib/persianFormat";
import { UiIcon } from "@/components/shared/UiIcon";
import { AppIconTile } from "@/components/shared/AppIconTile";
import { appIconColors, fallbackIconColor } from "@/config/appIconColors";
export function AradLearningWorkspace({ user, apps, onOpenApp, onShowLauncher, onSearch, onNotifications }: {
user: MockUser;
apps: HomeApp[];
onOpenApp: (app: HomeApp) => void;
onShowLauncher: () => void;
onSearch: () => void;
onNotifications: () => void;
}) {
const courses = useMemo(() => myCoursesService.listMyCourses().filter((course) => course.myCourseStatus === "in_progress").slice(0, 3), []);
const summary = useMemo(() => getDashboardQuickSummary(user.role), [user.role]);
const quickApps = useMemo(() => {
const preferred = ["course-builder", "micro-learning", "social", "users", "calendar", "exams", "analytics", "system-settings", "catalog"];
return preferred.map((id) => apps.find((app) => app.id === id)).filter(Boolean).slice(0, 8) as HomeApp[];
}, [apps]);
const openById = (id: string) => {
const app = apps.find((item) => item.id === id);
if (app) onOpenApp(app);
};
return (
<main className="learning-workspace absolute inset-x-0 bottom-[82px] top-11 z-10 overflow-y-auto px-4 py-4 xl:px-7" aria-label="میز کار یادگیری">
<div className="mx-auto grid max-w-[1540px] gap-4 xl:grid-cols-[minmax(250px,.78fr)_minmax(560px,1.8fr)_minmax(280px,.9fr)]">
<aside className="order-3 grid content-start gap-4 xl:order-1">
<section className="content-surface p-4" aria-labelledby="quick-stats-title">
<div className="section-heading"><UiIcon name="ChartNoAxesCombined" className="h-5 w-5 text-indigo-500" /><h2 id="quick-stats-title">آمار سریع</h2></div>
<div className="mt-4 grid grid-cols-2 gap-3">
{summary.map((item, index) => (
<article key={item.labelFa} className={`metric-tile metric-tile-${index + 1}`}>
<span>{item.labelFa}</span><strong>{item.valueFa}</strong>
</article>
))}
</div>
</section>
<section className="content-surface p-4" aria-labelledby="notifications-title">
<div className="section-heading"><UiIcon name="Bell" className="h-5 w-5 text-blue-500" /><h2 id="notifications-title">اعلانهای اخیر</h2></div>
<div className="mt-3 divide-y divide-[var(--color-divider)]">
{["مهلت آزمون مدیریت پروژه فردا پایان می‌یابد.", "گواهی تجربه کاربری صادر شد.", "جلسه پرسش و پاسخ امروز ساعت ۱۸ برگزار می‌شود."].map((text, index) => (
<button key={text} onClick={onNotifications} className="flex min-h-14 w-full items-center gap-3 py-3 text-right text-sm">
<span className={`status-dot ${index === 1 ? "bg-emerald-500" : index === 2 ? "bg-amber-500" : "bg-violet-500"}`} />
<span className="min-w-0 flex-1 leading-6">{text}</span><UiIcon name="ChevronLeft" className="h-4 w-4 text-[var(--color-text-muted)]" />
</button>
))}
</div>
<button onClick={onNotifications} className="secondary-action mt-3 w-full">مشاهده همه اعلانها</button>
</section>
</aside>
<div className="order-1 grid content-start gap-4 xl:order-2">
<section className="workspace-hero overflow-hidden p-5 sm:p-6">
<div className="relative z-10 grid gap-5 sm:grid-cols-[1fr_220px] sm:items-center">
<div>
<p className="text-sm font-semibold text-white/85">وقت بخیر</p>
<h1 className="mt-2 text-2xl font-black text-white sm:text-[30px]">{user.nameFa}</h1>
<p className="mt-2 max-w-xl text-sm leading-7 text-white/85">امروز را با یک قدم کوچک در مسیر رشد حرفهای آغاز کنید.</p>
<div className="mt-5 flex flex-wrap gap-2">
<button onClick={() => openById("my-courses")} className="hero-primary"><UiIcon name="Play" className="h-4 w-4" />ادامه یادگیری</button>
<button onClick={onSearch} className="hero-secondary"><UiIcon name="Search" className="h-4 w-4" />جستجوی دوره</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="hero-stat"><UiIcon name="Star" className="h-5 w-5" /><span>سطح شما</span><strong>پیشرو</strong></div>
<div className="hero-stat"><UiIcon name="Trophy" className="h-5 w-5" /><span>امتیاز کل</span><strong>۲٬۴۳۰</strong></div>
<div className="hero-progress col-span-2"><span>پیشرفت هفتگی</span><b>۷۰٪</b><div><i style={{ width: "70%" }} /></div></div>
</div>
</div>
</section>
<section className="content-surface p-4" aria-labelledby="continue-title">
<div className="flex items-center justify-between gap-3">
<div className="section-heading"><UiIcon name="BookOpen" className="h-5 w-5 text-blue-500" /><h2 id="continue-title">ادامه یادگیری</h2></div>
<button onClick={() => openById("my-courses")} className="text-sm font-bold text-[var(--color-accent)]">مشاهده همه</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{courses.map((course, index) => (
<button key={course.id} onClick={() => openById("my-courses")} className="course-progress-card text-right">
<span className={`course-cover course-cover-${index + 1}`}><UiIcon name={index === 0 ? "CodeXml" : index === 1 ? "Target" : "ChartSpline"} className="h-9 w-9" /></span>
<span className="block p-3"><b className="line-clamp-1 text-sm">{course.titleFa}</b><small className="mt-1 block text-[var(--color-text-muted)]">{course.instructorNameFa ?? course.providerNameFa ?? "آکادمی آراد"}</small><span className="mt-3 flex items-center gap-2"><i className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-muted)]"><i className="block h-full rounded-full bg-[var(--color-accent)]" style={{ width: `${course.progressPercent}%` }} /></i><em className="text-xs not-italic">{toPersianDigits(course.progressPercent)}٪</em></span></span>
</button>
))}
</div>
</section>
<section className="content-surface p-4" aria-labelledby="recommended-title">
<div className="section-heading"><UiIcon name="Sparkles" className="h-5 w-5 text-violet-500" /><h2 id="recommended-title">پیشنهادهای مناسب شما</h2></div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{["اصول مذاکره حرفه‌ای", "تحلیل داده با Power BI", "هوش مصنوعی در کسب‌وکار"].map((title, index) => (
<button key={title} onClick={() => openById("catalog")} className="recommendation-card"><span className={`recommendation-icon recommendation-icon-${index + 1}`}><UiIcon name={index === 0 ? "Handshake" : index === 1 ? "ChartColumnBig" : "BrainCircuit"} className="h-6 w-6" /></span><span><b>{title}</b><small>{index === 0 ? "مقدماتی • ۶ ساعت" : index === 1 ? "متوسط • ۱۰ ساعت" : "مقدماتی • ۸ ساعت"}</small></span></button>
))}
</div>
</section>
</div>
<aside className="order-2 grid content-start gap-4 xl:order-3">
<section className="content-surface p-4" aria-labelledby="quick-access-title">
<div className="flex items-center justify-between gap-2"><div className="section-heading"><UiIcon name="Workflow" className="h-5 w-5 text-violet-500" /><h2 id="quick-access-title">دسترسی سریع</h2></div><button onClick={onShowLauncher} className="text-xs font-bold text-[var(--color-accent)]">همه برنامهها</button></div>
<div className="mt-4 grid grid-cols-4 gap-3 xl:grid-cols-2 2xl:grid-cols-4">
{quickApps.map((app) => (
<button key={app.id} onClick={() => onOpenApp(app)} className="quick-app group">
<AppIconTile name={app.icon} seed={app.id} gradientClass={appIconColors[app.id] ?? fallbackIconColor} size="quick" />
<span className="line-clamp-1">{app.labelFa}</span>
{app.badge ? <b className="quick-app-badge">{toPersianDigits(app.badge)}</b> : null}
</button>
))}
</div>
</section>
<section className="content-surface p-4" aria-labelledby="today-title">
<div className="section-heading"><UiIcon name="CalendarDays" className="h-5 w-5 text-emerald-500" /><h2 id="today-title">برنامه امروز</h2></div>
<div className="mt-3 space-y-2">
{[{ time: "۱۷:۰۰", title: "اصول طراحی محصول", tone: "session" }, { time: "۱۸:۳۰", title: "آزمون پایان فصل ۲", tone: "exam" }, { time: "۲۰:۰۰", title: "مطالعه طراحی وایرفریم", tone: "study" }].map((item) => (
<button key={item.title} onClick={() => openById(item.tone === "exam" ? "exams" : "calendar")} className="schedule-row">
<span className={`schedule-icon schedule-${item.tone}`}><UiIcon name={item.tone === "exam" ? "ClipboardCheck" : item.tone === "session" ? "Video" : "BookOpen"} className="h-5 w-5" /></span><span className="min-w-0 flex-1"><b>{item.title}</b><small>{item.tone === "exam" ? "ارزیابی" : item.tone === "session" ? "جلسه آنلاین" : "یادآوری"}</small></span><time dir="ltr">{item.time}</time>
</button>
))}
</div>
<button onClick={() => openById("calendar")} className="secondary-action mt-3 w-full">مشاهده تقویم</button>
</section>
</aside>
</div>
</main>
);
}

مشاهده پرونده

@ -1,9 +1,11 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { UserRole } from "@/config/homeAppsConfig"; import type { UserRole } from "@/config/homeAppsConfig";
import { dashboardExplorerSections } from "@/config/dashboardExplorerConfig"; import { dashboardExplorerSections } from "@/config/dashboardExplorerConfig";
import { getDashboardQuickSummary } from "@/services/mock/dashboardMockService"; import { getDashboardQuickSummary } from "@/services/mock/dashboardMockService";
import { getAnalyticsDashboard } from "@/services/api/analyticsDashboardService";
import type { DashboardSummaryItem } from "@/types/dashboard";
import { DashboardExplorerGrid } from "./DashboardExplorerGrid"; import { DashboardExplorerGrid } from "./DashboardExplorerGrid";
import { DashboardExplorerToolbar } from "./DashboardExplorerToolbar"; import { DashboardExplorerToolbar } from "./DashboardExplorerToolbar";
import { DashboardQuickSummary } from "./DashboardQuickSummary"; import { DashboardQuickSummary } from "./DashboardQuickSummary";
@ -14,6 +16,10 @@ export function DashboardExplorer({ role }: { role: UserRole }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [timeRange, setTimeRange] = useState("این ماه"); const [timeRange, setTimeRange] = useState("این ماه");
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [summary, setSummary] = useState<DashboardSummaryItem[]>(() => getDashboardQuickSummary(role));
const [updatedAt, setUpdatedAt] = useState("");
const [source, setSource] = useState<"api" | "local">("local");
const [loadError, setLoadError] = useState("");
const roleSections = useMemo(() => dashboardExplorerSections.filter((section) => section.roleAccess.includes(role)), [role]); const roleSections = useMemo(() => dashboardExplorerSections.filter((section) => section.roleAccess.includes(role)), [role]);
const currentSection = roleSections.find((section) => section.id === currentSectionId); const currentSection = roleSections.find((section) => section.id === currentSectionId);
@ -22,7 +28,32 @@ export function DashboardExplorer({ role }: { role: UserRole }) {
if (!normalized) return roleSections; if (!normalized) return roleSections;
return roleSections.filter((section) => `${section.titleFa} ${section.descriptionFa}`.includes(normalized)); return roleSections.filter((section) => `${section.titleFa} ${section.descriptionFa}`.includes(normalized));
}, [query, roleSections]); }, [query, roleSections]);
const summary = useMemo(() => getDashboardQuickSummary(role), [role, refreshKey]); useEffect(() => {
const controller = new AbortController();
const dateRange = timeRange === "امروز" ? "today" : timeRange === "این هفته" ? "this_week" : timeRange === "سال جاری" ? "current_year" : "this_month";
const load = async () => {
try {
const result = await getAnalyticsDashboard(role, { dateRange }, controller.signal);
setSummary(result.kpis.slice(0, 4).map((item) => ({
labelFa: item.titleFa,
valueFa: `${item.value}${item.unitFa ?? ""}`
})));
setUpdatedAt(result.updatedAt);
setSource(result.source);
setLoadError("");
} catch (reason) {
if (!controller.signal.aborted) setLoadError(reason instanceof Error ? reason.message : "دریافت داده داشبورد ناموفق بود.");
}
};
load();
const interval = window.setInterval(() => {
if (document.visibilityState === "visible" && navigator.onLine) load();
}, 30_000);
return () => {
controller.abort();
window.clearInterval(interval);
};
}, [refreshKey, role, timeRange]);
const goHome = () => setCurrentSectionId(null); const goHome = () => setCurrentSectionId(null);
const goBack = () => setCurrentSectionId(null); const goBack = () => setCurrentSectionId(null);
@ -40,12 +71,16 @@ export function DashboardExplorer({ role }: { role: UserRole }) {
onTimeRange={setTimeRange} onTimeRange={setTimeRange}
onRefresh={() => setRefreshKey((value) => value + 1)} onRefresh={() => setRefreshKey((value) => value + 1)}
/> />
<main key={refreshKey} className="mac-scrollbar min-h-0 flex-1 overflow-auto p-5"> <main className="popup-scroll min-h-0 flex-1 overflow-auto p-5">
{!currentSection ? ( {!currentSection ? (
<div className="space-y-5"> <div className="space-y-5">
<header> <header>
<h2 className="text-xl font-black">داشبورد</h2> <h2 className="text-xl font-black">داشبورد</h2>
<p className="mt-2 text-sm leading-7 opacity-70">بخشهای داشبورد را مثل فایلها مرور کنید و وارد هر بخش شوید.</p> <p className="mt-2 text-sm leading-7 opacity-70">بخشهای داشبورد را مثل فایلها مرور کنید و وارد هر بخش شوید.</p>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-secondary)]" aria-live="polite">
<span>{loadError ? "اتصال زنده موقتاً قطع است." : updatedAt ? `آخرین بروزرسانی: ${new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(updatedAt))}` : "در حال دریافت داده…"}</span>
<span className={source === "api" ? "text-emerald-600 dark:text-emerald-400" : "text-amber-700 dark:text-amber-300"}>{source === "api" ? "داده زنده API" : "داده محلی توسعه"}</span>
</div>
</header> </header>
<DashboardQuickSummary items={summary} /> <DashboardQuickSummary items={summary} />
<DashboardExplorerGrid sections={filteredSections} onOpen={setCurrentSectionId} /> <DashboardExplorerGrid sections={filteredSections} onOpen={setCurrentSectionId} />

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronLeft, Home } from "lucide-react"; import { ChevronLeft, Home } from "@/components/shared/ui-icons";
export function DashboardExplorerBreadcrumb({ currentTitle, onHome }: { currentTitle?: string; onHome: () => void }) { export function DashboardExplorerBreadcrumb({ currentTitle, onHome }: { currentTitle?: string; onHome: () => void }) {
return ( return (

مشاهده پرونده

@ -1,6 +1,6 @@
"use client"; "use client";
import { ArrowRight, RefreshCw, Search } from "lucide-react"; import { ArrowRight, RefreshCw, Search } from "@/components/shared/ui-icons";
import { DashboardExplorerBreadcrumb } from "./DashboardExplorerBreadcrumb"; import { DashboardExplorerBreadcrumb } from "./DashboardExplorerBreadcrumb";
export function DashboardExplorerToolbar({ export function DashboardExplorerToolbar({
@ -25,17 +25,17 @@ export function DashboardExplorerToolbar({
onRefresh: () => void; onRefresh: () => void;
}) { }) {
return ( return (
<div className="border-b border-white/15 p-4"> <div className="mac-window-handle window-module-toolbar cursor-move">
<div className="flex flex-wrap items-center gap-2"> <div className="window-controls-safe-row window-toolbar-flow flex flex-wrap items-center gap-2">
<button disabled={!canGoBack} onClick={onBack} className="rounded-2xl bg-white/15 p-2 disabled:cursor-not-allowed disabled:opacity-40" title="بازگشت"><ArrowRight className="h-5 w-5" /></button> <button disabled={!canGoBack} onClick={onBack} className="rounded-2xl bg-white/15 p-2 disabled:cursor-not-allowed disabled:opacity-40" title="بازگشت"><ArrowRight className="h-5 w-5" /></button>
<div className="min-w-[220px] flex-1"><DashboardExplorerBreadcrumb currentTitle={currentTitle} onHome={onHome} /></div> <div className="window-toolbar-grow min-w-[220px] flex-1"><DashboardExplorerBreadcrumb currentTitle={currentTitle} onHome={onHome} /></div>
<select value={timeRange} onChange={(event) => onTimeRange(event.target.value)} className="rounded-2xl bg-white/15 px-3 py-2 text-sm outline-none"> <select value={timeRange} onChange={(event) => onTimeRange(event.target.value)} className="rounded-2xl bg-white/15 px-3 py-2 text-sm outline-none">
<option>امروز</option> <option>امروز</option>
<option>این هفته</option> <option>این هفته</option>
<option>این ماه</option> <option>این ماه</option>
<option>این فصل</option> <option>این فصل</option>
</select> </select>
<label className="flex w-full items-center gap-2 rounded-2xl bg-white/15 px-3 py-2 sm:w-64"> <label className="ui-search-box window-toolbar-search flex w-full items-center gap-2 rounded-2xl bg-white/15 px-3 py-2 sm:w-64">
<Search className="h-4 w-4 opacity-70" /> <Search className="h-4 w-4 opacity-70" />
<input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="جستجو در داشبورد..." className="w-full bg-transparent text-sm outline-none placeholder:text-current/45" /> <input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="جستجو در داشبورد..." className="w-full bg-transparent text-sm outline-none placeholder:text-current/45" />
</label> </label>

مشاهده پرونده

@ -1,11 +1,12 @@
"use client"; "use client";
import { memo, useMemo, useState } from "react"; import { memo, useMemo, useState } from "react";
import { AlertTriangle, Download, Users } from "lucide-react"; import { AlertTriangle, Download, Users } from "@/components/shared/ui-icons";
import type { UserRole } from "@/config/homeAppsConfig"; import type { UserRole } from "@/config/homeAppsConfig";
import { CompactExplorerChrome } from "@/components/shared/CompactExplorerChrome"; import { CompactExplorerChrome } from "@/components/shared/CompactExplorerChrome";
import { UploadDropzone } from "@/components/uploads/UploadDropzone"; import { UploadDropzone } from "@/components/uploads/UploadDropzone";
import { GlassListIcon } from "@/components/shared/GlassIcon"; import { ExplorerIconTile, GlassListIcon } from "@/components/shared/GlassIcon";
import { SwitchField } from "@/components/shared/Switch";
import { formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat"; import { formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat";
import { examApiService } from "@/services/api/examApiService"; import { examApiService } from "@/services/api/examApiService";
import type { Exam, ExamAnswer, ExamAttempt, ExamQuestion, ExamState, ExcelQuestionPreviewRow, QuestionDifficulty, QuestionType } from "@/types/exam"; import type { Exam, ExamAnswer, ExamAttempt, ExamQuestion, ExamState, ExcelQuestionPreviewRow, QuestionDifficulty, QuestionType } from "@/types/exam";
@ -27,6 +28,16 @@ const adminTabs: { id: ExamTab; titleFa: string; descriptionFa: string; iconName
]; ];
const learnerTabs = ["آزمون‌های من", "آزمون‌های آینده", "آزمون‌های فعال", "آزمون‌های پایان‌یافته", "نتایج من", "در انتظار تصحیح"] as const; const learnerTabs = ["آزمون‌های من", "آزمون‌های آینده", "آزمون‌های فعال", "آزمون‌های پایان‌یافته", "نتایج من", "در انتظار تصحیح"] as const;
type LearnerExamTab = (typeof learnerTabs)[number];
const learnerTabMeta: Record<LearnerExamTab, { descriptionFa: string; iconName: string }> = {
"آزمون‌های من": { descriptionFa: "همه آزمون‌های در دسترس شما", iconName: "ClipboardCheck" },
"آزمون‌های آینده": { descriptionFa: "آزمون‌هایی که هنوز شروع نشده‌اند", iconName: "Clock" },
"آزمون‌های فعال": { descriptionFa: "آزمون‌های آماده شروع یا ادامه", iconName: "Play" },
"آزمون‌های پایان‌یافته": { descriptionFa: "آزمون‌هایی که زمان آن‌ها تمام شده است", iconName: "Archive" },
"نتایج من": { descriptionFa: "نمره و نتیجه آزمون‌های تصحیح‌شده", iconName: "BadgeCheck" },
"در انتظار تصحیح": { descriptionFa: "پاسخ‌هایی که هنوز نتیجه نهایی ندارند", iconName: "Hourglass" }
};
const studioSteps = ["اطلاعات آزمون", "سوالات", "زمان‌بندی و قوانین", "اتصال به دوره / میکروآموزش", "پیش‌نمایش و انتشار"]; const studioSteps = ["اطلاعات آزمون", "سوالات", "زمان‌بندی و قوانین", "اتصال به دوره / میکروآموزش", "پیش‌نمایش و انتشار"];
const questionTypes: { type: QuestionType; labelFa: string }[] = [ const questionTypes: { type: QuestionType; labelFa: string }[] = [
{ type: "single_choice", labelFa: "چندگزینه‌ای تک‌پاسخ" }, { type: "single_choice", labelFa: "چندگزینه‌ای تک‌پاسخ" },
@ -89,8 +100,8 @@ function ExamAdminHome({ items, state, onOpen }: { items: typeof adminTabs; stat
<StatCard label="در انتظار تصحیح" value={pending} /> <StatCard label="در انتظار تصحیح" value={pending} />
<StatCard label="بانک سؤال" value={state.questionBank.filter((q) => !q.archived).length} /> <StatCard label="بانک سؤال" value={state.questionBank.filter((q) => !q.archived).length} />
</div> </div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-3"> <div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-5">
{items.map((item) => <button key={item.id} onClick={() => onOpen(item.id)} className="group rounded-[22px] border border-white/15 bg-white/12 p-4 text-right transition hover:bg-white/20"><GlassListIcon titleFa={item.titleFa} iconName={item.iconName} seed={item.id} size="sm" /><b className="mt-3 block">{item.titleFa}</b><span className="mt-2 block text-xs leading-6 opacity-65">{item.descriptionFa}</span></button>)} {items.map((item) => <ExplorerIconTile key={item.id} titleFa={item.titleFa} descriptionFa={item.descriptionFa} iconName={item.iconName} seed={item.id} onOpen={() => onOpen(item.id)} />)}
</div> </div>
<NotificationStrip notifications={state.notifications} /> <NotificationStrip notifications={state.notifications} />
</div> </div>
@ -208,12 +219,26 @@ function ExamPreviewPublishStep({ exam, state, errors, saveDraft, publish }: { e
} }
function LearnerExamPanel({ role, state, refresh }: { role: UserRole; state: ExamState; refresh: (state?: ExamState) => void }) { function LearnerExamPanel({ role, state, refresh }: { role: UserRole; state: ExamState; refresh: (state?: ExamState) => void }) {
const [active, setActive] = useState<(typeof learnerTabs)[number]>("آزمون‌های من"); const [openedTabs, setOpenedTabs] = useState<LearnerExamTab[]>([]);
const [active, setActive] = useState<LearnerExamTab | null>(null);
const [introExam, setIntroExam] = useState<Exam | null>(null); const [introExam, setIntroExam] = useState<Exam | null>(null);
const [running, setRunning] = useState<{ exam: Exam; attempt: ExamAttempt } | null>(null); const [running, setRunning] = useState<{ exam: Exam; attempt: ExamAttempt } | null>(null);
const user = role === "manager" ? { id: "manager-1", name: "مدیر واحد فروش", teamId: "team-1" } : { id: "employee-1", name: "نگار احمدی", teamId: "team-1" }; const user = role === "manager" ? { id: "manager-1", name: "مدیر واحد فروش", teamId: "team-1" } : { id: "employee-1", name: "نگار احمدی", teamId: "team-1" };
const myAttempts = state.attempts.filter((attempt) => attempt.userId === user.id); const myAttempts = state.attempts.filter((attempt) => attempt.userId === user.id);
const visible = state.exams.filter((exam) => exam.status !== "draft" && exam.status !== "archived"); const visible = state.exams.filter((exam) => exam.status !== "draft" && exam.status !== "archived");
const openTab = (tab: LearnerExamTab) => {
setOpenedTabs((items) => items.includes(tab) ? items : [...items, tab]);
setActive(tab);
setIntroExam(null);
setRunning(null);
};
const closeTab = (tab: LearnerExamTab) => {
const next = openedTabs.filter((item) => item !== tab);
setOpenedTabs(next);
if (active === tab) setActive(next.at(-1) ?? null);
setIntroExam(null);
setRunning(null);
};
const filtered = visible.filter((exam) => { const filtered = visible.filter((exam) => {
const attempt = myAttempts.find((item) => item.examId === exam.id); const attempt = myAttempts.find((item) => item.examId === exam.id);
if (active === "آزمون‌های آینده") return exam.status === "scheduled"; if (active === "آزمون‌های آینده") return exam.status === "scheduled";
@ -223,9 +248,53 @@ function LearnerExamPanel({ role, state, refresh }: { role: UserRole; state: Exa
if (active === "در انتظار تصحیح") return attempt?.status === "pending_grading"; if (active === "در انتظار تصحیح") return attempt?.status === "pending_grading";
return true; return true;
}); });
if (running) return <LearnerExamRunner exam={running.exam} attempt={running.attempt} refresh={refresh} onBack={() => setRunning(null)} />; return (
if (introExam) return <LearnerExamIntro exam={introExam} attempt={myAttempts.find((item) => item.examId === introExam.id)} onBack={() => setIntroExam(null)} onStart={() => { const started = examApiService.startAttempt(introExam.id, user.id, user.name, user.teamId); refresh(started.state); setRunning({ exam: introExam, attempt: started.attempt }); }} />; <div className="h-full overflow-hidden rounded-[28px] border border-white/15 bg-white/10 shadow-2xl backdrop-blur-2xl" dir="rtl">
return <div className="h-full overflow-hidden rounded-[28px] border border-white/15 bg-white/10 shadow-2xl backdrop-blur-2xl p-5" dir="rtl"><div className="flex flex-wrap gap-2">{learnerTabs.map((tab) => <button key={tab} onClick={() => setActive(tab)} className={`rounded-2xl px-4 py-2 text-sm ${active === tab ? "bg-emerald-500 text-white" : "bg-white/15"}`}>{tab}</button>)}</div>{role === "manager" ? <ManagerTeamSummary state={state} /> : null}<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filtered.map((exam) => <LearnerExamCard key={exam.id} exam={exam} attempt={myAttempts.find((item) => item.examId === exam.id)} onOpen={() => setIntroExam(exam)} />)}</div>{filtered.length === 0 ? <EmptyState text="آزمونی برای نمایش وجود ندارد." /> : null}</div>; <CompactExplorerChrome
titleFa="آزمون‌ها"
tabs={openedTabs.map((tab) => ({ id: tab, titleFa: tab }))}
activeTabId={active}
onHome={() => {
setActive(null);
setIntroExam(null);
setRunning(null);
}}
onSelectTab={(tab) => {
setActive(tab);
setIntroExam(null);
setRunning(null);
}}
onCloseTab={closeTab}
>
{!active ? <LearnerExamHome onOpen={openTab} /> : null}
{active && running ? <LearnerExamRunner exam={running.exam} attempt={running.attempt} refresh={refresh} onBack={() => setRunning(null)} /> : null}
{active && !running && introExam ? <LearnerExamIntro exam={introExam} attempt={myAttempts.find((item) => item.examId === introExam.id)} onBack={() => setIntroExam(null)} onStart={() => { const started = examApiService.startAttempt(introExam.id, user.id, user.name, user.teamId); refresh(started.state); setRunning({ exam: introExam, attempt: started.attempt }); }} /> : null}
{active && !running && !introExam ? (
<div className="p-5">
{role === "manager" ? <ManagerTeamSummary state={state} /> : null}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((exam) => <LearnerExamCard key={exam.id} exam={exam} attempt={myAttempts.find((item) => item.examId === exam.id)} onOpen={() => setIntroExam(exam)} />)}
</div>
{filtered.length === 0 ? <EmptyState text="آزمونی برای نمایش وجود ندارد." /> : null}
</div>
) : null}
</CompactExplorerChrome>
</div>
);
}
function LearnerExamHome({ onOpen }: { onOpen: (tab: LearnerExamTab) => void }) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-3 p-5">
{learnerTabs.map((tab) => (
<button key={tab} onClick={() => onOpen(tab)} className="group rounded-[22px] border border-white/15 bg-white/12 p-4 text-right transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400">
<GlassListIcon titleFa={tab} iconName={learnerTabMeta[tab].iconName} seed={tab} size="sm" />
<b className="mt-3 block">{tab}</b>
<span className="mt-2 block text-xs leading-6 opacity-65">{learnerTabMeta[tab].descriptionFa}</span>
</button>
))}
</div>
);
} }
function LearnerExamIntro({ exam, attempt, onBack, onStart }: { exam: Exam; attempt?: ExamAttempt; onBack: () => void; onStart: () => void }) { function LearnerExamIntro({ exam, attempt, onBack, onStart }: { exam: Exam; attempt?: ExamAttempt; onBack: () => void; onStart: () => void }) {
@ -479,7 +548,7 @@ function NumberField({ label, value, suffix, onChange }: { label: string; value:
} }
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <button onClick={() => onChange(!checked)} className="flex w-full items-center justify-between gap-3 rounded-2xl bg-white/12 p-3 text-right text-sm"><span>{label}</span><span className={`relative h-6 w-11 rounded-full ${checked ? "bg-emerald-500" : "bg-white/25"}`}><span className={`absolute top-1 h-4 w-4 rounded-full bg-white transition ${checked ? "right-6" : "right-1"}`} /></span></button>; return <SwitchField label={label} checked={checked} onCheckedChange={onChange} />;
} }
function Segment({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) { function Segment({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {

مشاهده پرونده

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { SwitchField } from "@/components/shared/Switch";
import { import {
Archive, Archive,
Award, Award,
@ -30,7 +31,7 @@ import {
UserPlus, UserPlus,
Users, Users,
X X
} from "lucide-react"; } from "@/components/shared/ui-icons";
import type { UserRole } from "@/config/homeAppsConfig"; import type { UserRole } from "@/config/homeAppsConfig";
import { toPersianDigits } from "@/lib/persianFormat"; import { toPersianDigits } from "@/lib/persianFormat";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -173,7 +174,7 @@ export function LearningPathModule({ role, onToast }: { role: UserRole; onToast?
const [activePathTabId, setActivePathTabId] = useState(""); const [activePathTabId, setActivePathTabId] = useState("");
const reload = async () => setState(await fetchLearningPathState(role)); const reload = async () => setState(await fetchLearningPathState(role));
useEffect(() => { reload(); }, [role]); useEffect(() => { void fetchLearningPathState(role).then(setState); }, [role]);
if (!state) return <div className="grid h-full place-items-center text-sm font-bold">در حال آمادهسازی مسیرهای یادگیری...</div>; if (!state) return <div className="grid h-full place-items-center text-sm font-bold">در حال آمادهسازی مسیرهای یادگیری...</div>;
@ -239,7 +240,7 @@ export function LearningPathModule({ role, onToast }: { role: UserRole; onToast?
}; };
return ( return (
<main className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--color-bg)] text-[var(--color-text)]" dir="rtl"> <main className="window-module-shell" dir="rtl">
<CompactExplorerChrome<LearningPathExplorerTabId> titleFa="مسیر یادگیری" homeTitleFa="مسیر یادگیری" tabs={explorerTabs} activeTabId={activeTabId} query={query} searchPlaceholder={isAdmin ? "جستجو در بخش‌های مسیر یادگیری..." : "جستجو در مسیرهای یادگیری..."} onQuery={setQuery} onHome={goHome} onSelectTab={(id) => { setActiveTabId(id); if (id.startsWith("path:")) setSelectedPathId(id.replace("path:", "")); }} onCloseTab={closeMainTab}> <CompactExplorerChrome<LearningPathExplorerTabId> titleFa="مسیر یادگیری" homeTitleFa="مسیر یادگیری" tabs={explorerTabs} activeTabId={activeTabId} query={query} searchPlaceholder={isAdmin ? "جستجو در بخش‌های مسیر یادگیری..." : "جستجو در مسیرهای یادگیری..."} onQuery={setQuery} onHome={goHome} onSelectTab={(id) => { setActiveTabId(id); if (id.startsWith("path:")) setSelectedPathId(id.replace("path:", "")); }} onCloseTab={closeMainTab}>
<section className="min-h-full p-5">{renderActiveTab()}</section> <section className="min-h-full p-5">{renderActiveTab()}</section>
</CompactExplorerChrome> </CompactExplorerChrome>
@ -826,14 +827,7 @@ function SelectField({ label, value, onChange, options }: { label: string; value
} }
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return ( return <SwitchField label={label} checked={checked} onCheckedChange={onChange} />;
<button type="button" onClick={() => onChange(!checked)} className={cn("flex items-center justify-between gap-4 rounded-2xl border p-3 text-right text-sm font-bold transition", checked ? "border-cyan-300 bg-cyan-50 text-cyan-950 dark:bg-cyan-500/16 dark:text-cyan-100" : "border-white/60 bg-white/55 text-slate-700 hover:border-cyan-300 dark:border-slate-400/15 dark:bg-slate-900/60 dark:text-slate-200")}>
<span>{label}</span>
<span className={cn("relative h-7 w-12 shrink-0 rounded-full transition", checked ? "bg-cyan-500" : "bg-slate-300 dark:bg-slate-700")}>
<span className={cn("absolute top-1 h-5 w-5 rounded-full bg-white shadow transition", checked ? "right-6" : "right-1")} />
</span>
</button>
);
} }
function normalizePathItems(path: LearningPath): LearningPath { function normalizePathItems(path: LearningPath): LearningPath {

مشاهده پرونده

@ -0,0 +1,15 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { LicenseAccess } from "@/types/license";
const fullAccess: LicenseAccess = { status: "active", mode: "full" };
const LicenseAccessContext = createContext<LicenseAccess>(fullAccess);
export function LicenseAccessProvider({ access, children }: { access: LicenseAccess; children: ReactNode }) {
return <LicenseAccessContext.Provider value={access}>{children}</LicenseAccessContext.Provider>;
}
export function useLicenseAccess() {
return useContext(LicenseAccessContext);
}

مشاهده پرونده

@ -0,0 +1,33 @@
"use client";
import { useMemo, useState } from "react";
import { BadgeCheck, CalendarClock, KeyRound, RefreshCw, ShieldCheck } from "@/components/shared/ui-icons";
import { MacButton } from "@/components/mac/MacUI";
import { Surface } from "@/components/shared/Surface";
import { formatJalaliDate, toPersianDigits } from "@/lib/persianFormat";
import { licenseService } from "@/services/licenses/licenseService";
import type { LicenseStatus } from "@/types/license";
const statusFa: Record<LicenseStatus, string> = { draft: "پیش‌نویس", pending_activation: "در انتظار فعال‌سازی", active: "فعال", expiring: "نزدیک به انقضا", grace_period: "مهلت تمدید", expired: "منقضی", suspended: "تعلیق", revoked: "باطل‌شده" };
export function LicenseManagementPage() {
const [licenses, setLicenses] = useState(() => licenseService.list());
const [query, setQuery] = useState("");
const filtered = useMemo(() => licenses.filter((item) => item.organizationNameFa.includes(query) || item.licenseKey.toLowerCase().includes(query.toLowerCase())), [licenses, query]);
const expiring = licenses.filter((item) => item.status === "expiring" || item.status === "grace_period").length;
return (
<main className="window-module-shell">
<header className="mac-window-handle window-module-toolbar window-controls-safe-row flex cursor-move flex-wrap items-center justify-between gap-3">
<div><h1 className="text-xl font-black">مدیریت لایسنسهای سالانه</h1><p className="mt-1 text-xs text-[var(--color-text-secondary)]">تمام امکانات فعال؛ ظرفیت، استقرار و پشتیبانی براساس قرارداد سازمان</p></div>
<label className="ui-search-box mac-window-no-drag flex min-h-11 w-full max-w-sm items-center gap-2 rounded-2xl bg-[var(--color-input-bg)] px-3 sm:w-72"><KeyRound className="h-4 w-4" /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="جستجوی سازمان یا کلید لایسنس" className="w-full bg-transparent outline-none" /></label>
</header>
<div className="popup-scroll min-h-0 flex-1 overflow-auto p-5">
<div className="grid gap-3 md:grid-cols-3"><Metric icon={ShieldCheck} label="لایسنس فعال" value={licenses.filter((item) => item.status === "active").length} /><Metric icon={CalendarClock} label="نیازمند تمدید" value={expiring} /><Metric icon={BadgeCheck} label="امکانات هر لایسنس" value="همه" /></div>
<Surface level="panel" className="mt-5 overflow-auto rounded-[24px] border border-[var(--color-border)]"><table className="w-full min-w-[980px] text-sm"><thead><tr>{["سازمان", "وضعیت", "شروع", "پایان", "مهلت تمدید", "کاربر", "فضا", "کلاس هم‌زمان", "استقرار", "پشتیبانی", "عملیات"].map((title) => <th key={title} className="p-3 text-right">{title}</th>)}</tr></thead><tbody>{filtered.map((license) => <tr key={license.id} className="border-t border-[var(--color-divider)]"><td className="p-3"><b>{license.organizationNameFa}</b><span className="mt-1 block font-mono text-[11px] text-[var(--color-text-secondary)]">{license.licenseKey}</span></td><td className="p-3"><Status status={license.status} /></td><td className="p-3">{formatJalaliDate(license.startsAt)}</td><td className="p-3">{formatJalaliDate(license.expiresAt)}</td><td className="p-3">{formatJalaliDate(license.graceEndsAt)}</td><td className="p-3">{toPersianDigits(license.capacity.activeUsers)}</td><td className="p-3">{toPersianDigits(license.capacity.storageGb)} GB</td><td className="p-3">{toPersianDigits(license.capacity.concurrentOnlineClasses)}</td><td className="p-3">{license.deployment === "cloud" ? "ابری" : "نصب سازمانی"}</td><td className="p-3">{license.supportLevel === "premium" ? "ویژه" : "استاندارد"}</td><td className="p-3"><MacButton intent="positive" onClick={() => setLicenses(licenseService.renew(license.id))}><RefreshCw className="h-4 w-4" />تمدید یکساله</MacButton></td></tr>)}</tbody></table></Surface>
</div>
</main>
);
}
function Metric({ icon: Icon, label, value }: { icon: typeof ShieldCheck; label: string; value: string | number }) { return <Surface level="card" className="flex items-center gap-3 rounded-[22px] border border-[var(--color-border)] p-4"><span className="grid h-12 w-12 place-items-center rounded-2xl bg-[var(--color-selected)] text-[var(--color-accent)]"><Icon className="h-6 w-6" /></span><div><p className="text-xs text-[var(--color-text-secondary)]">{label}</p><b className="mt-1 block text-xl">{toPersianDigits(value)}</b></div></Surface>; }
function Status({ status }: { status: LicenseStatus }) { const tone = status === "active" ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-200" : status === "expiring" || status === "grace_period" ? "bg-amber-500/15 text-amber-700 dark:text-amber-200" : "bg-rose-500/15 text-rose-700 dark:text-rose-200"; return <span className={`rounded-full px-3 py-1 text-xs font-black ${tone}`}>{statusFa[status]}</span>; }

مشاهده پرونده

@ -3,9 +3,11 @@
import type { DesktopState } from "@/lib/macDesktopStorage"; import type { DesktopState } from "@/lib/macDesktopStorage";
import { wallpapersConfig } from "@/config/wallpapersConfig"; import { wallpapersConfig } from "@/config/wallpapersConfig";
import type { UserRole } from "@/config/homeAppsConfig"; import type { UserRole } from "@/config/homeAppsConfig";
import { MacToggle } from "./MacToggle";
import { toPersianDigits } from "@/lib/persianFormat"; import { toPersianDigits } from "@/lib/persianFormat";
import { LayoutGrid, Moon, Sparkles, SunMedium } from "lucide-react"; import { AccessibleDialog } from "@/components/shared/AccessibleDialog";
import { UiIcon } from "@/components/shared/UiIcon";
import { SwitchField } from "@/components/shared/Switch";
import { accentThemes, type AccentTheme } from "@/design-system/appearance";
type TransparencyState = { type TransparencyState = {
value: number; value: number;
@ -13,28 +15,26 @@ type TransparencyState = {
}; };
export function MacControlCenter({ open, role, state, onUpdate, onClose, onOpenWidgets }: { open: boolean; role: UserRole; state: DesktopState; onUpdate: (patch: Partial<DesktopState>) => void; onClose: () => void; onOpenWidgets: () => void }) { export function MacControlCenter({ open, role, state, onUpdate, onClose, onOpenWidgets }: { open: boolean; role: UserRole; state: DesktopState; onUpdate: (patch: Partial<DesktopState>) => void; onClose: () => void; onOpenWidgets: () => void }) {
if (!open) return null;
const opacity = Math.max(0, Math.min(100, state.glassOpacity)); const opacity = Math.max(0, Math.min(100, state.glassOpacity));
const transparency: TransparencyState = { value: opacity, updatedAt: new Date().toISOString() }; const transparency: TransparencyState = { value: opacity, updatedAt: new Date().toISOString() };
return ( return (
<div className="fixed inset-0 z-[170]" onClick={onClose}> <AccessibleDialog open={open} title="تنظیمات سریع" description="ظاهر و رفتار میز کار را شخصی‌سازی کنید." onClose={onClose} as="aside" side="left" panelClassName="max-h-[calc(100dvh-32px)] max-w-sm" overlayClassName="p-4">
<section className="liquid-glass absolute left-4 top-12 max-h-[calc(100vh-64px)] w-[min(380px,calc(100vw-32px))] overflow-auto rounded-[32px] p-5 text-slate-900 shadow-[0_26px_90px_rgba(15,23,42,0.18)] dark:text-white mac-scrollbar" onClick={(e) => e.stopPropagation()}> <div className="popup-scroll popup-scroll-fade mt-4 max-h-[calc(100dvh-150px)] space-y-4 overflow-y-auto py-2">
<h2 className="text-lg font-black">تنظیمات سریع</h2>
<div className="mt-4 space-y-4">
<Segment title="حالت نمایش" items={["پیش‌فرض", "روشن", "تاریک"]} value={state.theme === "system" ? "پیش‌فرض" : state.theme === "dark" ? "تاریک" : "روشن"} onPick={(v) => onUpdate({ theme: v === "پیش‌فرض" ? "system" : v === "تاریک" ? "dark" : "light" })} /> <Segment title="حالت نمایش" items={["پیش‌فرض", "روشن", "تاریک"]} value={state.theme === "system" ? "پیش‌فرض" : state.theme === "dark" ? "تاریک" : "روشن"} onPick={(v) => onUpdate({ theme: v === "پیش‌فرض" ? "system" : v === "تاریک" ? "dark" : "light" })} />
<Segment title="اندازه آیکون‌ها" items={["کوچک", "متوسط", "بزرگ"]} value={state.iconSize === "small" ? "کوچک" : state.iconSize === "large" ? "بزرگ" : "متوسط"} onPick={(v) => onUpdate({ iconSize: v === "کوچک" ? "small" : v === "بزرگ" ? "large" : "medium" })} /> <Segment title="اندازه آیکون‌ها" items={["کوچک", "متوسط", "بزرگ"]} value={state.iconSize === "small" ? "کوچک" : state.iconSize === "large" ? "بزرگ" : "متوسط"} onPick={(v) => onUpdate({ iconSize: v === "کوچک" ? "small" : v === "بزرگ" ? "large" : "medium" })} />
<div><p className="mb-2 text-sm font-bold">رنگ تأکیدی سامانه</p><div className="grid grid-cols-7 gap-2">{Object.entries(accentThemes).map(([id, theme]) => <button key={id} type="button" aria-label={theme.labelFa} title={theme.labelFa} onClick={() => onUpdate({ accentTheme: id as AccentTheme })} className={`h-9 rounded-xl border-2 transition ${state.accentTheme === id ? "scale-105 border-[var(--color-text)]" : "border-transparent"}`} style={{ backgroundColor: theme.accent }} />)}</div></div>
<button onClick={() => { onClose(); onOpenWidgets(); }} className="flex w-full items-center gap-3 rounded-[20px] border border-white/18 bg-white/18 p-3 text-right shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] transition hover:bg-white/28"> <button onClick={() => { onClose(); onOpenWidgets(); }} className="flex w-full items-center gap-3 rounded-[20px] border border-white/18 bg-white/18 p-3 text-right shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] transition hover:bg-white/28">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-500 text-white shadow-lg"><LayoutGrid className="h-4 w-4" /></span> <span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-600 text-white"><UiIcon name="LayoutGrid" className="h-4 w-4" /></span>
<b className="block">ویجتها</b> <b className="block">ویجتها</b>
</button> </button>
<div className="rounded-[20px] border border-white/18 bg-white/16 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.34)]"> <div className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-soft)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="flex items-center gap-2 text-sm font-bold"><Sparkles className="h-4 w-4 text-cyan-500" />شفافیت</p> <p className="flex items-center gap-2 text-sm font-black"><UiIcon name="Layers2" className="h-4 w-4 text-cyan-600" />شفافیت</p>
<b className="rounded-full bg-white/24 px-2 py-0.5 text-xs">{toPersianDigits(transparency.value)}٪</b> <b className="text-sm font-black tabular-nums">{toPersianDigits(transparency.value)}٪</b>
</div> </div>
<div className="mt-2 rounded-[16px] bg-gradient-to-l from-slate-900/8 via-white/24 to-cyan-300/24 px-3 py-2 dark:from-white/8 dark:via-white/12 dark:to-cyan-300/20"> <div className="mt-4 px-1">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Moon className="h-3.5 w-3.5 opacity-60" /> <UiIcon name="Moon" className="h-4 w-4 text-[var(--color-text-secondary)]" />
<input <input
aria-label="شفافیت" aria-label="شفافیت"
type="range" type="range"
@ -43,16 +43,18 @@ export function MacControlCenter({ open, role, state, onUpdate, onClose, onOpenW
step={1} step={1}
value={transparency.value} value={transparency.value}
dir="ltr" dir="ltr"
disabled={!state.transparencyEnabled}
onChange={(event) => onUpdate({ glassOpacity: Math.max(0, Math.min(100, Number(event.target.value))) })} onChange={(event) => onUpdate({ glassOpacity: Math.max(0, Math.min(100, Number(event.target.value))) })}
className="modern-range h-5 flex-1" className="modern-range h-5 flex-1"
style={{ ["--range-value" as string]: `${transparency.value}%` }} style={{ ["--range-value" as string]: `${transparency.value}%` }}
/> />
<SunMedium className="h-3.5 w-3.5 text-cyan-500" /> <UiIcon name="SunMedium" className="h-4 w-4 text-cyan-600" />
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-between rounded-2xl bg-white/15 p-3"><span>مخفیسازی خودکار نوار ابزار</span><MacToggle checked={state.dockAutoHide} onChange={(checked) => onUpdate({ dockAutoHide: checked })} /></div> <SwitchField label="افکت شفافیت" description="شفافیت و Blur تمام پنجره‌ها، منوها و کارت‌های سامانه" checked={state.transparencyEnabled} onCheckedChange={(transparencyEnabled) => onUpdate({ transparencyEnabled })} />
<div className="flex items-center justify-between rounded-2xl bg-white/15 p-3"><span>صدای اعلانها</span><MacToggle checked={state.notificationSound} onChange={(checked) => onUpdate({ notificationSound: checked })} /></div> <SwitchField label="مخفی‌سازی خودکار نوار ابزار" checked={state.dockAutoHide} onCheckedChange={(dockAutoHide) => onUpdate({ dockAutoHide })} />
<SwitchField label="صدای اعلان‌ها" checked={state.notificationSound} onCheckedChange={(notificationSound) => onUpdate({ notificationSound })} />
<label className="block rounded-2xl bg-white/15 p-3 text-sm"> <label className="block rounded-2xl bg-white/15 p-3 text-sm">
تصویر پسزمینه سفارشی تصویر پسزمینه سفارشی
<input type="file" accept="image/*" onChange={(event) => { <input type="file" accept="image/*" onChange={(event) => {
@ -66,10 +68,8 @@ export function MacControlCenter({ open, role, state, onUpdate, onClose, onOpenW
<div><p className="mb-2 text-sm font-bold">پسزمینه</p><div className="grid grid-cols-5 gap-2">{wallpapersConfig.map((w) => <button key={w.id} title={w.nameFa} onClick={() => onUpdate({ wallpaperId: w.id })} className={`h-12 rounded-2xl ring-2 ${state.wallpaperId === w.id ? "ring-white" : "ring-transparent"}`} style={{ background: w.previewUrl }} />)}</div></div> <div><p className="mb-2 text-sm font-bold">پسزمینه</p><div className="grid grid-cols-5 gap-2">{wallpapersConfig.map((w) => <button key={w.id} title={w.nameFa} onClick={() => onUpdate({ wallpaperId: w.id })} className={`h-12 rounded-2xl ring-2 ${state.wallpaperId === w.id ? "ring-white" : "ring-transparent"}`} style={{ background: w.previewUrl }} />)}</div></div>
<button className="w-full rounded-2xl bg-white/20 p-3 text-right">وضعیت سامانه: پایدار</button> <button className="w-full rounded-2xl bg-white/20 p-3 text-right">وضعیت سامانه: پایدار</button>
<button className="w-full rounded-2xl bg-white/20 p-3 text-right">امنیت حساب</button> <button className="w-full rounded-2xl bg-white/20 p-3 text-right">امنیت حساب</button>
<button className="w-full rounded-2xl bg-rose-500/80 p-3 text-white">خروج از حساب</button>
</div> </div>
</section> </AccessibleDialog>
</div>
); );
} }

مشاهده پرونده

@ -5,8 +5,9 @@ import type { DesktopState, DesktopWidgetState } from "@/lib/macDesktopStorage";
import type { MockUser } from "@/services/mock/mockData"; import type { MockUser } from "@/services/mock/mockData";
import type { MacWindowState } from "@/hooks/useWindowManager"; import type { MacWindowState } from "@/hooks/useWindowManager";
import { Rnd } from "react-rnd"; import { Rnd } from "react-rnd";
import { Bell, Folder, Search, SlidersHorizontal } from "lucide-react"; import { Bell, Folder, Search, SlidersHorizontal } from "@/components/shared/ui-icons";
import { MacDesktopIconGrid, desktopIconMetrics } from "./MacDesktopIconGrid"; import { MacDesktopIconGrid } from "./MacDesktopIconGrid";
import { createDesktopGrid, desktopIconMetrics } from "./macDesktopGrid";
import { MacDock } from "./MacDock"; import { MacDock } from "./MacDock";
import { MacMenuBar } from "./MacMenuBar"; import { MacMenuBar } from "./MacMenuBar";
import { MacWallpaper } from "./MacWallpaper"; import { MacWallpaper } from "./MacWallpaper";
@ -15,6 +16,8 @@ import { MacWindowManager } from "./MacWindowManager";
import { MacContextMenu } from "./MacContextMenu"; import { MacContextMenu } from "./MacContextMenu";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MacFolderWindow } from "./MacFolderWindow"; import { MacFolderWindow } from "./MacFolderWindow";
import { AradLearningWorkspace } from "@/components/dashboard/AradLearningWorkspace";
import { AppIconTile } from "@/components/shared/AppIconTile";
export function MacDesktop(props: { export function MacDesktop(props: {
user: MockUser; user: MockUser;
@ -48,6 +51,9 @@ export function MacDesktop(props: {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null); const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
const [folderDraft, setFolderDraft] = useState(""); const [folderDraft, setFolderDraft] = useState("");
const [folderZ, setFolderZ] = useState(185); const [folderZ, setFolderZ] = useState(185);
const [launcherOpen, setLauncherOpen] = useState(true);
const suppressedFolderOpenIds = useRef(new Set<string>());
const folderDragStart = useRef<{ folderId: string; x: number; y: number } | null>(null);
useEffect(() => { useEffect(() => {
let frame = 0; let frame = 0;
const updateViewport = () => { const updateViewport = () => {
@ -72,29 +78,24 @@ export function MacDesktop(props: {
const desktopApps = useMemo(() => props.apps.filter((app) => app.id !== "widgets" && !folderAppIds.has(app.id)), [folderAppIds, props.apps]); const desktopApps = useMemo(() => props.apps.filter((app) => app.id !== "widgets" && !folderAppIds.has(app.id)), [folderAppIds, props.apps]);
const dockRenderApps = useMemo(() => Array.from(new Set([...props.dockApps.map((app) => app.id), ...props.windows.map((win) => win.appId)])).map((id) => props.apps.find((app) => app.id === id)).filter(Boolean) as HomeApp[], [props.apps, props.dockApps, props.windows]); const dockRenderApps = useMemo(() => Array.from(new Set([...props.dockApps.map((app) => app.id), ...props.windows.map((win) => win.appId)])).map((id) => props.apps.find((app) => app.id === id)).filter(Boolean) as HomeApp[], [props.apps, props.dockApps, props.windows]);
const pinnedDockIds = useMemo(() => props.dockApps.map((app) => app.id), [props.dockApps]); const pinnedDockIds = useMemo(() => props.dockApps.map((app) => app.id), [props.dockApps]);
const dockObscured = useMemo(() => props.windows.some((win) => {
if (win.isMinimized) return false;
const bottom = win.isMaximized ? viewport.height - 42 : win.y + win.height;
const left = win.isMaximized ? 16 : win.x;
const right = win.isMaximized ? viewport.width - 16 : win.x + win.width;
const dockLeft = Math.max(16, viewport.width / 2 - 360);
const dockRight = Math.min(viewport.width - 16, viewport.width / 2 + 360);
return bottom > viewport.height - 112 && right > dockLeft && left < dockRight;
}), [props.windows, viewport.height, viewport.width]);
const openFromDock = useCallback((app: HomeApp) => { const openFromDock = useCallback((app: HomeApp) => {
const hasWindow = props.windows.some((win) => win.appId === app.id); const hasWindow = props.windows.some((win) => win.appId === app.id);
if (app.disabledByPlan && !hasWindow) {
props.onDisabled(app.disabledReasonFa ?? "این بخش در طرح فعلی سازمان شما فعال نیست.");
return;
}
props.windowActions.toggleAppWindow(app); props.windowActions.toggleAppWindow(app);
}, [props]); }, [props]);
const openFolder = props.state.folders.find((folder) => folder.id === openFolderId); const openFolder = props.state.folders.find((folder) => folder.id === openFolderId);
const openFolderApps = openFolder ? openFolder.appIds.map((id) => props.apps.find((app) => app.id === id)).filter(Boolean) as HomeApp[] : []; const openFolderApps = openFolder ? openFolder.appIds.map((id) => props.apps.find((app) => app.id === id)).filter(Boolean) as HomeApp[] : [];
const grid = useMemo(() => { const grid = useMemo(() => {
const m = desktopIconMetrics[props.state.iconSize]; return createDesktopGrid(viewport, props.state.iconSize);
const top = 58; }, [props.state.iconSize, viewport]);
const right = 24;
const screenWidth = viewport.width;
const screenHeight = viewport.height;
const rows = Math.max(1, Math.floor((screenHeight - 176) / (m.height + m.gapY)));
const cols = Math.max(1, Math.floor((screenWidth - 48) / (m.width + m.gapX)));
const originX = Math.max(24, screenWidth - right - m.width);
const toCell = (x: number, y: number) => ({ col: Math.max(0, Math.min(cols - 1, Math.round((originX - x) / (m.width + m.gapX)))), row: Math.max(0, Math.min(rows - 1, Math.round((y - top) / (m.height + m.gapY)))) });
const toPoint = (col: number, row: number) => ({ x: Math.max(24, originX - col * (m.width + m.gapX)), y: Math.min(Math.max(top, top + row * (m.height + m.gapY)), screenHeight - m.height - 88) });
return { rows, cols, toCell, toPoint };
}, [props.state.iconSize, viewport.height, viewport.width]);
const occupiedCells = (ignoreId?: string) => { const occupiedCells = (ignoreId?: string) => {
const cells = new Set<string>(); const cells = new Set<string>();
desktopApps.forEach((app, index) => { desktopApps.forEach((app, index) => {
@ -147,10 +148,10 @@ export function MacDesktop(props: {
} }
return null; return null;
}; };
const placeDesktopItem = useCallback((id: string, x: number, y: number) => { const placeDesktopItem = (id: string, x: number, y: number) => {
const cell = findNearestEmptyCell(grid.toCell(x, y), id); const cell = findNearestEmptyCell(grid.toCell(x, y), id);
return cell ? grid.toPoint(cell.col, cell.row) : (props.state.desktopIconPositions[id] ?? { x, y }); return cell ? grid.toPoint(cell.col, cell.row) : (props.state.desktopIconPositions[id] ?? { x, y });
}, [findNearestEmptyCell, grid, props.state.desktopIconPositions]); };
const reflowDesktopIcons = (force: boolean) => { const reflowDesktopIcons = (force: boolean) => {
const nextPositions = force ? {} as Record<string, { x: number; y: number }> : { ...props.state.desktopIconPositions }; const nextPositions = force ? {} as Record<string, { x: number; y: number }> : { ...props.state.desktopIconPositions };
const occupied = new Set<string>(); const occupied = new Set<string>();
@ -275,6 +276,7 @@ export function MacDesktop(props: {
if (app) props.onOpenApp(app); if (app) props.onOpenApp(app);
}, [props]); }, [props]);
const openDesktopMenu = (event: React.MouseEvent) => { const openDesktopMenu = (event: React.MouseEvent) => {
if ((event.target as HTMLElement).closest("[data-mac-dock]")) return;
event.preventDefault(); event.preventDefault();
setContext({ x: event.clientX, y: event.clientY, items: ["پوشه جدید", "مرتب‌سازی آیکون‌ها", "تغییر پس‌زمینه", "افزودن برنامه به صفحه خانه", "افزودن برنامه به نوار ابزار", "اندازه آیکون‌ها", "بازنشانی چیدمان"] }); setContext({ x: event.clientX, y: event.clientY, items: ["پوشه جدید", "مرتب‌سازی آیکون‌ها", "تغییر پس‌زمینه", "افزودن برنامه به صفحه خانه", "افزودن برنامه به نوار ابزار", "اندازه آیکون‌ها", "بازنشانی چیدمان"] });
}; };
@ -311,6 +313,8 @@ export function MacDesktop(props: {
}; };
return ( return (
<div <div
id="main-content"
tabIndex={-1}
className="relative h-screen w-screen overflow-hidden" className="relative h-screen w-screen overflow-hidden"
onContextMenu={openDesktopMenu} onContextMenu={openDesktopMenu}
onDragOver={(event) => { onDragOver={(event) => {
@ -328,14 +332,13 @@ export function MacDesktop(props: {
}} }}
> >
<MacWallpaper wallpaperId={props.state.wallpaperId} theme={props.state.theme} customWallpaperDataUrl={props.state.customWallpaperDataUrl} /> <MacWallpaper wallpaperId={props.state.wallpaperId} theme={props.state.theme} customWallpaperDataUrl={props.state.customWallpaperDataUrl} />
<MacMenuBar user={props.user} onSearch={props.onSearch} onControl={props.onControl} onNotifications={props.onNotifications} onTile={props.windowActions.tileWindows} onMinimizeAll={props.windowActions.minimizeAll} onCloseAll={props.windowActions.closeAll} onShowDesktop={props.windowActions.showDesktop} onLogout={props.onLogout} onSwitchUser={props.onSwitchUser} onProfile={props.onProfile} /> <MacMenuBar user={props.user} profileImageDataUrl={props.state.profileImageDataUrl} onSearch={props.onSearch} onControl={props.onControl} onNotifications={props.onNotifications} onTile={props.windowActions.tileWindows} onMinimizeAll={props.windowActions.minimizeAll} onCloseAll={props.windowActions.closeAll} onShowDesktop={props.windowActions.showDesktop} onLogout={props.onLogout} onSwitchUser={props.onSwitchUser} onProfile={props.onProfile} />
<MacDesktopIconGrid apps={desktopApps} size={props.state.iconSize} viewport={viewport} positions={props.state.desktopIconPositions} onMove={(appId, position) => { props.onUpdateState({ desktopIconPositions: { ...props.state.desktopIconPositions, [appId]: placeDesktopItem(appId, position.x, position.y) } }); props.onToast?.("آیکون جابه‌جا شد."); }} onOpen={props.onOpenApp} onDisabled={props.onDisabled} onIconContextMenu={openIconMenu} /> {!launcherOpen ? <AradLearningWorkspace user={props.user} apps={props.apps} onOpenApp={props.onOpenApp} onShowLauncher={() => setLauncherOpen(true)} onSearch={props.onSearch} onNotifications={props.onNotifications} /> : null}
{props.state.folders.map((folder) => ( {launcherOpen ? <MacDesktopIconGrid apps={desktopApps} size={props.state.iconSize} viewport={viewport} positions={props.state.desktopIconPositions} onMove={(appId, position) => { props.onUpdateState({ desktopIconPositions: { ...props.state.desktopIconPositions, [appId]: placeDesktopItem(appId, position.x, position.y) } }); props.onToast?.("آیکون جابه‌جا شد."); }} onOpen={props.onOpenApp} onDisabled={props.onDisabled} onIconContextMenu={openIconMenu} /> : null}
<Rnd key={folder.id} bounds="parent" size={{ width: 126, height: 116 }} position={{ x: folder.x, y: folder.y }} enableResizing={false} onDragStop={(_, d) => { const point = placeDesktopItem(folder.id, d.x, d.y); props.onUpdateState({ folders: props.state.folders.map((item) => item.id === folder.id ? { ...item, ...point } : item) }); }} style={{ zIndex: 13 }}> {launcherOpen ? props.state.folders.map((folder) => (
<button onContextMenu={(event) => openFolderMenu(event, folder.id)} onDoubleClick={() => setOpenFolderId(folder.id)} onClick={() => setOpenFolderId(folder.id)} className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl p-2 text-center text-xs font-bold text-white drop-shadow-lg hover:bg-white/15"> <Rnd key={folder.id} bounds="parent" size={{ width: 126, height: 116 }} position={{ x: folder.x, y: folder.y }} enableResizing={false} onDragStart={(_, d) => { folderDragStart.current = { folderId: folder.id, x: d.x, y: d.y }; }} onDragStop={(_, d) => { const start = folderDragStart.current?.folderId === folder.id ? folderDragStart.current : null; const didDrag = Boolean(start && Math.hypot(d.x - start.x, d.y - start.y) >= 6); folderDragStart.current = null; if (!didDrag) return; suppressedFolderOpenIds.current.add(folder.id); const point = placeDesktopItem(folder.id, d.x, d.y); props.onUpdateState({ folders: props.state.folders.map((item) => item.id === folder.id ? { ...item, ...point } : item) }); window.setTimeout(() => suppressedFolderOpenIds.current.delete(folder.id), 250); }} style={{ zIndex: 13 }}>
<span className="flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-br from-amber-200 to-orange-500 text-amber-950 shadow-glass ring-1 ring-white/35"> <button onContextMenu={(event) => openFolderMenu(event, folder.id)} onClick={() => { if (!suppressedFolderOpenIds.current.has(folder.id)) setOpenFolderId(folder.id); }} className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl p-2 text-center text-xs font-bold text-white drop-shadow-lg hover:bg-white/15">
<Folder className="h-8 w-8" /> <AppIconTile Icon={Folder} seed="desktop-folder" gradientClass="from-amber-400 to-orange-500" size="lg" />
</span>
{editingFolderId === folder.id ? ( {editingFolderId === folder.id ? (
<input autoFocus value={folderDraft} onClick={(event) => event.stopPropagation()} onChange={(event) => setFolderDraft(event.target.value)} onBlur={() => saveFolderName(folder.id)} onKeyDown={(event) => { if (event.key === "Enter") saveFolderName(folder.id); if (event.key === "Escape") { setEditingFolderId(null); setFolderDraft(""); } }} className="w-full rounded-xl bg-white/90 px-2 py-1 text-center text-slate-900 outline-none" /> <input autoFocus value={folderDraft} onClick={(event) => event.stopPropagation()} onChange={(event) => setFolderDraft(event.target.value)} onBlur={() => saveFolderName(folder.id)} onKeyDown={(event) => { if (event.key === "Enter") saveFolderName(folder.id); if (event.key === "Escape") { setEditingFolderId(null); setFolderDraft(""); } }} className="w-full rounded-xl bg-white/90 px-2 py-1 text-center text-slate-900 outline-none" />
) : ( ) : (
@ -343,15 +346,15 @@ export function MacDesktop(props: {
)} )}
</button> </button>
</Rnd> </Rnd>
))} )) : null}
<div className="liquid-glass group absolute left-0 top-16 z-20 hidden -translate-x-[calc(100%-18px)] items-center gap-2 overflow-hidden rounded-full px-3 py-2 text-xs text-slate-900/90 transition hover:translate-x-0 dark:text-white/90 lg:flex"> <div className="liquid-glass group absolute left-0 top-16 z-20 hidden -translate-x-[calc(100%-18px)] items-center gap-2 overflow-hidden rounded-full px-3 py-2 text-xs text-slate-900/90 transition hover:translate-x-0 dark:text-white/90 lg:flex">
<button onClick={props.onSearch} className="flex items-center gap-1 rounded-full bg-white/15 px-3 py-1"><Search className="h-3.5 w-3.5" />جستجو</button> <button onClick={props.onSearch} className="flex items-center gap-1 rounded-full bg-white/15 px-3 py-1"><Search className="h-3.5 w-3.5" />جستجو</button>
<button onClick={props.onControl} className="flex items-center gap-1 rounded-full bg-white/15 px-3 py-1"><SlidersHorizontal className="h-3.5 w-3.5" />تنظیمات</button> <button onClick={props.onControl} className="flex items-center gap-1 rounded-full bg-white/15 px-3 py-1"><SlidersHorizontal className="h-3.5 w-3.5" />تنظیمات</button>
<button onClick={props.onNotifications} className="flex items-center gap-1 rounded-full bg-white/15 px-3 py-1"><Bell className="h-3.5 w-3.5" />اعلانها</button> <button onClick={props.onNotifications} className="flex items-center gap-1 rounded-full bg-white/15 px-3 py-1"><Bell className="h-3.5 w-3.5" />اعلانها</button>
</div> </div>
<MacWidgetLayer role={props.user.role} layout={props.state.widgetLayout} forbiddenRects={desktopForbiddenRects} isManaging={Boolean(props.widgetsManageMode)} onLayoutChange={(widgetLayout) => props.onUpdateState({ widgetLayout, enabledWidgets: widgetLayout.widgets.filter((widget: DesktopWidgetState) => widget.isVisible).map((widget: DesktopWidgetState) => widget.widgetId), widgetsInitialized: true })} onOpenAppId={openAppById} /> {launcherOpen ? <MacWidgetLayer role={props.user.role} layout={props.state.widgetLayout} forbiddenRects={desktopForbiddenRects} isManaging={Boolean(props.widgetsManageMode)} onLayoutChange={(widgetLayout) => props.onUpdateState({ widgetLayout, enabledWidgets: widgetLayout.widgets.filter((widget: DesktopWidgetState) => widget.isVisible).map((widget: DesktopWidgetState) => widget.widgetId), widgetsInitialized: true })} onOpenAppId={openAppById} /> : null}
<MacWindowManager windows={props.windows} role={props.user.role} desktopState={props.state as DesktopState} onUpdateDesktopState={props.onUpdateState} onToast={props.onToast} focusWindow={props.windowActions.focusWindow} updateWindow={props.windowActions.updateWindow} closeWindow={props.windowActions.closeWindow} /> <MacWindowManager windows={props.windows} role={props.user.role} desktopState={props.state as DesktopState} onUpdateDesktopState={props.onUpdateState} onToast={props.onToast} focusWindow={props.windowActions.focusWindow} updateWindow={props.windowActions.updateWindow} closeWindow={props.windowActions.closeWindow} />
<MacDock apps={dockRenderApps} allApps={props.apps} pinnedAppIds={pinnedDockIds} windows={props.windows} autoHide={props.state.dockAutoHide} onOpen={openFromDock} onAddDock={props.onAddDockIcon} onAddToDock={props.onAddToDock} onRemoveFromDock={props.onRemoveFromDock} onCloseApp={props.windowActions.closeWindowsByAppId} onReorderDock={reorderDock} /> <MacDock apps={dockRenderApps} allApps={props.apps} pinnedAppIds={pinnedDockIds} windows={props.windows} autoHide={props.state.dockAutoHide} obscured={dockObscured} launcherOpen={launcherOpen} onToggleWorkspace={() => setLauncherOpen((value) => !value)} onOpen={openFromDock} onAddDock={props.onAddDockIcon} onAddToDock={props.onAddToDock} onRemoveFromDock={props.onRemoveFromDock} onCloseApp={props.windowActions.closeWindowsByAppId} onReorderDock={reorderDock} />
{context && <MacContextMenu x={context.x} y={context.y} items={context.items} onSelect={selectContext} onClose={closeContext} />} {context && <MacContextMenu x={context.x} y={context.y} items={context.items} onSelect={selectContext} onClose={closeContext} />}
{openFolder && <MacFolderWindow folder={openFolder} apps={openFolderApps} iconSize={props.state.iconSize} zIndex={folderZ} onFocus={() => setFolderZ((value) => value + 1)} onClose={() => setOpenFolderId(null)} onOpenApp={props.onOpenApp} onDisabled={props.onDisabled} onDropApp={(appId) => moveAppToFolder(appId, openFolder.id)} onMoveToDesktop={(appId) => moveAppToDesktop(appId, openFolder.id)} />} {openFolder && <MacFolderWindow folder={openFolder} apps={openFolderApps} iconSize={props.state.iconSize} zIndex={folderZ} onFocus={() => setFolderZ((value) => value + 1)} onClose={() => setOpenFolderId(null)} onOpenApp={props.onOpenApp} onDisabled={props.onDisabled} onDropApp={(appId) => moveAppToFolder(appId, openFolder.id)} onMoveToDesktop={(appId) => moveAppToDesktop(appId, openFolder.id)} />}
</div> </div>

مشاهده پرونده

@ -10,16 +10,21 @@ export const MacDesktopIcon = memo(function MacDesktopIcon({ app, size, isDraggi
const labelWidth = size === "large" ? "max-w-[88px]" : size === "medium" ? "max-w-[82px]" : "max-w-[76px]"; const labelWidth = size === "large" ? "max-w-[88px]" : size === "medium" ? "max-w-[82px]" : "max-w-[76px]";
const labelHeight = size === "large" ? "min-h-9" : "min-h-8"; const labelHeight = size === "large" ? "min-h-9" : "min-h-8";
const color = appIconColors[app.id] ?? fallbackIconColor; const color = appIconColors[app.id] ?? fallbackIconColor;
const planState = app as HomeApp & { disabledByPlan?: boolean; disabledReasonFa?: string };
return ( return (
<button <div className={`inline-flex w-fit select-none flex-col items-center justify-start gap-2 p-1 text-center text-xs font-bold text-white transition duration-150 ${isDragging ? "scale-[1.04] opacity-95" : ""}`}>
onDoubleClick={() => app.disabledByPlan ? onDisabled(app.disabledReasonFa ?? "این بخش در طرح فعلی سازمان شما فعال نیست.") : onOpen(app)} <button
onClick={() => app.disabledByPlan && onDisabled(app.disabledReasonFa ?? "این بخش در طرح فعلی سازمان شما فعال نیست.")} type="button"
onContextMenu={(event) => onContextMenu(event, app)} onDoubleClick={() => planState.disabledByPlan ? onDisabled(planState.disabledReasonFa ?? "این بخش در طرح فعلی سازمان شما فعال نیست.") : onOpen(app)}
title={app.disabledByPlan ? app.disabledReasonFa : app.labelFa} onClick={() => planState.disabledByPlan && onDisabled(planState.disabledReasonFa ?? "این بخش در طرح فعلی سازمان شما فعال نیست.")}
className={`inline-flex w-fit cursor-grab select-none flex-col items-center justify-start gap-2 rounded-xl p-1 text-center text-xs font-bold text-slate-900 drop-shadow-sm transition duration-150 active:scale-95 dark:text-white dark:drop-shadow-lg ${isDragging ? "scale-[1.04] cursor-grabbing opacity-95" : ""} ${app.disabledByPlan ? "grayscale opacity-45" : ""}`} onContextMenu={(event) => onContextMenu(event, app)}
> aria-label={`${app.labelFa}، باز کردن`}
<GlassIcon name={app.icon} seed={app.id} gradientClass={color} size={size === "small" ? "md" : size === "large" ? "lg" : "md"} className={dimension} /> title={planState.disabledByPlan ? planState.disabledReasonFa : app.labelFa}
<span className={`line-clamp-2 ${labelHeight} ${labelWidth} rounded-lg px-1.5 py-0.5 leading-5 text-slate-900 [text-shadow:0_1px_10px_rgba(255,255,255,0.74)] dark:text-white dark:[text-shadow:0_1px_8px_rgba(0,0,0,0.5)] ${isSelected ? "bg-sky-500/20 dark:bg-sky-400/25" : ""}`}>{app.labelFa}</span> className={`${dimension} shrink-0 cursor-grab rounded-[20px] p-0 transition duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${isDragging ? "cursor-grabbing" : ""} ${isSelected ? "ring-2 ring-cyan-300 ring-offset-2 ring-offset-transparent" : ""} ${planState.disabledByPlan ? "grayscale opacity-45" : ""}`}
</button> >
<GlassIcon borderless name={app.icon} seed={app.id} gradientClass={color} size={size === "small" ? "md" : size === "large" ? "lg" : "md"} className={dimension} />
</button>
<span className={`pointer-events-none line-clamp-2 ${labelHeight} ${labelWidth} rounded-lg bg-slate-950/55 px-1.5 py-0.5 leading-5 text-white shadow-sm backdrop-blur-[2px] [text-shadow:0_1px_3px_rgba(0,0,0,0.9)]`}>{app.labelFa}</span>
</div>
); );
}); });

مشاهده پرونده

@ -1,42 +1,30 @@
"use client"; "use client";
import { Rnd } from "react-rnd"; import { Rnd } from "react-rnd";
import { memo, useMemo, useState } from "react"; import { memo, useMemo, useRef, useState } from "react";
import type { HomeApp } from "@/config/homeAppsConfig"; import type { HomeApp } from "@/config/homeAppsConfig";
import { MacDesktopIcon } from "./MacDesktopIcon"; import { MacDesktopIcon } from "./MacDesktopIcon";
import { createDesktopGrid, desktopIconMetrics } from "./macDesktopGrid";
const metrics = { export { desktopIconMetrics } from "./macDesktopGrid";
small: { width: 116, height: 112, icon: 112, gapX: 12, gapY: 12 },
medium: { width: 122, height: 122, icon: 116, gapX: 13, gapY: 13 },
large: { width: 128, height: 132, icon: 122, gapX: 14, gapY: 14 }
};
export const desktopIconMetrics = metrics;
export const MacDesktopIconGrid = memo(function MacDesktopIconGrid({ apps, size, viewport, positions = {}, onMove, onOpen, onDisabled, onIconContextMenu }: { apps: HomeApp[]; size: "small" | "medium" | "large"; viewport: { width: number; height: number }; positions?: Record<string, { x: number; y: number }>; onMove?: (appId: string, position: { x: number; y: number }) => void; onOpen: (app: HomeApp) => void; onDisabled: (message: string) => void; onIconContextMenu: (event: React.MouseEvent, app: HomeApp) => void }) { export const MacDesktopIconGrid = memo(function MacDesktopIconGrid({ apps, size, viewport, positions = {}, onMove, onOpen, onDisabled, onIconContextMenu }: { apps: HomeApp[]; size: "small" | "medium" | "large"; viewport: { width: number; height: number }; positions?: Record<string, { x: number; y: number }>; onMove?: (appId: string, position: { x: number; y: number }) => void; onOpen: (app: HomeApp) => void; onDisabled: (message: string) => void; onIconContextMenu: (event: React.MouseEvent, app: HomeApp) => void }) {
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const m = metrics[size]; const suppressedOpenIds = useRef(new Set<string>());
const top = 58; const dragStart = useRef<{ appId: string; x: number; y: number } | null>(null);
const right = 24; const grid = useMemo(() => createDesktopGrid(viewport, size), [size, viewport]);
const screenWidth = viewport.width; const m = desktopIconMetrics[size];
const screenHeight = viewport.height;
const rows = Math.max(1, Math.floor((screenHeight - 176) / (m.height + m.gapY)));
const cols = Math.max(1, Math.floor((screenWidth - 48) / (m.width + m.gapX)));
const originX = Math.max(24, screenWidth - right - m.width);
const renderedApps = useMemo(() => apps.map((app, index) => { const renderedApps = useMemo(() => apps.map((app, index) => {
const col = Math.min(cols - 1, Math.floor(index / rows)); const col = Math.min(grid.cols - 1, Math.floor(index / grid.rows));
const row = index % rows; const row = index % grid.rows;
const fallback = { x: Math.max(24, originX - col * (m.width + m.gapX)), y: Math.min(top + row * (m.height + m.gapY), screenHeight - m.height - 88) }; const fallback = grid.toPoint(col, row);
return { app, position: positions[app.id] ?? fallback }; return { app, position: positions[app.id] ?? fallback };
}), [apps, cols, m.gapX, m.height, m.width, m.gapY, originX, positions, rows, screenHeight]); }), [apps, grid, positions]);
const snap = (x: number, y: number) => {
const col = Math.max(0, Math.round((originX - x) / (m.width + m.gapX))); const openUnlessDragged = (app: HomeApp) => {
const row = Math.max(0, Math.round((y - top) / (m.height + m.gapY))); if (suppressedOpenIds.current.has(app.id)) return;
return { onOpen(app);
x: Math.max(24, originX - col * (m.width + m.gapX)),
y: Math.min(Math.max(top, top + row * (m.height + m.gapY)), screenHeight - m.height - 88)
};
}; };
return ( return (
<> <>
@ -48,15 +36,23 @@ export const MacDesktopIconGrid = memo(function MacDesktopIconGrid({ apps, size,
size={{ width: m.width, height: m.height }} size={{ width: m.width, height: m.height }}
position={position} position={position}
enableResizing={false} enableResizing={false}
onDragStart={() => { setDraggingId(app.id); setSelectedId(app.id); }} onDragStart={(_, d) => {
onDragStop={(_, d) => { dragStart.current = { appId: app.id, x: d.x, y: d.y };
setDraggingId(null); setDraggingId(app.id);
onMove?.(app.id, snap(d.x, d.y)); setSelectedId(app.id);
window.setTimeout(() => setDraggingId(null), 0);
}} }}
onMouseUp={() => setDraggingId(null)} onDragStop={(_, d) => {
onPointerUp={(event: any) => { const start = dragStart.current?.appId === app.id ? dragStart.current : null;
const didDrag = Boolean(start && Math.hypot(d.x - start.x, d.y - start.y) >= 6);
dragStart.current = null;
setDraggingId(null); setDraggingId(null);
if (didDrag) {
suppressedOpenIds.current.add(app.id);
onMove?.(app.id, grid.snap(d.x, d.y));
window.setTimeout(() => suppressedOpenIds.current.delete(app.id), 250);
}
}}
onPointerUp={(event: any) => {
try { try {
if (event.currentTarget?.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); if (event.currentTarget?.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
} catch { } catch {
@ -66,7 +62,7 @@ export const MacDesktopIconGrid = memo(function MacDesktopIconGrid({ apps, size,
className={`flex items-start justify-center ${draggingId === app.id ? "cursor-grabbing" : ""}`} className={`flex items-start justify-center ${draggingId === app.id ? "cursor-grabbing" : ""}`}
style={{ zIndex: draggingId === app.id ? 30 : 12, transition: draggingId === app.id ? "none" : "transform 160ms ease" }} style={{ zIndex: draggingId === app.id ? 30 : 12, transition: draggingId === app.id ? "none" : "transform 160ms ease" }}
> >
<MacDesktopIcon app={app} size={size} isDragging={draggingId === app.id} isSelected={selectedId === app.id} onOpen={onOpen} onDisabled={onDisabled} onContextMenu={onIconContextMenu} /> <MacDesktopIcon app={app} size={size} isDragging={draggingId === app.id} isSelected={selectedId === app.id} onOpen={openUnlessDragged} onDisabled={(message) => { if (!suppressedOpenIds.current.has(app.id)) onDisabled(message); }} onContextMenu={onIconContextMenu} />
</Rnd> </Rnd>
); );
})} })}

مشاهده پرونده

@ -1,12 +1,13 @@
"use client"; "use client";
import { Plus } from "lucide-react";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import type { DragEvent } from "react"; import type { DragEvent } from "react";
import type { HomeApp } from "@/config/homeAppsConfig"; import type { HomeApp } from "@/config/homeAppsConfig";
import type { MacWindowState } from "@/hooks/useWindowManager"; import type { MacWindowState } from "@/hooks/useWindowManager";
import { MacDockContextMenu, type MacDockContextMenuState } from "./MacDockContextMenu"; import { MacDockContextMenu, type MacDockContextMenuState } from "./MacDockContextMenu";
import { MacDockIcon } from "./MacDockIcon"; import { MacDockIcon } from "./MacDockIcon";
import { UiIcon } from "@/components/shared/UiIcon";
import { AppIconTile } from "@/components/shared/AppIconTile";
type DockItem = { type DockItem = {
appId: string; appId: string;
@ -27,6 +28,9 @@ export function MacDock({
pinnedAppIds, pinnedAppIds,
windows, windows,
autoHide, autoHide,
obscured,
launcherOpen,
onToggleWorkspace,
onOpen, onOpen,
onAddDock, onAddDock,
onAddToDock, onAddToDock,
@ -39,6 +43,9 @@ export function MacDock({
pinnedAppIds: string[]; pinnedAppIds: string[];
windows: MacWindowState[]; windows: MacWindowState[];
autoHide: boolean; autoHide: boolean;
obscured: boolean;
launcherOpen: boolean;
onToggleWorkspace: () => void;
onOpen: (app: HomeApp) => void; onOpen: (app: HomeApp) => void;
onAddDock: () => void; onAddDock: () => void;
onAddToDock: (app: HomeApp) => void; onAddToDock: (app: HomeApp) => void;
@ -49,6 +56,7 @@ export function MacDock({
const [menu, setMenu] = useState<MacDockContextMenuState | null>(null); const [menu, setMenu] = useState<MacDockContextMenuState | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropBeforeId, setDropBeforeId] = useState<string | null>(null); const [dropBeforeId, setDropBeforeId] = useState<string | null>(null);
const [forcedVisible, setForcedVisible] = useState(false);
const dockRef = useRef<HTMLDivElement>(null); const dockRef = useRef<HTMLDivElement>(null);
const pinnedIds = useMemo(() => unique(pinnedAppIds), [pinnedAppIds]); const pinnedIds = useMemo(() => unique(pinnedAppIds), [pinnedAppIds]);
@ -75,6 +83,12 @@ export function MacDock({
y, y,
isPinned: pinnedSet.has(appId), isPinned: pinnedSet.has(appId),
canClose: windows.some((win) => win.appId === appId) canClose: windows.some((win) => win.appId === appId)
,
windows: windows.filter((win) => win.appId === appId).map((win) => ({
id: win.id,
titleFa: win.titleFa,
isMinimized: win.isMinimized
}))
}); });
}; };
@ -122,8 +136,26 @@ export function MacDock({
setDropBeforeId(null); setDropBeforeId(null);
}; };
const hiddenByWindow = obscured && !forcedVisible;
return ( return (
<nav className={`fixed bottom-4 left-1/2 z-[900] max-w-[calc(100vw-32px)] -translate-x-1/2 overflow-visible transition ${autoHide ? "translate-y-16 hover:translate-y-0" : ""}`}> <>
{obscured ? <div className="fixed inset-x-0 bottom-0 z-[899] h-5" onPointerEnter={() => setForcedVisible(true)} aria-hidden="true" /> : null}
<nav
data-mac-dock="true"
aria-label="نوار برنامه‌ها"
className={`fixed bottom-4 left-1/2 z-[900] max-w-[calc(100vw-32px)] -translate-x-1/2 overflow-visible transition-[opacity,transform,filter] duration-200 ${autoHide ? "translate-y-16 hover:translate-y-0" : ""} ${hiddenByWindow ? "opacity-[.08] blur-[1px] hover:opacity-100 hover:blur-0" : "opacity-100"}`}
onPointerEnter={() => setForcedVisible(true)}
onPointerLeave={() => setForcedVisible(false)}
onContextMenuCapture={(event) => {
const target = event.target as HTMLElement;
const appTarget = target.closest<HTMLElement>("[data-dock-app-id]");
const appId = appTarget?.dataset.dockAppId;
event.preventDefault();
event.stopPropagation();
if (appId) openDockContextMenu(appId, event.clientX, event.clientY);
}}
>
<div <div
ref={dockRef} ref={dockRef}
className="flex max-w-full items-end gap-1.5 overflow-x-auto overflow-y-visible bg-transparent px-2 pb-1.5 pt-4 shadow-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="flex max-w-full items-end gap-1.5 overflow-x-auto overflow-y-visible bg-transparent px-2 pb-1.5 pt-4 shadow-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
@ -165,8 +197,20 @@ export function MacDock({
); );
})} })}
<span className="mx-0.5 h-8 w-px shrink-0 bg-white/24" /> <span className="mx-0.5 h-8 w-px shrink-0 bg-white/24" />
<button type="button" onClick={onAddDock} onContextMenu={(event) => event.preventDefault()} className="relative z-10 flex h-9 w-9 shrink-0 items-center justify-center rounded-[12px] text-slate-900 drop-shadow-[0_1px_8px_rgba(255,255,255,0.75)] transition duration-180 hover:z-50 hover:-translate-y-3 hover:scale-125 dark:text-white dark:drop-shadow-[0_1px_8px_rgba(0,0,0,0.55)]"> <button
<Plus className="h-[18px] w-[18px]" /> type="button"
data-dock-workspace-toggle="true"
aria-label={launcherOpen ? "نمایش میز کار" : "نمایش برنامه‌های دسکتاپ"}
aria-pressed={!launcherOpen}
title={launcherOpen ? "میز کار" : "برنامه‌ها"}
onClick={onToggleWorkspace}
onContextMenu={(event) => event.preventDefault()}
className="relative z-10 flex h-11 w-11 shrink-0 items-center justify-center rounded-[12px] bg-transparent p-0 transition-transform duration-180 hover:z-50 hover:-translate-y-1 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500"
>
<AppIconTile name={launcherOpen ? "LayoutDashboard" : "LayoutGrid"} seed="workspace-toggle" gradientClass="from-cyan-400 via-blue-500 to-violet-600" size="dock" />
</button>
<button type="button" aria-label="افزودن برنامه به نوار ابزار" onClick={onAddDock} onContextMenu={(event) => event.preventDefault()} className="relative z-10 flex h-11 w-11 shrink-0 items-center justify-center rounded-[12px] text-slate-900 drop-shadow-[0_1px_8px_rgba(255,255,255,0.75)] transition duration-180 hover:z-50 hover:-translate-y-1 hover:scale-105 dark:text-white dark:drop-shadow-[0_1px_8px_rgba(0,0,0,0.55)]">
<UiIcon name="Plus" className="h-[18px] w-[18px]" />
</button> </button>
</div> </div>
@ -175,12 +219,19 @@ export function MacDock({
menu={menu} menu={menu}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
onCloseApp={onCloseApp} onCloseApp={onCloseApp}
onUnpin={(appId) => { onOpenApp={(appId) => {
const app = allApps.find((item) => item.id === appId); const app = allApps.find((item) => item.id === appId);
if (app) onRemoveFromDock(app); if (app) onOpen(app);
}}
onTogglePin={(appId, shouldPin) => {
const app = allApps.find((item) => item.id === appId);
if (!app) return;
if (shouldPin) onAddToDock(app);
else onRemoveFromDock(app);
}} }}
/> />
) : null} ) : null}
</nav> </nav>
</>
); );
} }

مشاهده پرونده

@ -1,7 +1,8 @@
"use client"; "use client";
import { Trash2, X } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { UiIcon } from "@/components/shared/UiIcon";
import { MacButton, MacGlassPanel } from "./MacUI"; import { MacButton, MacGlassPanel } from "./MacUI";
export type MacDockContextMenuState = { export type MacDockContextMenuState = {
@ -10,77 +11,76 @@ export type MacDockContextMenuState = {
y: number; y: number;
isPinned: boolean; isPinned: boolean;
canClose: boolean; canClose: boolean;
windows: Array<{ id: string; titleFa: string; isMinimized: boolean }>;
}; };
function getSafeMenuPosition(clientX: number, clientY: number, menuWidth = 220, menuHeight = 120, padding = 8) { function getSafeMenuPosition(clientX: number, clientY: number, menuWidth: number, menuHeight: number, padding = 8) {
const viewportWidth = window.innerWidth; const opensToLeft = clientX + menuWidth + padding > window.innerWidth;
const viewportHeight = window.innerHeight; const opensUpward = clientY + menuHeight + padding > window.innerHeight;
let x = clientX; const x = Math.max(padding, opensToLeft ? clientX - menuWidth : clientX);
let y = clientY; const y = Math.max(padding, opensUpward ? clientY - menuHeight : clientY);
if (x + menuWidth + padding > viewportWidth) x = viewportWidth - menuWidth - padding;
if (y + menuHeight + padding > viewportHeight) y = viewportHeight - menuHeight - padding;
if (x < padding) x = padding;
if (y < padding) y = padding;
return { x, y }; return { x, y };
} }
export function MacDockContextMenu({ export function MacDockContextMenu({
menu, menu,
onClose, onClose,
onOpenApp,
onCloseApp, onCloseApp,
onUnpin onTogglePin
}: { }: {
menu: MacDockContextMenuState; menu: MacDockContextMenuState;
onClose: () => void; onClose: () => void;
onOpenApp: (appId: string) => void;
onCloseApp: (appId: string) => void; onCloseApp: (appId: string) => void;
onUnpin: (appId: string) => void; onTogglePin: (appId: string, shouldPin: boolean) => void;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(() => getSafeMenuPosition(menu.x, menu.y)); const [position, setPosition] = useState({ x: menu.x, y: menu.y });
const [measured, setMeasured] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
setPosition(getSafeMenuPosition(menu.x, menu.y)); const panel = ref.current;
}, [menu.x, menu.y]); if (!panel) return;
const updatePosition = () => {
const rect = panel.getBoundingClientRect();
setPosition(getSafeMenuPosition(menu.x, menu.y, rect.width, rect.height));
setMeasured(true);
};
updatePosition();
window.addEventListener("resize", updatePosition);
return () => window.removeEventListener("resize", updatePosition);
}, [menu.x, menu.y, menu.windows.length, menu.isPinned, menu.canClose]);
useEffect(() => { useEffect(() => {
const close = (event: PointerEvent) => { const closeOutside = (event: PointerEvent) => {
if (ref.current?.contains(event.target as Node)) return; if (!ref.current?.contains(event.target as Node)) onClose();
onClose();
}; };
const closeWithEscape = (event: KeyboardEvent) => { const closeWithEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose(); if (event.key === "Escape") onClose();
}; };
window.addEventListener("pointerdown", close, true); window.addEventListener("pointerdown", closeOutside, true);
window.addEventListener("keydown", closeWithEscape); window.addEventListener("keydown", closeWithEscape);
return () => { return () => {
window.removeEventListener("pointerdown", close, true); window.removeEventListener("pointerdown", closeOutside, true);
window.removeEventListener("keydown", closeWithEscape); window.removeEventListener("keydown", closeWithEscape);
}; };
}, [onClose]); }, [onClose]);
return ( return createPortal(
<MacGlassPanel <MacGlassPanel ref={ref} data-dock-context-menu className="fixed z-[1000] w-[230px] space-y-2 rounded-[18px] p-2.5 text-right text-sm" style={{ left: position.x, top: position.y, visibility: measured ? "visible" : "hidden" }} dir="rtl" onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.preventDefault()}>
ref={ref} {!menu.windows.length ? <MacButton intent="neutral" className="w-full justify-start border-0 bg-transparent px-3 shadow-none hover:bg-[var(--color-selected)]" onClick={() => { onOpenApp(menu.appId); onClose(); }}>
className="fixed z-[1000] w-[220px] rounded-[16px] p-2 text-right text-sm text-slate-900 dark:text-white" <UiIcon name="Play" className="h-4 w-4" />باز کردن برنامه
style={{ left: position.x, top: position.y }} </MacButton> : null}
dir="rtl" {menu.isPinned ? <MacButton intent="neutral" className="w-full justify-start border-0 bg-transparent px-3 shadow-none hover:bg-[var(--color-selected)]" onClick={() => { onTogglePin(menu.appId, false); onClose(); }}>
onPointerDown={(event) => event.stopPropagation()} <UiIcon name="PinOff" className="h-4 w-4" />حذف از داک
onContextMenu={(event) => { </MacButton> : null}
event.preventDefault();
event.stopPropagation();
}}
>
{menu.canClose ? ( {menu.canClose ? (
<MacButton tone="subtle" className="w-full justify-start px-3" onClick={() => { onCloseApp(menu.appId); onClose(); }}> <MacButton intent="neutral" className="w-full justify-start border-0 bg-transparent px-3 shadow-none hover:bg-[var(--color-selected)]" onClick={() => { onCloseApp(menu.appId); onClose(); }}>
<X className="h-4 w-4" /> <UiIcon name="CircleX" className="h-4 w-4" />
بستن بستن
</MacButton> </MacButton>
) : ( ) : null}
<MacButton tone="subtle" className="w-full justify-start px-3" onClick={() => { onUnpin(menu.appId); onClose(); }}> </MacGlassPanel>,
<Trash2 className="h-4 w-4" /> document.body
حذف از داک
</MacButton>
)}
</MacGlassPanel>
); );
} }

برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر