update project files
|
پس از عرض: | ارتفاع: | اندازه: 1.4 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.2 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.4 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.1 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.0 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.2 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.1 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.3 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.1 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.3 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 211 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 115 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 723 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 487 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 721 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 226 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 979 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.1 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.1 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.1 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 534 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 76 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 76 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 209 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 664 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 703 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 112 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 775 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 831 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 419 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 558 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 106 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 143 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 207 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 875 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 872 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 780 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 780 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 780 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 839 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 182 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 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`
|
||||||
|
|
@ -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 در سقف مصرف این نشست باز ماندهاند و عمداً تیک نخوردهاند
|
||||||
|
|
@ -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.
|
||||||
|
پس از عرض: | ارتفاع: | اندازه: 988 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 980 KiB |
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||