Add user authentication feature
This commit is contained in:
parent
c6fbb9c7f2
commit
1f69cb9e7d
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "uniradar",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
88
README.md
88
README.md
|
|
@ -1,32 +1,66 @@
|
|||
# React + TypeScript + Vite
|
||||
# رادار آینده دانشگاه اصفهان
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
سامانه رصد و آیندهپژوهی آموزش عالی، با افق راهبردی **۱۴۰۵ تا ۱۴۱۵**.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## اجرا
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
| فرمان | کار |
|
||||
| ----------------- | -------------------------------------- |
|
||||
| `npm run dev` | اجرای محیط توسعه |
|
||||
| `npm run build` | بررسی نوعها و ساخت نسخه تولید |
|
||||
| `npm run preview` | پیشنمایش نسخه ساختهشده |
|
||||
| `npm run lint` | اجرای oxlint |
|
||||
|
||||
## بخشها
|
||||
|
||||
**رصد**
|
||||
|
||||
- **رادار آینده** — چهار جریان: روندها، فناوریها، سیاستها و سیگنالهای آینده.
|
||||
سه نما دارد: رادار دایرهای، کارت و جدول.
|
||||
- **بازار کار آینده** — مشاغل رو به رشد، مشاغل در معرض تغییر، مهارتهای رو به
|
||||
رشد و مهارتهای در حال کاهش.
|
||||
|
||||
**افق ۱۴۱۵**
|
||||
|
||||
- **دانشگاه اصفهان ۱۴۱۵** — ده ویژگی دانشگاه مطلوب و نمره وضع موجود هر کدام.
|
||||
- **تحلیل شکاف** — نمودار عنکبوتی فاصله وضع موجود تا هدف.
|
||||
- **مثلث آینده** — کشش آینده، فشارهای حال و وزن گذشته.
|
||||
- **شبکه روابط** — زنجیره اثر از پیشران بیرونی تا شکاف قابلیت.
|
||||
- **گزارشها** — خلاصه آماده چاپ.
|
||||
|
||||
**سامانه** — مدیریت دادهها و تنظیمات (پشتیبانگیری، بازیابی، حالت نمایش).
|
||||
|
||||
## ورود
|
||||
|
||||
نام کاربری و رمز عبور، کد یکبار مصرف، یا ورود سریع با یکی از چهار نقش
|
||||
(ریاست، پژوهشگر، برنامهریز، مدیر سامانه). دادهها در `localStorage` مرورگر
|
||||
نگهداری میشوند.
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
src/
|
||||
components/ui/ اجزای shadcn/ui
|
||||
components/layout/ پوسته برنامه (نوار کناری، سربرگ)
|
||||
components/visual/ رادار، نمودار عنکبوتی، مثلث، نمودارها
|
||||
components/cards/ کارتهای موارد رصد، ویژگیها، مشاغل و مهارتها
|
||||
pages/ صفحهها
|
||||
services/ داده و احراز هویت
|
||||
utils/ افق زمانی، دستهها، ارقام فارسی، ریاضیات رادار
|
||||
index.css توکنهای طراحی (روشن و تیره)
|
||||
```
|
||||
|
||||
نکته: افق زمانی فقط در `src/utils/horizon.ts` تعریف شده و همه برچسبها و
|
||||
حلقههای رادار از آن مشتق میشوند. دستهبندیها هم در
|
||||
`src/utils/categories.ts` متمرکز است.
|
||||
|
||||
راهنمای طراحی دیداری در [`design_system.md`](./design_system.md).
|
||||
|
||||
## پشته فنی
|
||||
|
||||
React 19 · TypeScript · Vite · Tailwind CSS v4 · shadcn/ui (Radix) · lucide-react
|
||||
|
|
|
|||
160
design_system.md
160
design_system.md
|
|
@ -1,92 +1,112 @@
|
|||
# نظام طراحی سامانه هوشمندی راهبردی دانشگاه اصفهان (Design System Specification)
|
||||
# نظام طراحی — رادار آینده دانشگاه اصفهان
|
||||
|
||||
این سند مرجع رسمی و استانداردهای طراحی دیداری، سلسلهمراتب تایپوگرافی، پالت رنگ، الگوهای کامپوننت و راهنمای نگارش سامانه **«رادار آینده دانشگاه اصفهان»** است.
|
||||
مرجع طراحی دیداری سامانه. پیادهسازی در `src/index.css` و کامپوننتهای
|
||||
`src/components/ui/` (shadcn/ui روی Tailwind v4) است.
|
||||
|
||||
---
|
||||
|
||||
## ۱. تایپوگرافی (Typography Standard)
|
||||
## ۱. پایه
|
||||
|
||||
### ۱.۱. قلم واحد سازمانی (Universal Font Family)
|
||||
- تمام متون، اعداد، نشانها، دکمهها و عناصر واسط کاربری باید منحصراً از قلم **Vazirmatn** استفاده کنند.
|
||||
- استفاده از فونتهای مونو یا سیستمی (`font-mono`, `Consolas`, `Courier`) برای اعداد و برچسبهای فارسی **اکیداً ممنوع** است. اعداد فارسی باید با وزن متناسب قلم وزیرمتن نمایش داده شوند.
|
||||
|
||||
```css
|
||||
*, ::before, ::after {
|
||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif !important;
|
||||
}
|
||||
```
|
||||
|
||||
### ۱.۲. مقیاس و سلسلهمراتب تیترها (Heading Hierarchy & Contrast)
|
||||
- **قاعده اساسی**: تیترها و عناوین باید به رنگ **مشکی کامل** (`#0f172a` یا `#000000`)، با وزن **بسیار ضخیم (Bold 800 - Black 900)** و با اندازه چشمگیر نسبت به متن بدنه باشند تا سلسلهمراتب بصری در یک نگاه درک شود.
|
||||
|
||||
| سطح عنصر | نام کلاس | اندازه فونت (rem / px) | وزن قلم | رنگ متن | کاربرد |
|
||||
|---|---|---|---|---|---|
|
||||
| **Display / Hero** | `.text-display` | `2.25rem` (36px) | 900 (Black) | `#0f172a` | ارقام کلیدی داشبورد و شاخصهای آمادگی ۱۴۱۵ |
|
||||
| **Heading 1** | `.heading-1` / `h1` | `1.75rem` (28px) | 900 (Black) | `#0f172a` | عنوان اصلی هر صفحه |
|
||||
| **Heading 2** | `.heading-2` / `h2` | `1.25rem` (20px) | 800 (ExtraBold) | `#0f172a` | تیتر بخشهای اصلی و سرفصلها |
|
||||
| **Heading 3** | `.heading-3` / `h3` | `1.00rem` (16px) | 800 (ExtraBold) | `#0f172a` | عنوان کارتهای رادار و پنلهای تحلیلی |
|
||||
| **Heading 4** | `.heading-4` / `h4` | `0.875rem` (14px) | 700 (Bold) | `#1e293b` | عناوین فرعی و آیتمهای لیست |
|
||||
| **Body Main** | `.body-main` / `p` | `0.8125rem` (13px) | 500 (Medium) | `#334155` | متن توضیحات و خلاصههای سیاستی |
|
||||
| **Caption / Meta**| `.caption` | `0.75rem` (12px) | 600 (SemiBold) | `#64748b` | اطلاعات فراداده، افق زمانی و منابع |
|
||||
| **Badge / Chip** | `.badge-label` | `0.6875rem` (11px) | 700 (Bold) | متناسب با دسته | برچسبهای وضعیت و شمارندهها |
|
||||
| مورد | مقدار |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| فریمورک CSS | Tailwind CSS v4 (`@tailwindcss/vite`) |
|
||||
| کتابخانه اجزا | shadcn/ui روی Radix |
|
||||
| جهت | RTL (`dir="rtl"` روی `<html>`) |
|
||||
| قلم | Vazirmatn (وزنهای ۴۰۰، ۵۰۰، ۶۰۰، ۷۰۰) |
|
||||
| حالت نمایش | روشن و تیره، با کلاس `.dark` روی `<html>` |
|
||||
|
||||
---
|
||||
|
||||
## ۲. پالت رنگ و کنتراست (Color Palette)
|
||||
## ۲. رنگ
|
||||
|
||||
### ۲.۱. بوم و پسزمینهها (Canvas & Surfaces)
|
||||
- **بوم اصلی (Canvas Background)**: `#f4f6fb` (خاکستری سرد و ملایم با مش گرادینت سبک)
|
||||
- **کارتها (Card Surface)**: `#ffffff` (سفید یکدست با سایه نرم `0 8px 24px -4px rgba(15,23,42,0.04)`)
|
||||
- **کارتهای فرعی (Subtle Surface)**: `#f8fafc`
|
||||
- **خطوط حاشیه (Borders)**: `#e2e8f0` (خطوط تفکیککننده محکم اما چشمنواز)
|
||||
همه رنگها متغیر CSS در فضای رنگی `oklch` هستند و در `:root` (روشن) و
|
||||
`.dark` (تیره) تعریف میشوند. هیچ رنگ ثابتی نباید مستقیماً در کامپوننتها
|
||||
نوشته شود.
|
||||
|
||||
### ۲.۲. رنگهای موضوعی پاستلی (Pastel Theme Tints)
|
||||
برای طبقهبندی هوشمندی آینده و ایجاد زیبایی در ساختار جعبههای بنتو:
|
||||
- **روندها (Trends)**: پسزمینه `#eaf3fe` | حاشیه `#c8e0fd` | متن `#0284c7`
|
||||
- **فناوریها (Tech)**: پسزمینه `#e6f6f1` | حاشیه `#c4ecdf` | متن `#059669`
|
||||
- **سیاستها (Policies)**: پسزمینه `#fef7e6` | حاشیه `#fde8b8` | متن `#d97706`
|
||||
- **سیگنالهای آینده (Signals)**: پسزمینه `#fdecf2` | حاشیه `#f9cbd9` | متن `#e11d48`
|
||||
- **برنامهریزی ۱۴۱۵ / مثلث آینده**: پسزمینه `#f3effe` | حاشیه `#ded2fd` | متن `#7c3aed`
|
||||
### ۲.۱. نقشها
|
||||
|
||||
`--background` · `--foreground` · `--card` · `--popover` · `--muted` ·
|
||||
`--accent` · `--primary` · `--secondary` · `--destructive` · `--border` ·
|
||||
`--input` · `--ring` و مجموعه `--sidebar-*`.
|
||||
|
||||
### ۲.۲. رنگهای معنایی
|
||||
|
||||
رنگ فقط برای **معنا** به کار میرود، نه تزئین. چهار جریان رصد، هر کدام یک
|
||||
رنگ ثابت دارند که در `src/utils/categories.ts` تعریف شده و در همهجا —
|
||||
رادار، نشانها، جدولها و نمودارها — یکسان است:
|
||||
|
||||
| جریان | متغیر | سطح ملایم |
|
||||
| ----------------- | --------------- | -------------------- |
|
||||
| روندها | `--cat-trend` | `--cat-trend-soft` |
|
||||
| فناوریها | `--cat-tech` | `--cat-tech-soft` |
|
||||
| سیاستها | `--cat-policy` | `--cat-policy-soft` |
|
||||
| سیگنالهای آینده | `--cat-signal` | `--cat-signal-soft` |
|
||||
|
||||
جهت تغییر (رشد یا کاهش) با `--positive` و `--negative` نشان داده میشود.
|
||||
|
||||
### ۲.۳. کنتراست
|
||||
|
||||
هر جفت رنگ متن/زمینه باید در **هر دو حالت** روشن و تیره حداقل نسبت
|
||||
کنتراست ۴٫۵:۱ برای متن معمولی و ۳:۱ برای متن بزرگ یا ضخیم را داشته باشد.
|
||||
مقادیر روشنایی رنگهای معنایی دقیقاً برای رسیدن به همین آستانه تنظیم
|
||||
شدهاند؛ تغییر آنها بدون سنجش مجدد کنتراست مجاز نیست.
|
||||
|
||||
---
|
||||
|
||||
## ۳. نوار کناری ناوبری (Sidebar Standard)
|
||||
## ۳. تایپوگرافی
|
||||
|
||||
- **بدنه نوار کناری**: پسزمینه سفید خالص (`#ffffff`)، مرز چپ با صفحه `#e2e8f0`، عرض ۲۷۰ پیکسل.
|
||||
- **عناوین بخشها**: خاکستری ملایم `#94a3b8`، فونت ۱۰.۵ پیکسل، وزن ۸۰۰، حروف کوچک، با فاصله مناسب از گروه قبلی.
|
||||
- **دکمههای ناوبری**:
|
||||
- *حالت عادی*: متن `#475569`، آیکون ۲۰ پیکسل با رنگ `#64748b`، گوشههای گرد ۱۲ پیکسل (`rounded-xl`).
|
||||
- *حالت شناور (Hover)*: پسزمینه `#f1f5f9`، متن مشکی `#0f172a`.
|
||||
- *حالت فعال (Active)*: پسزمینه سرمهای تیره لوکس `#0f172a`، متن سفید خالص `#ffffff`، آیکون روشن، سایه ملایم.
|
||||
- **شمارندهها و برچسبها**:
|
||||
- نشانهای عددی باید در سمت چپ آیتم قرار گیرند (`align-self: flex-start` در ساختار راستبهچپ).
|
||||
- متن شمارنده کاملاً با قلم وزیرمتن فارسی و بدون فونت مونو یا فاصله بیمورد درج شود.
|
||||
- **حذف متون دوزبانه زاید**:
|
||||
- تمامی پرانتزهای انگلیسی غیرضروری مانند `(Overview)`، `(Trends)`، `(Tech)`، `(Policies)`، `(Signals)`، `(Spider Gap)`، `(PULL/PUSH)` و `(CRUD)` در نوار کناری **ممنوع** بوده و با واژگان حرفهای و فاخر فارسی جایگزین میشوند.
|
||||
یک مقیاس، چهار وزن. **هیچ متنی ضخیمتر از ۷۰۰ نباشد** — وزنهای ۸۰۰ و ۹۰۰
|
||||
حفرههای حروف فارسی را میبندند و بهجای ایجاد سلسلهمراتب، آن را از بین
|
||||
میبرند.
|
||||
|
||||
| کاربرد | اندازه | وزن |
|
||||
| ----------------- | ------------- | --- |
|
||||
| عنوان صفحه | `1.375rem` | ۷۰۰ |
|
||||
| عنوان کارت | `0.9375rem` | ۷۰۰ |
|
||||
| متن بدنه | `0.8125rem` | ۴۰۰ |
|
||||
| برچسب و فراداده | `0.75rem` | ۵۰۰ |
|
||||
| ریزنویس | `0.6875rem` | ۶۰۰ |
|
||||
|
||||
- ارتفاع خط بدنه `1.75` است؛ فارسی به فضای بیشتری بین سطرها نیاز دارد.
|
||||
- ارقام با کلاس `.tnum` نمایش داده میشوند تا در ستونها همتراز بمانند.
|
||||
- اعداد همیشه فارسیاند (`toPersianDigits`).
|
||||
|
||||
---
|
||||
|
||||
## ۴. استانداردهای هندسی نمودارها و مدلهای بصری
|
||||
## ۴. فاصله، گردی و ارتفاع
|
||||
|
||||
### ۴.۱. مثلث هوشمندی آینده (Futures Triangle Geometry)
|
||||
- **تناسب ابعادی استاندارد**:
|
||||
- از ترسیم مثلثهای عریض، پخ و تخت (نسبت عرض به ارتفاع ۲ به ۱) اکیداً پرهیز شود.
|
||||
- نسبت ارتفاع به قاعده مثلث باید بین **۰.۸۵ تا ۰.۹۰** باشد (شبیه به مثلث متساویالاضلاع کشیده و متوازن).
|
||||
- اضلاع مثلث باید با خطوط رنگی ضخیم (حداقل ۴ پیکسل) و گرادینتهای جهتدار متصل شوند.
|
||||
- گره مرکزی (هوشمندی دانشگاه اصفهان) دقیقاً در گرانیگاه هندسی $(x_c, y_c)$ قرار گیرد.
|
||||
|
||||
### ۴.۲. رادار دایرهای آیندهپژوهی (Futures Radar)
|
||||
- دایره مرکزی سفید مرواریدی، پرههای رنگی ملایم برای ۴ ربع، نقاط سیگنال با حاشیه سفید تمیز و عدد شدت اثر درون نقطه.
|
||||
|
||||
### ۴.۳. نمودار عنکبوتی ۱۰ بعدی (Spider Gap Chart)
|
||||
- چندضلعی هدف دانشگاه اصفهان ۱۴۱۵ به رنگ سبز زمردی (`#00b894`) و چندضلعی وضع موجود به رنگ کهربایی (`#f59e0b`).
|
||||
- گردی از `--radius` (`0.7rem`) مشتق میشود: `radius-sm` تا `radius-2xl`.
|
||||
- سایه فقط برای جدا کردن لایههای شناور (کارت شناور، منو، دیالوگ). کارتهای
|
||||
درون صفحه با خط مویین `border` از هم جدا میشوند، نه با سایه.
|
||||
- فاصلهها از مقیاس Tailwind؛ فاصله استاندارد میان کارتها `gap-3` یا `gap-4`.
|
||||
|
||||
---
|
||||
|
||||
## ۵. راهنمای زبان و لحن سازمانی (Content & Tone Standards)
|
||||
## ۵. حرکت
|
||||
|
||||
1. **حذف واژگان تکراری و عبارات توخالی (Zero AI-Slop)**:
|
||||
- حذف برچسبهای آکادمیک گلدرشت مانند `نظریه سوهیل عنایتالله (Sohail Inayatullah)` از هدر صفحات و استفاده از برچسبهای راهبردی خلاصه (`چارچوب آیندهپژوهی`).
|
||||
- حذف توضیحات شعاری و کشدار؛ محتوا باید مستقیماً پیامد دانشگاهی، عدد شاخص یا اقدام فوری را گزارش دهد.
|
||||
2. **نگارش فارسی معیار و روان**:
|
||||
- به کارگیری اصطلاحات دقیق آیندهپژوهی و مدیریت دانشگاهی (مانند: *سیگنالهای ضعیف، افق زمانی، شکاف بلوغ، شایستگیهای نوظهور، آمایش آموزشی*).
|
||||
- انتقالها کوتاه (۱۵۰ تا ۲۰۰ میلیثانیه) و فقط روی ویژگیهای مشخص.
|
||||
- **`transition-all` استفاده نشود**؛ چون رنگها متغیر CSS هستند، تغییر حالت
|
||||
روشن/تیره باعث انیمیشن ناخواسته رنگ میشود.
|
||||
- هنگام تعویض حالت نمایش، صفت `data-theme-switching` روی `<html>` قرار
|
||||
میگیرد و همه انتقالها را موقتاً غیرفعال میکند.
|
||||
- `prefers-reduced-motion` رعایت میشود.
|
||||
|
||||
---
|
||||
|
||||
## ۶. نگارش متن
|
||||
|
||||
- متن رابط کاربری **توصیفی** است، نه تبلیغاتی. برچسب کوتاه و دقیق.
|
||||
- عنوانها بدون پسوند انگلیسی داخل پرانتز.
|
||||
- هیچ عدد، تاریخ یا نتیجهگیریای در متن ثابت کدنویسی نمیشود؛ همه از داده
|
||||
محاسبه میشوند.
|
||||
- افق زمانی همیشه از `src/utils/horizon.ts` خوانده میشود
|
||||
(۱۴۰۵ تا ۱۴۱۵)، نه بهصورت دستی.
|
||||
|
||||
---
|
||||
|
||||
## ۷. دسترسپذیری
|
||||
|
||||
- تمرکز صفحهکلید با حلقه `--ring` دیده میشود.
|
||||
- هر کنترل بدون متن، `aria-label` دارد.
|
||||
- ناوبری روی موبایل به کشوی کناری (Sheet) تبدیل میشود و با صفحهکلید
|
||||
قابل استفاده است.
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='46' fill='%23090e1a' stroke='%2300d4aa' stroke-width='4'/><circle cx='50' cy='50' r='30' fill='none' stroke='%2338bdf8' stroke-width='2' stroke-dasharray='4,4'/><circle cx='50' cy='50' r='14' fill='none' stroke='%2300d4aa' stroke-width='2'/><circle cx='50' cy='50' r='5' fill='%2300d4aa'/><line x1='50' y1='50' x2='82' y2='30' stroke='%2300d4aa' stroke-width='2.5'/></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="رادار آینده دانشگاه اصفهان — سامانه هوشمندی راهبردی، دیدهبانی سیگنالها، روندها، فناوریها و تحلیل شکاف تا افق ۱۴۱۵" />
|
||||
<meta name="description" content="رصد روندها، فناوریها، سیاستها و سیگنالهای آینده آموزش عالی، و سنجش فاصله دانشگاه اصفهان تا افق ۱۴۰۵ تا ۱۴۱۵." />
|
||||
<title>رادار آینده دانشگاه اصفهان | University Futures Radar</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-navy">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -10,16 +10,24 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.34.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
|
|
|
|||
184
src/App.css
184
src/App.css
|
|
@ -1,184 +0,0 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
138
src/App.tsx
138
src/App.tsx
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
|
|
@ -11,15 +11,14 @@ import {
|
|||
import { dataService } from './services/dataService';
|
||||
import { authService } from './services/authService';
|
||||
|
||||
// Layout
|
||||
import { Header } from './components/layout/Header';
|
||||
import { Sidebar } from './components/layout/Sidebar';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { AppSidebar } from './components/layout/AppSidebar';
|
||||
import { AppHeader } from './components/layout/AppHeader';
|
||||
|
||||
// Modals
|
||||
import { GlobalSearchModal } from './components/modals/GlobalSearchModal';
|
||||
import { EntityEditorModal } from './components/modals/EntityEditorModal';
|
||||
|
||||
// Pages
|
||||
import { Login } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { RadarPage } from './pages/RadarPage';
|
||||
|
|
@ -30,27 +29,35 @@ import { GapAnalysisPage } from './pages/GapAnalysisPage';
|
|||
import { FuturesTrianglePage } from './pages/FuturesTrianglePage';
|
||||
import { StrategicNetworkPage } from './pages/StrategicNetworkPage';
|
||||
import { ReportsPage } from './pages/ReportsPage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
'/': 'داشبورد',
|
||||
'/radar': 'رادار آینده',
|
||||
'/labor-market': 'بازار کار آینده',
|
||||
'/university-1415': 'دانشگاه اصفهان ۱۴۱۵',
|
||||
'/gap-analysis': 'تحلیل شکاف',
|
||||
'/futures-triangle': 'مثلث آینده',
|
||||
'/network': 'شبکه روابط',
|
||||
'/reports': 'گزارشها'
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const [currentSession, setCurrentSession] = useState<UserSession | null>(authService.getSession());
|
||||
const [currentSession, setCurrentSession] = useState<UserSession | null>(
|
||||
authService.getSession()
|
||||
);
|
||||
const [currentRoute, setCurrentRoute] = useState<string>('/');
|
||||
const [selectedItem, setSelectedItem] = useState<IntelligenceItem | null>(null);
|
||||
const [radarCategoryFilter, setRadarCategoryFilter] = useState<Category | 'all'>('all');
|
||||
|
||||
// Modals
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
// Live Data State
|
||||
const [items, setItems] = useState<IntelligenceItem[]>([]);
|
||||
const [dimensions, setDimensions] = useState<University1415Dimension[]>([]);
|
||||
const [jobs, setJobs] = useState<JobEntity[]>([]);
|
||||
const [skills, setSkills] = useState<SkillEntity[]>([]);
|
||||
const [forces, setForces] = useState<FuturesForce[]>([]);
|
||||
|
||||
// Refresh all state from dataService
|
||||
const refreshData = () => {
|
||||
setItems(dataService.getAllItems());
|
||||
setDimensions(dataService.getDimensions());
|
||||
|
|
@ -63,19 +70,16 @@ export function App() {
|
|||
refreshData();
|
||||
const unsub = authService.subscribe((sess) => {
|
||||
setCurrentSession(sess);
|
||||
if (!sess) {
|
||||
setCurrentRoute('/login');
|
||||
}
|
||||
if (!sess) setCurrentRoute('/login');
|
||||
});
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
// Global Ctrl+K listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setIsSearchOpen(true);
|
||||
setIsSearchOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
|
@ -85,10 +89,8 @@ export function App() {
|
|||
const handleNavigate = (route: string) => {
|
||||
setSelectedItem(null);
|
||||
|
||||
// Handle query params like /radar?category=trend
|
||||
if (route.startsWith('/radar?category=')) {
|
||||
const cat = route.split('=')[1] as Category;
|
||||
setRadarCategoryFilter(cat);
|
||||
setRadarCategoryFilter(route.split('=')[1] as Category);
|
||||
setCurrentRoute('/radar');
|
||||
} else {
|
||||
setRadarCategoryFilter('all');
|
||||
|
|
@ -102,55 +104,50 @@ export function App() {
|
|||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleSelectDimension = (dim: University1415Dimension) => {
|
||||
const handleSelectDimension = () => {
|
||||
setSelectedItem(null);
|
||||
setCurrentRoute('/university-1415');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleSaveNewItem = (newItem: IntelligenceItem) => {
|
||||
dataService.createItem(newItem);
|
||||
refreshData();
|
||||
};
|
||||
|
||||
// If on login route or not authenticated
|
||||
if (!currentSession || currentRoute === '/login') {
|
||||
return (
|
||||
<Login
|
||||
onLoginSuccess={() => {
|
||||
setCurrentRoute('/');
|
||||
}}
|
||||
/>
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
weak_signal: items.filter((i) => i.category === 'weak_signal').length,
|
||||
trend: items.filter((i) => i.category === 'trend').length,
|
||||
technology: items.filter((i) => i.category === 'technology').length,
|
||||
policy: items.filter((i) => i.category === 'policy').length
|
||||
}),
|
||||
[items]
|
||||
);
|
||||
|
||||
if (!currentSession || currentRoute === '/login') {
|
||||
return <Login onLoginSuccess={() => setCurrentRoute('/')} />;
|
||||
}
|
||||
|
||||
// Calculate section counts
|
||||
const counts = {
|
||||
signals: items.filter((i) => i.category === 'weak_signal').length,
|
||||
trends: items.filter((i) => i.category === 'trend').length,
|
||||
tech: items.filter((i) => i.category === 'technology').length,
|
||||
policies: items.filter((i) => i.category === 'policy').length
|
||||
};
|
||||
const headerTitle = selectedItem
|
||||
? selectedItem.title
|
||||
: ROUTE_TITLES[currentRoute] ?? 'رادار آینده';
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Institutional Sidebar */}
|
||||
<Sidebar
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
currentRoute={selectedItem ? '' : currentRoute}
|
||||
onNavigate={handleNavigate}
|
||||
counts={counts}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="main-content">
|
||||
<Header
|
||||
<SidebarInset className="min-w-0">
|
||||
<AppHeader
|
||||
currentSession={currentSession}
|
||||
title={headerTitle}
|
||||
items={items}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
onSelectItem={handleSelectItem}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
|
||||
<main className="page-wrapper">
|
||||
{/* Detail Page View (Highest Priority) */}
|
||||
<main className="page-shell">
|
||||
{selectedItem ? (
|
||||
<ItemDetailPage
|
||||
item={selectedItem}
|
||||
|
|
@ -220,33 +217,13 @@ export function App() {
|
|||
)}
|
||||
|
||||
{currentRoute === '/reports' && (
|
||||
<ReportsPage
|
||||
items={items}
|
||||
dimensions={dimensions}
|
||||
jobs={jobs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentRoute === '/admin' && (
|
||||
<AdminPage
|
||||
items={items}
|
||||
dimensions={dimensions}
|
||||
jobs={jobs}
|
||||
skills={skills}
|
||||
onRefreshData={refreshData}
|
||||
onSelectItem={handleSelectItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentRoute === '/settings' && (
|
||||
<SettingsPage onRefreshData={refreshData} />
|
||||
<ReportsPage items={items} dimensions={dimensions} jobs={jobs} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
{/* Global Modals */}
|
||||
<GlobalSearchModal
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
|
|
@ -254,9 +231,16 @@ export function App() {
|
|||
dimensions={dimensions}
|
||||
jobs={jobs}
|
||||
skills={skills}
|
||||
onSelectItem={handleSelectItem}
|
||||
onSelectDimension={handleSelectDimension}
|
||||
onSelectItem={(item) => {
|
||||
setIsSearchOpen(false);
|
||||
handleSelectItem(item);
|
||||
}}
|
||||
onSelectDimension={() => {
|
||||
setIsSearchOpen(false);
|
||||
handleSelectDimension();
|
||||
}}
|
||||
onSelectJob={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSelectedItem(null);
|
||||
setCurrentRoute('/labor-market');
|
||||
}}
|
||||
|
|
@ -265,9 +249,13 @@ export function App() {
|
|||
<EntityEditorModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={handleSaveNewItem}
|
||||
onSave={(newItem) => {
|
||||
dataService.createItem(newItem);
|
||||
refreshData();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
|
|
@ -1,84 +1,80 @@
|
|||
import React from 'react';
|
||||
import { University1415Dimension } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { Target, ArrowLeft, Lightbulb, ArrowUpRight } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
interface Dimension1415CardProps {
|
||||
dimension: University1415Dimension;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const Dimension1415Card: React.FC<Dimension1415CardProps> = ({ dimension, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
export const Dimension1415Card: React.FC<Dimension1415CardProps> = ({
|
||||
dimension,
|
||||
onClick
|
||||
}) => (
|
||||
<Card
|
||||
data-card
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
className="intel-card interactive flex flex-col justify-between gap-3 text-right bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md"
|
||||
style={{ borderTop: `4px solid ${dimension.pillarColor}` }}
|
||||
>
|
||||
<div>
|
||||
{/* Header Badges */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10.5px] text-slate-400 font-bold">
|
||||
{dimension.titleEn}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-bold px-2.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${dimension.pillarColor}15`,
|
||||
color: dimension.pillarColor
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className="group cursor-pointer gap-0 overflow-hidden p-0 transition-[box-shadow,border-color,transform] duration-200 hover:border-foreground/15 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
آمادگی: {toPersianDigits(dimension.readinessPercentage)}٪
|
||||
{/* Colour is the dimension's own identity, kept to a hairline. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="block h-0.5 w-full"
|
||||
style={{ background: dimension.pillarColor }}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-1.5 flex items-start justify-between gap-2">
|
||||
<h3 className="text-[13.5px] font-bold leading-snug transition-colors group-hover:text-primary">
|
||||
{dimension.title}
|
||||
</h3>
|
||||
<span className="tnum shrink-0 text-[11px] font-semibold text-muted-foreground">
|
||||
{toPersianDigits(dimension.readinessPercentage)}٪
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-slate-900 font-extrabold text-base mb-1.5 flex items-center justify-between">
|
||||
<span>{dimension.title}</span>
|
||||
<div className="circle-action-btn text-slate-700 w-7 h-7 text-xs">
|
||||
<ArrowUpRight size={14} />
|
||||
</div>
|
||||
</h4>
|
||||
<p className="text-[10.5px] text-muted-foreground">{dimension.titleEn}</p>
|
||||
|
||||
{/* Definition */}
|
||||
<p className="text-slate-600 text-xs leading-relaxed line-clamp-2">
|
||||
<p className="mt-2 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{dimension.definition}
|
||||
</p>
|
||||
|
||||
{/* Strategic Recommendation Preview */}
|
||||
{dimension.strategicRecommendations[0] && (
|
||||
<div className="mt-3 p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-[11px] text-slate-700 flex items-start gap-2">
|
||||
<Lightbulb size={14} className="text-amber-500 shrink-0 mt-0.5" />
|
||||
<span className="line-clamp-2">{dimension.strategicRecommendations[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scores & Gap Meter */}
|
||||
<div className="pt-3 border-t border-slate-100 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<span className="text-amber-600">
|
||||
موجود: {toPersianDigits(dimension.currentScore)}
|
||||
<div className="mt-3 border-t pt-2.5">
|
||||
<div className="tnum mb-1.5 flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground">
|
||||
موجود <b className="font-semibold text-foreground">
|
||||
{toPersianDigits(dimension.currentScore)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="text-teal-600">
|
||||
هدف: {toPersianDigits(dimension.targetScore)}
|
||||
<span className="text-muted-foreground">
|
||||
هدف <b className="font-semibold text-foreground">
|
||||
{toPersianDigits(dimension.targetScore)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="text-rose-600 bg-rose-50 px-2 py-0.5 rounded-full text-[11px]">
|
||||
شکاف: {toPersianDigits(dimension.gap)}
|
||||
<span className="font-semibold text-negative">
|
||||
شکاف {toPersianDigits(dimension.gap)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Readiness Bar */}
|
||||
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden flex">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${dimension.readinessPercentage}%`,
|
||||
backgroundColor: dimension.pillarColor
|
||||
background: dimension.pillarColor
|
||||
}}
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import React from 'react';
|
||||
import { IntelligenceItem } from '../../types/futures';
|
||||
import {
|
||||
toPersianDigits,
|
||||
getCategoryLabelFa,
|
||||
getCategoryColor,
|
||||
getHorizonShortFa
|
||||
} from '../../utils/persianNumbers';
|
||||
import { Clock, Zap, ArrowUpRight } from 'lucide-react';
|
||||
import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
|
||||
import { CATEGORY_META } from '../../utils/categories';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface IntelligenceCardProps {
|
||||
item: IntelligenceItem;
|
||||
|
|
@ -14,59 +11,63 @@ interface IntelligenceCardProps {
|
|||
}
|
||||
|
||||
export const IntelligenceCard: React.FC<IntelligenceCardProps> = ({ item, onClick }) => {
|
||||
const catColor = getCategoryColor(item.category);
|
||||
const meta = CATEGORY_META[item.category];
|
||||
const isCritical = item.status === 'critical' || item.impactScore === 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
data-card
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
className={`intel-card interactive flex flex-col justify-between gap-3 text-right bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md ${
|
||||
isCritical ? 'border-r-4 border-r-rose-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{/* Top Badges */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span
|
||||
className="text-[10px] font-bold px-2.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${catColor}15`,
|
||||
color: catColor
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group cursor-pointer gap-0 p-4 transition-[box-shadow,border-color,transform] duration-200',
|
||||
'hover:border-foreground/15 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
>
|
||||
{getCategoryLabelFa(item.category)}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-semibold"
|
||||
style={{ color: meta.color }}
|
||||
>
|
||||
<span className="cat-dot" style={{ background: meta.color }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-slate-400 font-medium">
|
||||
<Clock size={12} />
|
||||
<span>{getHorizonShortFa(item.horizon)}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{getHorizonShortFa(item.horizon)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-slate-900 font-extrabold text-sm leading-snug line-clamp-2 hover:text-teal-600 transition-colors">
|
||||
<h3 className="line-clamp-2 text-[13.5px] font-bold leading-relaxed text-foreground transition-colors group-hover:text-primary">
|
||||
{item.title}
|
||||
</h4>
|
||||
</h3>
|
||||
|
||||
{/* Executive Summary */}
|
||||
<p className="text-slate-600 text-xs leading-relaxed line-clamp-3 mt-2">
|
||||
<p className="mt-1.5 line-clamp-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{item.executiveSummary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer Indicators */}
|
||||
<div className="pt-3 border-t border-slate-100 flex items-center justify-between text-xs font-bold">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-teal-700 flex items-center gap-1">
|
||||
<Zap size={13} className="text-teal-500" />
|
||||
اثر: {toPersianDigits(item.impactScore)}/۵
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-2.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
اثر{' '}
|
||||
<span className="tnum font-semibold text-foreground">
|
||||
{toPersianDigits(item.impactScore)}
|
||||
</span>
|
||||
<span className="tnum"> / ۵</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="circle-action-btn w-7 h-7 text-slate-600">
|
||||
<ArrowUpRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
{isCritical && (
|
||||
<span className="rounded px-1.5 py-0.5 text-[10.5px] font-semibold text-negative bg-negative-soft">
|
||||
بحرانی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,110 +1,118 @@
|
|||
import React from 'react';
|
||||
import { JobEntity, SkillEntity } from '../../types/futures';
|
||||
import { toPersianDigits, getHorizonLabelFa } from '../../utils/persianNumbers';
|
||||
import { Briefcase, Brain, TrendingUp, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { TrendingUp, TrendingDown, AlertTriangle, ArrowUpRight } from 'lucide-react';
|
||||
|
||||
interface JobCardProps {
|
||||
job: JobEntity;
|
||||
}
|
||||
/** Small tinted pill used for the growing / declining signal on both cards. */
|
||||
const TrendPill: React.FC<{
|
||||
positive: boolean;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
}> = ({ positive, icon: Icon, children }) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-semibold"
|
||||
style={{
|
||||
background: positive ? 'var(--positive-soft)' : 'var(--negative-soft)',
|
||||
color: positive ? 'var(--positive)' : 'var(--negative)'
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const JobCard: React.FC<JobCardProps> = ({ job }) => {
|
||||
export const JobCard: React.FC<{ job: JobEntity }> = ({ job }) => {
|
||||
const isGrowing = job.type === 'growing';
|
||||
|
||||
return (
|
||||
<div className="intel-card flex flex-col justify-between gap-3 text-right bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className={`badge ${isGrowing ? 'badge-teal' : 'badge-rose'}`}>
|
||||
{isGrowing ? (
|
||||
<>
|
||||
<TrendingUp size={12} />
|
||||
شغل رو به رشد (+{toPersianDigits(job.growthRatePercent)}٪)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle size={12} />
|
||||
شغل در معرض تحول ({toPersianDigits(job.automationRiskPercent)}٪ ریسک)
|
||||
</>
|
||||
)}
|
||||
<Card data-card className="gap-0 p-4">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<TrendPill positive={isGrowing} icon={isGrowing ? TrendingUp : AlertTriangle}>
|
||||
{isGrowing
|
||||
? `رشد ${toPersianDigits(job.growthRatePercent)}٪`
|
||||
: `ریسک اتوماسیون ${toPersianDigits(job.automationRiskPercent)}٪`}
|
||||
</TrendPill>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{getHorizonShortFa(job.horizon)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-medium">{getHorizonLabelFa(job.horizon)}</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-slate-900 font-extrabold text-sm mb-1">{job.title}</h4>
|
||||
<div className="text-teal-700 text-xs font-bold mb-2">
|
||||
دانشکده / گروه مرتبط: {job.discipline}
|
||||
</div>
|
||||
<h3 className="text-[13.5px] font-bold leading-relaxed">{job.title}</h3>
|
||||
|
||||
<p className="text-slate-600 text-xs leading-relaxed mb-3">
|
||||
<p className="mt-0.5 text-[11.5px] text-primary">{job.discipline}</p>
|
||||
|
||||
<p className="mt-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{job.description}
|
||||
</p>
|
||||
|
||||
{/* Required Skills Badges */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{job.keyRequiredSkills.map((sk, idx) => (
|
||||
{job.keyRequiredSkills.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{job.keyRequiredSkills.map((skill, i) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-slate-100 text-slate-700 border border-slate-200 text-[10.5px] px-2 py-0.5 rounded-full font-semibold"
|
||||
key={i}
|
||||
className="rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
|
||||
>
|
||||
{sk}
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* University Action Box */}
|
||||
<div className="p-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs flex items-start gap-2">
|
||||
<ShieldCheck size={15} className="text-teal-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-slate-500 text-[11px] block font-bold">اقدام دانشگاه اصفهان:</span>
|
||||
<span className="text-slate-800 text-[11.5px] font-medium leading-relaxed">{job.universityAction}</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border bg-muted/40 p-2.5">
|
||||
<div className="mb-0.5 text-[10.5px] font-semibold text-muted-foreground">
|
||||
اقدام دانشگاه
|
||||
</div>
|
||||
<p className="text-[11.5px] leading-relaxed">{job.universityAction}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillEntity;
|
||||
}
|
||||
|
||||
export const SkillCard: React.FC<SkillCardProps> = ({ skill }) => {
|
||||
export const SkillCard: React.FC<{ skill: SkillEntity }> = ({ skill }) => {
|
||||
const isGrowing = skill.type === 'growing';
|
||||
|
||||
return (
|
||||
<div className="intel-card flex flex-col justify-between gap-3 text-right bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className={`badge ${isGrowing ? 'badge-purple' : 'badge-amber'}`}>
|
||||
{isGrowing ? 'شایستگی صعودی' : 'مهارت در حال کاهش'}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs font-bold">
|
||||
اهمیت: {toPersianDigits(skill.importanceScore)}/۵
|
||||
<Card data-card className="gap-0 p-4">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<TrendPill positive={isGrowing} icon={isGrowing ? ArrowUpRight : TrendingDown}>
|
||||
{isGrowing ? 'رو به رشد' : 'در حال کاهش'}
|
||||
</TrendPill>
|
||||
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||
اهمیت {toPersianDigits(skill.importanceScore)}/۵
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-slate-900 font-extrabold text-sm mb-2">{skill.title}</h4>
|
||||
<p className="text-slate-600 text-xs leading-relaxed mb-3">
|
||||
<h3 className="text-[13.5px] font-bold leading-relaxed">{skill.title}</h3>
|
||||
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Affected Fields */}
|
||||
<div className="text-xs text-slate-500 mb-2">
|
||||
<span className="font-bold text-slate-700 block mb-1">رشتههای تحت تأثیر:</span>
|
||||
{skill.affectedAcademicFields.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 text-[10.5px] font-semibold text-muted-foreground">
|
||||
رشتههای تحت تأثیر
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.affectedAcademicFields.map((f, idx) => (
|
||||
<span key={idx} className="bg-slate-100 text-slate-700 text-[10.5px] px-2 py-0.5 rounded-full border border-slate-200">
|
||||
{f}
|
||||
{skill.affectedAcademicFields.map((field, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-2.5 bg-slate-50 border border-slate-200 rounded-2xl text-[11px] text-slate-700">
|
||||
<span className="text-teal-700 font-bold block mb-0.5">وضعیت جایگزینی / ارتقا:</span>
|
||||
<span className="leading-relaxed">{skill.replacementOrAugmentation}</span>
|
||||
<div className="mt-3 rounded-lg border bg-muted/40 p-2.5">
|
||||
<div className="mb-0.5 text-[10.5px] font-semibold text-muted-foreground">
|
||||
جایگزینی یا ارتقا
|
||||
</div>
|
||||
<p className="text-[11.5px] leading-relaxed">{skill.replacementOrAugmentation}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
/** One plain sentence. Not a slogan. */
|
||||
description?: string;
|
||||
/** Buttons, filters — anything that acts on the whole page. */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}) => (
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{description && <p className="page-subtitle max-w-2xl">{description}</p>}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import { ArrowUpLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toPersianDigits } from '@/utils/persianNumbers';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
/** Short qualifier under the figure — a unit, a share, a comparison. */
|
||||
meta?: string;
|
||||
/** Accent colour token; drives the rail and the icon tint only. */
|
||||
accent?: string;
|
||||
icon?: React.ElementType;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single figure. Deliberately monochrome apart from a thin accent rail —
|
||||
* a wall of differently-tinted cards reads as decoration, not as data.
|
||||
*/
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
meta,
|
||||
accent,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
className
|
||||
}) => {
|
||||
const interactive = Boolean(onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-card
|
||||
onClick={onClick}
|
||||
disabled={!interactive}
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-xl border bg-card p-3.5 text-right transition-[box-shadow,border-color,transform] duration-200',
|
||||
interactive
|
||||
? 'cursor-pointer hover:border-foreground/15 hover:shadow-sm'
|
||||
: 'cursor-default',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Accent rail on the inline-start edge. */}
|
||||
{accent && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 start-0 w-[3px]"
|
||||
style={{ background: accent }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="size-4 shrink-0 text-muted-foreground/70"
|
||||
strokeWidth={1.9}
|
||||
style={accent ? { color: accent } : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-baseline gap-1.5">
|
||||
<span className="kpi-number text-2xl font-bold text-foreground">
|
||||
{typeof value === 'number' ? toPersianDigits(value) : value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{meta && (
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[11px] text-muted-foreground">{meta}</span>
|
||||
{interactive && (
|
||||
<ArrowUpLeft className="size-3.5 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import React from 'react';
|
||||
import { Search, Bell, LogOut, Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { authService } from '@/services/authService';
|
||||
import type { IntelligenceItem, UserSession } from '@/types/futures';
|
||||
import { CATEGORY_META } from '@/utils/categories';
|
||||
import { toPersianDigits } from '@/utils/persianNumbers';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
interface AppHeaderProps {
|
||||
currentSession: UserSession | null;
|
||||
title: string;
|
||||
/** Everything currently on the radar — alerts are derived from it. */
|
||||
items: IntelligenceItem[];
|
||||
onOpenSearch: () => void;
|
||||
onSelectItem: (item: IntelligenceItem) => void;
|
||||
onNavigate: (route: string) => void;
|
||||
}
|
||||
|
||||
export const AppHeader: React.FC<AppHeaderProps> = ({
|
||||
currentSession,
|
||||
title,
|
||||
items,
|
||||
onOpenSearch,
|
||||
onSelectItem,
|
||||
onNavigate
|
||||
}) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
onNavigate('/login');
|
||||
};
|
||||
|
||||
const initial = currentSession?.fullName?.trim().slice(0, 1) || 'د';
|
||||
|
||||
// Alerts are the critical items in the nearest horizon, not a fixed list.
|
||||
const alerts = items
|
||||
.filter((i) => i.status === 'critical' && i.horizon === 'now')
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<header className="no-print sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 border-b bg-background/85 px-3 backdrop-blur-md sm:px-4">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="ms-1 me-1 !h-4" />
|
||||
|
||||
<h1 className="truncate text-sm font-semibold">{title}</h1>
|
||||
|
||||
{/* Search collapses to an icon button once space runs out. */}
|
||||
<div className="ms-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onOpenSearch}
|
||||
className="hidden h-9 w-56 items-center gap-2 rounded-lg border bg-muted/50 px-3 text-[12.5px] text-muted-foreground transition-colors hover:bg-muted lg:flex xl:w-72"
|
||||
>
|
||||
<Search className="size-3.5 shrink-0" />
|
||||
<span className="truncate">جستجو…</span>
|
||||
<kbd className="tnum ms-auto rounded border bg-background px-1.5 py-0.5 text-[10px] font-medium">
|
||||
Ctrl K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={onOpenSearch}
|
||||
aria-label="جستجو"
|
||||
>
|
||||
<Search />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="اعلانها">
|
||||
<Bell />
|
||||
{alerts.length > 0 && (
|
||||
<span className="absolute end-2 top-2 size-1.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
موارد بحرانی افق اکنون
|
||||
<Badge variant="secondary" className="tnum">
|
||||
{toPersianDigits(alerts.length)}
|
||||
</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-[12px] text-muted-foreground">
|
||||
موردی ثبت نشده است.
|
||||
</div>
|
||||
) : (
|
||||
alerts.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="flex-col items-start gap-0.5"
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-1.5">
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{ background: CATEGORY_META[item.category].color }}
|
||||
/>
|
||||
<span className="line-clamp-1 flex-1 text-[12.5px] font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{item.whyItMatters}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === 'dark' ? 'حالت روشن' : 'حالت تیره'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun /> : <Moon />}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="ms-0.5 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary text-[12px] font-semibold text-primary-foreground">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<span className="block text-[12.5px] font-semibold">
|
||||
{currentSession?.fullName || 'کاربر'}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{currentSession?.roleTitleFa}
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onNavigate('/settings')}>
|
||||
تنظیمات
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout} variant="destructive">
|
||||
<LogOut />
|
||||
خروج
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Radar,
|
||||
Briefcase,
|
||||
Target,
|
||||
GitPullRequest,
|
||||
Triangle,
|
||||
GitFork,
|
||||
FileText,
|
||||
Compass
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar
|
||||
} from '@/components/ui/sidebar';
|
||||
import { toPersianDigits } from '@/utils/persianNumbers';
|
||||
import {
|
||||
HORIZON_START,
|
||||
HORIZON_END,
|
||||
HORIZON_YEAR_INDEX,
|
||||
HORIZON_SPAN
|
||||
} from '@/utils/horizon';
|
||||
import { CATEGORY_META, CATEGORY_ORDER } from '@/utils/categories';
|
||||
import type { Category } from '@/types/futures';
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentRoute: string;
|
||||
onNavigate: (route: string) => void;
|
||||
counts: Record<Category, number>;
|
||||
}
|
||||
|
||||
type NavEntry = {
|
||||
route: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
export const AppSidebar: React.FC<AppSidebarProps> = ({
|
||||
currentRoute,
|
||||
onNavigate,
|
||||
counts
|
||||
}) => {
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
// On mobile the sidebar is a sheet, so navigating has to close it.
|
||||
const go = (route: string) => {
|
||||
onNavigate(route);
|
||||
if (isMobile) setOpenMobile(false);
|
||||
};
|
||||
|
||||
const radarTotal = CATEGORY_ORDER.reduce((sum, c) => sum + counts[c], 0);
|
||||
|
||||
const groups: { label: string; items: NavEntry[] }[] = [
|
||||
{
|
||||
label: 'رصد',
|
||||
items: [
|
||||
{ route: '/', label: 'داشبورد', icon: LayoutDashboard },
|
||||
{ route: '/radar', label: 'رادار آینده', icon: Radar },
|
||||
{ route: '/labor-market', label: 'بازار کار آینده', icon: Briefcase }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: `افق ${toPersianDigits(HORIZON_END)}`,
|
||||
items: [
|
||||
{ route: '/university-1415', label: 'دانشگاه اصفهان ۱۴۱۵', icon: Target },
|
||||
{ route: '/gap-analysis', label: 'تحلیل شکاف', icon: GitPullRequest },
|
||||
{ route: '/futures-triangle', label: 'مثلث آینده', icon: Triangle },
|
||||
{ route: '/network', label: 'شبکه روابط', icon: GitFork },
|
||||
{ route: '/reports', label: 'گزارشها', icon: FileText }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const horizonProgress = (HORIZON_YEAR_INDEX / HORIZON_SPAN) * 100;
|
||||
|
||||
return (
|
||||
<Sidebar side="right" collapsible="icon" className="no-print">
|
||||
<SidebarHeader className="gap-3">
|
||||
<button
|
||||
onClick={() => go('/')}
|
||||
className="flex items-center gap-2.5 rounded-lg px-1 py-1 text-right transition-colors hover:bg-sidebar-accent"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Compass className="size-4.5" strokeWidth={2} />
|
||||
</span>
|
||||
<span className="grid min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-[13px] font-bold text-sidebar-foreground">
|
||||
رادار آینده
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
دانشگاه اصفهان
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* The ten-year window everything on screen is scoped to. */}
|
||||
<div className="rounded-lg border bg-muted/40 px-2.5 py-2 group-data-[collapsible=icon]:hidden">
|
||||
<div className="mb-1.5 flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground">افق راهبردی</span>
|
||||
<span className="tnum font-semibold text-primary">
|
||||
{toPersianDigits(HORIZON_START)}–{toPersianDigits(HORIZON_END)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1 w-full overflow-hidden rounded-full bg-border"
|
||||
role="progressbar"
|
||||
aria-valuenow={HORIZON_YEAR_INDEX}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={HORIZON_SPAN}
|
||||
aria-label="پیشروی در افق راهبردی"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
style={{ width: `${horizonProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10.5px] text-muted-foreground">
|
||||
سال {toPersianDigits(HORIZON_YEAR_INDEX)} از {toPersianDigits(HORIZON_SPAN)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{groups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const active = currentRoute === item.route;
|
||||
const Icon = item.icon;
|
||||
const isRadar = item.route === '/radar';
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.route}>
|
||||
<SidebarMenuButton
|
||||
isActive={active}
|
||||
tooltip={item.label}
|
||||
onClick={() => go(item.route)}
|
||||
>
|
||||
<Icon strokeWidth={1.9} />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
{isRadar && (
|
||||
<SidebarMenuBadge className="tnum">
|
||||
{toPersianDigits(radarTotal)}
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
|
||||
{/* Radar splits into the four streams from the brief. */}
|
||||
{isRadar && (
|
||||
<SidebarMenuSub>
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
return (
|
||||
<SidebarMenuSubItem key={cat}>
|
||||
<SidebarMenuSubButton
|
||||
onClick={() => go(`/radar?category=${cat}`)}
|
||||
>
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{ background: meta.color }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{meta.label}</span>
|
||||
<span className="tnum text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(counts[cat])}
|
||||
</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<div className="rounded-lg border bg-muted/40 px-2.5 py-2 text-[11px] text-muted-foreground group-data-[collapsible=icon]:hidden">
|
||||
سامانه هوشمندی راهبردی دانشگاه اصفهان
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { UserSession, PersonaType } from '../../types/futures';
|
||||
import { authService, PRESET_USERS } from '../../services/authService';
|
||||
import { Search, Bell, LogOut, Shield, Compass, ChevronDown, UserCheck, Sparkles } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
currentSession: UserSession | null;
|
||||
onOpenSearch: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ currentSession, onOpenSearch, onNavigate }) => {
|
||||
const [showPersonaMenu, setShowPersonaMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
|
||||
const handleSwitchPersona = (role: PersonaType) => {
|
||||
authService.switchPersona(role);
|
||||
setShowPersonaMenu(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
onNavigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="no-print sticky top-0 z-40 w-full bg-white/90 backdrop-blur-md border-b border-slate-200 px-4 lg:px-8 py-3 flex items-center justify-between">
|
||||
{/* Brand & Emblem */}
|
||||
<div className="flex items-center gap-3.5 cursor-pointer" onClick={() => onNavigate('/')}>
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-slate-900 to-slate-800 text-white flex items-center justify-center shadow-md shadow-slate-900/10">
|
||||
<Compass size={22} className="text-teal-400" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-900 font-extrabold text-sm lg:text-base tracking-tight">
|
||||
رادار آینده دانشگاه اصفهان
|
||||
</span>
|
||||
<span className="hidden sm:inline-block text-[10px] bg-teal-50 text-teal-700 font-bold px-2 py-0.5 rounded-full border border-teal-200">
|
||||
افق ۱۴۱۵
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500 text-[11px] hidden md:block">
|
||||
سامانه هوشمندی راهبردی و آیندهپژوهی آموزش عالی
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Global Search Trigger (Bento Pill Input) */}
|
||||
<div className="flex-1 max-w-md mx-6 hidden md:block">
|
||||
<button
|
||||
onClick={onOpenSearch}
|
||||
className="w-full bg-slate-100 hover:bg-slate-200/70 border border-slate-200 text-slate-500 hover:text-slate-800 px-4 py-2 rounded-full text-xs flex items-center justify-between transition-all shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Search size={15} className="text-slate-400" />
|
||||
<span>جستجو در روندها، فناوریها، سیگنالها و مشاغل ۱۴۱۵...</span>
|
||||
</div>
|
||||
<kbd className="bg-white text-slate-600 text-[10px] px-2 py-0.5 rounded-full font-mono border border-slate-200 shadow-sm">
|
||||
Ctrl + K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Controls: Persona Switcher, Notifications, Profile */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile Search Button */}
|
||||
<button
|
||||
onClick={onOpenSearch}
|
||||
className="md:hidden p-2 text-slate-600 hover:text-slate-900 bg-slate-100 border border-slate-200 rounded-xl"
|
||||
title="جستجو"
|
||||
>
|
||||
<Search size={17} />
|
||||
</button>
|
||||
|
||||
{/* Persona Perspective Switcher */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPersonaMenu(!showPersonaMenu)}
|
||||
className="flex items-center gap-2 bg-slate-100 hover:bg-slate-200/80 border border-slate-200 px-3.5 py-1.5 rounded-full text-xs font-bold text-slate-800 transition-colors shadow-sm"
|
||||
>
|
||||
<Shield size={14} className="text-teal-600" />
|
||||
<span className="hidden sm:inline text-slate-500 font-medium">دیدگاه:</span>
|
||||
<span className="text-teal-700">
|
||||
{currentSession ? currentSession.roleTitleFa.split(' ')[0] : 'دیدگاه'}
|
||||
</span>
|
||||
<ChevronDown size={13} className="text-slate-400" />
|
||||
</button>
|
||||
|
||||
{showPersonaMenu && (
|
||||
<div className="absolute left-0 mt-2 w-64 bg-white border border-slate-200 rounded-2xl shadow-xl p-2 z-50 animate-fadeIn">
|
||||
<div className="text-[11px] font-bold text-slate-400 px-3 py-1.5 border-b border-slate-100">
|
||||
تغییر دیدگاه تحلیلی سامانه:
|
||||
</div>
|
||||
{(Object.keys(PRESET_USERS) as PersonaType[]).map((pKey) => {
|
||||
const user = PRESET_USERS[pKey];
|
||||
const isActive = currentSession?.role === pKey;
|
||||
return (
|
||||
<button
|
||||
key={pKey}
|
||||
onClick={() => handleSwitchPersona(pKey)}
|
||||
className={`w-full text-right p-2.5 rounded-xl text-xs flex items-center justify-between transition-colors mt-1 ${
|
||||
isActive
|
||||
? 'bg-teal-50 text-teal-800 font-bold border border-teal-200'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold">{user.roleTitleFa}</div>
|
||||
<div className="text-[10.5px] text-slate-400">{user.fullName}</div>
|
||||
</div>
|
||||
{isActive && <UserCheck size={14} className="text-teal-600" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications Trigger */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="p-2 text-slate-600 hover:text-slate-900 bg-slate-100 hover:bg-slate-200/80 border border-slate-200 rounded-full transition-colors relative"
|
||||
title="اعلانهای رصدخانه"
|
||||
>
|
||||
<Bell size={17} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-rose-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 mt-2 w-80 bg-white border border-slate-200 rounded-2xl shadow-xl p-4 z-50 animate-fadeIn">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-100">
|
||||
<span className="text-xs font-extrabold text-slate-900">اعلانهای فوری رادار</span>
|
||||
<span className="badge badge-rose">۳ سیگنال بحرانی</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 mt-2.5 text-xs">
|
||||
<div className="p-2.5 bg-rose-50 border border-rose-200 rounded-xl">
|
||||
<span className="font-bold text-rose-700 block">فراگیر شدن هوش مصنوعی زاینده</span>
|
||||
<p className="text-slate-600 text-[11px] mt-0.5">ضرورت بازطراحی فرآیندهای سنجش آموزشی در نیمسال جاری</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<span className="font-bold text-amber-700 block">بحران آب و فرونشست اصفهان</span>
|
||||
<p className="text-slate-600 text-[11px] mt-0.5">ابلاغ مأموریتهای استانی و بازنگری پژوهانهها</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill & Logout */}
|
||||
<div className="flex items-center gap-2 pl-1 border-r border-slate-200 pr-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-teal-500 to-sky-500 text-white font-black text-xs flex items-center justify-center shadow-sm">
|
||||
{currentSession?.fullName ? currentSession.fullName.slice(0, 1) : 'د'}
|
||||
</div>
|
||||
<div className="hidden xl:flex flex-col text-right">
|
||||
<span className="text-xs font-extrabold text-slate-900 leading-tight">
|
||||
{currentSession?.fullName || 'کاربر سازمانی'}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-slate-500">
|
||||
{currentSession?.roleTitleFa || 'ریاست دانشگاه'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-full transition-colors"
|
||||
title="خروج از سامانه"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import React from 'react';
|
||||
import { UserSession, PersonaType } from '../../types/futures';
|
||||
import { authService } from '../../services/authService';
|
||||
import { Shield, Sparkles, Target, Compass } from 'lucide-react';
|
||||
|
||||
interface PersonaBannerProps {
|
||||
currentSession: UserSession | null;
|
||||
}
|
||||
|
||||
export const PersonaBanner: React.FC<PersonaBannerProps> = ({ currentSession }) => {
|
||||
const currentRole = currentSession?.role || 'president';
|
||||
|
||||
const personaConfig: Record<
|
||||
PersonaType,
|
||||
{ title: string; question: string; icon: React.ReactNode; bg: string; border: string; accent: string; badge: string }
|
||||
> = {
|
||||
president: {
|
||||
title: 'دیدگاه ریاست و هیئت رئیسه دانشگاه',
|
||||
question: '«این تغییرات، تهدیدها و فرصتها برای کلیت دانشگاه اصفهان چه معنایی دارد و اولویت فوری چیست؟»',
|
||||
icon: <Shield size={16} />,
|
||||
bg: 'bg-emerald-50/70',
|
||||
border: 'border-emerald-200',
|
||||
accent: 'text-emerald-700',
|
||||
badge: 'bg-emerald-100 text-emerald-800'
|
||||
},
|
||||
researcher: {
|
||||
title: 'دیدگاه پژوهشگر آیندهپژوهی و تحلیلگر علم',
|
||||
question: '«چه سیگنالهای ضعیف، فناوریهای اخلالگر و روندهای جدیدی در حال ظهور در آموزش عالی هستند؟»',
|
||||
icon: <Compass size={16} />,
|
||||
bg: 'bg-sky-50/70',
|
||||
border: 'border-sky-200',
|
||||
accent: 'text-sky-700',
|
||||
badge: 'bg-sky-100 text-sky-800'
|
||||
},
|
||||
planner: {
|
||||
title: 'دیدگاه برنامهریز راهبردی و تحول آموزشی',
|
||||
question: '«شکاف میان وضعیت موجود و چشمانداز دانشگاه اصفهان ۱۴۱۵ کجاست و چه برنامههایی باید اولویت یابند؟»',
|
||||
icon: <Target size={16} />,
|
||||
bg: 'bg-amber-50/70',
|
||||
border: 'border-amber-200',
|
||||
accent: 'text-amber-700',
|
||||
badge: 'bg-amber-100 text-amber-800'
|
||||
},
|
||||
admin: {
|
||||
title: 'دیدگاه راهبری و مدیریت سیستم هوشمندی',
|
||||
question: '«مدیریت یکپارچه پایگاه داده، ارزیابی شاخصها، ثبت سیگنالهای تازه و صدور گزارشهای نهادی»',
|
||||
icon: <Sparkles size={16} />,
|
||||
bg: 'bg-purple-50/70',
|
||||
border: 'border-purple-200',
|
||||
accent: 'text-purple-700',
|
||||
badge: 'bg-purple-100 text-purple-800'
|
||||
}
|
||||
};
|
||||
|
||||
const currentConfig = personaConfig[currentRole];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full ${currentConfig.bg} border ${currentConfig.border} rounded-2xl px-5 py-3 mb-2 flex flex-col md:flex-row items-start md:items-center justify-between gap-3 text-xs shadow-sm`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`p-2 rounded-xl bg-white ${currentConfig.accent} border ${currentConfig.border} shadow-sm`}>
|
||||
{currentConfig.icon}
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-extrabold text-slate-900 ml-2">{currentConfig.title}:</span>
|
||||
<span className="text-slate-600 font-medium italic">{currentConfig.question}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Perspective Switching Pills */}
|
||||
<div className="flex items-center gap-1.5 self-end md:self-auto shrink-0 text-[11px]">
|
||||
<span className="text-slate-400 hidden lg:inline font-semibold">تغییر لنز:</span>
|
||||
<div className="flex items-center bg-white border border-slate-200 p-1 rounded-full shadow-sm gap-1">
|
||||
<button
|
||||
onClick={() => authService.switchPersona('president')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
currentRole === 'president'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
ریاست
|
||||
</button>
|
||||
<button
|
||||
onClick={() => authService.switchPersona('researcher')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
currentRole === 'researcher'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
پژوهشگر
|
||||
</button>
|
||||
<button
|
||||
onClick={() => authService.switchPersona('planner')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
currentRole === 'planner'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
برنامهریز
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Radar,
|
||||
Briefcase,
|
||||
Target,
|
||||
GitPullRequest,
|
||||
Triangle,
|
||||
GitFork,
|
||||
FileText,
|
||||
Database,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
|
||||
interface SidebarProps {
|
||||
currentRoute: string;
|
||||
onNavigate: (route: string) => void;
|
||||
counts: {
|
||||
signals: number;
|
||||
trends: number;
|
||||
tech: number;
|
||||
policies: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ currentRoute, onNavigate, counts }) => {
|
||||
const [radarExpanded, setRadarExpanded] = useState(true);
|
||||
|
||||
const isActive = (route: string) => currentRoute === route;
|
||||
|
||||
return (
|
||||
<aside className="no-print w-64 lg:w-72 bg-white border-l border-slate-200 flex flex-col justify-between p-4 select-none shrink-0 z-30 shadow-[4px_0_24px_-4px_rgba(15,23,42,0.03)]">
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto">
|
||||
{/* Navigation Group 1: Foresight Observation */}
|
||||
<div className="text-[11px] font-extrabold text-slate-400 px-3 pt-2 pb-1">
|
||||
دیدهبانی و رصد هوشمند
|
||||
</div>
|
||||
|
||||
{/* Dashboard */}
|
||||
<button
|
||||
onClick={() => onNavigate('/')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<LayoutDashboard size={18} className={isActive('/') ? 'text-teal-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">داشبورد اصلی</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Radar & Sub-sections */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onNavigate('/radar');
|
||||
setRadarExpanded(!radarExpanded);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/radar')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Radar size={18} className={isActive('/radar') ? 'text-teal-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">رادار آینده</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-bold leading-none ${
|
||||
isActive('/radar') ? 'bg-slate-800 text-teal-300' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{toPersianDigits(counts.signals + counts.trends + counts.tech + counts.policies)}
|
||||
</span>
|
||||
{radarExpanded ? <ChevronDown size={14} className="opacity-60" /> : <ChevronLeft size={14} className="opacity-60" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{radarExpanded && (
|
||||
<div className="mr-5 pr-2 border-r-2 border-slate-100 flex flex-col gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => onNavigate('/radar?category=trend')}
|
||||
className="flex items-center justify-between py-1.5 px-3 rounded-lg text-xs font-semibold text-slate-600 hover:text-slate-900 hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-sky-500"></span>
|
||||
<span>روندها</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-sky-600 font-bold">
|
||||
{toPersianDigits(counts.trends)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('/radar?category=technology')}
|
||||
className="flex items-center justify-between py-1.5 px-3 rounded-lg text-xs font-semibold text-slate-600 hover:text-slate-900 hover:bg-teal-50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-teal-500"></span>
|
||||
<span>فناوریها</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-teal-600 font-bold">
|
||||
{toPersianDigits(counts.tech)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('/radar?category=policy')}
|
||||
className="flex items-center justify-between py-1.5 px-3 rounded-lg text-xs font-semibold text-slate-600 hover:text-slate-900 hover:bg-amber-50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
|
||||
<span>سیاستها</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-amber-700 font-bold">
|
||||
{toPersianDigits(counts.policies)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('/radar?category=weak_signal')}
|
||||
className="flex items-center justify-between py-1.5 px-3 rounded-lg text-xs font-semibold text-slate-600 hover:text-slate-900 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
|
||||
<span>سیگنالهای ضعیف</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-rose-600 font-bold">
|
||||
{toPersianDigits(counts.signals)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Future Labor Market */}
|
||||
<button
|
||||
onClick={() => onNavigate('/labor-market')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/labor-market')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Briefcase size={18} className={isActive('/labor-market') ? 'text-amber-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">بازار کار آینده</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Navigation Group 2: Strategic Planning */}
|
||||
<div className="text-[11px] font-extrabold text-slate-400 px-3 pt-4 pb-1">
|
||||
برنامهریزی راهبردی ۱۴۱۵
|
||||
</div>
|
||||
|
||||
{/* University 1415 */}
|
||||
<button
|
||||
onClick={() => onNavigate('/university-1415')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/university-1415')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Target size={18} className={isActive('/university-1415') ? 'text-teal-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">دانشگاه اصفهان ۱۴۱۵</span>
|
||||
</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${
|
||||
isActive('/university-1415') ? 'bg-slate-800 text-teal-300' : 'bg-teal-50 text-teal-700'
|
||||
}`}>
|
||||
۱۰ بعد
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Gap Analysis */}
|
||||
<button
|
||||
onClick={() => onNavigate('/gap-analysis')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/gap-analysis')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GitPullRequest size={18} className={isActive('/gap-analysis') ? 'text-rose-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">تحلیل شکاف</span>
|
||||
</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${
|
||||
isActive('/gap-analysis') ? 'bg-slate-800 text-rose-300' : 'bg-rose-50 text-rose-700'
|
||||
}`}>
|
||||
وضع موجود
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Futures Triangle */}
|
||||
<button
|
||||
onClick={() => onNavigate('/futures-triangle')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/futures-triangle')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Triangle size={18} className={isActive('/futures-triangle') ? 'text-purple-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">مثلث آینده</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Strategic Impact Network */}
|
||||
<button
|
||||
onClick={() => onNavigate('/network')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/network')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GitFork size={18} className={isActive('/network') ? 'text-teal-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">شبکه روابط راهبردی</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reports & Briefs */}
|
||||
<button
|
||||
onClick={() => onNavigate('/reports')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/reports')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText size={18} className={isActive('/reports') ? 'text-sky-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">گزارشها و تحلیلها</span>
|
||||
</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${
|
||||
isActive('/reports') ? 'bg-slate-800 text-sky-300' : 'bg-sky-50 text-sky-700'
|
||||
}`}>
|
||||
نسخه چاپی
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Navigation Group 3: Configuration */}
|
||||
<div className="text-[11px] font-extrabold text-slate-400 px-3 pt-4 pb-1">
|
||||
پیکربندی سامانه
|
||||
</div>
|
||||
|
||||
{/* Admin CRUD */}
|
||||
<button
|
||||
onClick={() => onNavigate('/admin')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/admin')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Database size={18} className={isActive('/admin') ? 'text-teal-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">مدیریت دادهها</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => onNavigate('/settings')}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive('/settings')
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings size={18} className={isActive('/settings') ? 'text-slate-400' : 'text-slate-400'} />
|
||||
<span className="font-bold">تنظیمات</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Institutional Branding Footer Card */}
|
||||
<div className="pt-3 border-t border-slate-100">
|
||||
<div className="p-3 bg-slate-50 border border-slate-200/80 rounded-2xl flex flex-col gap-0.5 text-right">
|
||||
<div className="flex items-center justify-between text-xs font-black text-slate-900">
|
||||
<span>دانشگاه اصفهان</span>
|
||||
<span className="w-2 h-2 rounded-full bg-teal-500"></span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 font-semibold">سامانه هوشمندی راهبردی</span>
|
||||
<span className="text-[10px] text-slate-400 font-bold mt-0.5">افق دهساله ۱۴۱۵</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,28 @@
|
|||
import React, { useState } from 'react';
|
||||
import { IntelligenceItem, Category, Horizon, ItemStatus } from '../../types/futures';
|
||||
import { X, Save } from 'lucide-react';
|
||||
import { CATEGORY_META, CATEGORY_ORDER } from '../../utils/categories';
|
||||
import { getHorizonLabelFa } from '../../utils/persianNumbers';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
interface EntityEditorModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -9,6 +31,17 @@ interface EntityEditorModalProps {
|
|||
initialItem?: IntelligenceItem | null;
|
||||
}
|
||||
|
||||
const HORIZONS: Horizon[] = ['now', 'near', 'mid', 'long'];
|
||||
|
||||
const STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
active: 'فعال',
|
||||
emerging: 'نوظهور',
|
||||
monitoring: 'تحت پایش',
|
||||
critical: 'بحرانی'
|
||||
};
|
||||
|
||||
const SCOPES = ['ایران', 'منطقهای', 'جهانی'] as const;
|
||||
|
||||
export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
|
@ -21,22 +54,22 @@ export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
|||
const [category, setCategory] = useState<Category>(initialItem?.category || 'trend');
|
||||
const [horizon, setHorizon] = useState<Horizon>(initialItem?.horizon || 'near');
|
||||
const [status, setStatus] = useState<ItemStatus>(initialItem?.status || 'active');
|
||||
const [impactScore, setImpactScore] = useState<number>(initialItem?.impactScore || 4);
|
||||
const [uncertaintyScore, setUncertaintyScore] = useState<number>(initialItem?.uncertaintyScore || 2);
|
||||
const [geographicScope, setGeographicScope] = useState<'ایران' | 'منطقهای' | 'جهانی'>(
|
||||
const [impactScore, setImpactScore] = useState(initialItem?.impactScore || 4);
|
||||
const [uncertaintyScore, setUncertaintyScore] = useState(
|
||||
initialItem?.uncertaintyScore || 2
|
||||
);
|
||||
const [scope, setScope] = useState<(typeof SCOPES)[number]>(
|
||||
initialItem?.geographicScope || 'ایران'
|
||||
);
|
||||
const [summary, setSummary] = useState(initialItem?.executiveSummary || '');
|
||||
const [whyItMatters, setWhyItMatters] = useState(initialItem?.whyItMatters || '');
|
||||
const [relevance, setRelevance] = useState(initialItem?.relevanceToUniversity || '');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !summary.trim()) return;
|
||||
|
||||
const item: IntelligenceItem = {
|
||||
onSave({
|
||||
id: initialItem?.id || `custom-${Date.now()}`,
|
||||
title,
|
||||
category,
|
||||
|
|
@ -45,7 +78,7 @@ export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
|||
impactScore,
|
||||
uncertaintyScore,
|
||||
confidence: 'high',
|
||||
geographicScope,
|
||||
geographicScope: scope,
|
||||
executiveSummary: summary,
|
||||
whyItMatters,
|
||||
relevanceToUniversity: relevance,
|
||||
|
|
@ -58,201 +91,181 @@ export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
|||
laborMarket: 4,
|
||||
strategicImportance: impactScore
|
||||
},
|
||||
evidence: initialItem?.evidence || [
|
||||
{
|
||||
title: 'شواهد ثبت شده توسط کاربر در سامانه هوشمندی',
|
||||
source: 'دادهورزی داخلی دانشگاه اصفهان',
|
||||
date: '۱۴۰۳/۰۶/۰۱',
|
||||
snippet: summary
|
||||
}
|
||||
],
|
||||
evidence: initialItem?.evidence || [],
|
||||
implications: initialItem?.implications || {
|
||||
opportunities: ['فرصت نوآوری آموزشی و پاسخ به تقاضای بازار'],
|
||||
risks: ['خطر عدم انطباق بهنگام برنامههای درسی'],
|
||||
threats: ['عقبماندگی از رقبای منطقهای'],
|
||||
requiredCapabilities: ['توسعه زیرساختها و بازآموزی اساتید'],
|
||||
affectedDepartments: ['معاونت آموزشی', 'گروههای آموزشی مرتبط']
|
||||
opportunities: [],
|
||||
risks: [],
|
||||
threats: [],
|
||||
requiredCapabilities: [],
|
||||
affectedDepartments: []
|
||||
},
|
||||
relatedItemIds: initialItem?.relatedItemIds || [],
|
||||
relatedJobIds: initialItem?.relatedJobIds || [],
|
||||
relatedSkillIds: initialItem?.relatedSkillIds || [],
|
||||
relatedDimensionIds: initialItem?.relatedDimensionIds || ['dim-1']
|
||||
};
|
||||
|
||||
onSave(item);
|
||||
relatedDimensionIds: initialItem?.relatedDimensionIds || []
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="modal-content p-6 flex flex-col gap-4 text-right animate-fadeIn max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-white font-black text-base">
|
||||
{isEditing ? 'ویرایش موجودیت هوشمندی' : 'افزودن سیگنال / روند / فناوری جدید'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-h-[88vh] max-w-2xl overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'ویرایش مورد' : 'ثبت مورد جدید'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
روند، فناوری، سیاست یا سیگنال آینده را در رادار ثبت کنید.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 text-xs">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">عنوان موجودیت:</label>
|
||||
<input
|
||||
type="text"
|
||||
<div className="my-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="title">عنوان</Label>
|
||||
<Input
|
||||
id="title"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="مثال: دوقلوی دیجیتال آزمایشگاههای مهندسی دانشگاه اصفهان"
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row: Category, Horizon, Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">دستهبندی رادار:</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as Category)}
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
||||
>
|
||||
<option value="trend">روند (Trend)</option>
|
||||
<option value="technology">فناوری (Technology)</option>
|
||||
<option value="policy">سیاست و حکمرانی (Policy)</option>
|
||||
<option value="weak_signal">سیگنال ضعیف آینده (Weak Signal)</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>دسته</Label>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as Category)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_ORDER.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{CATEGORY_META[cat].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">افق زمانی تحقق:</label>
|
||||
<select
|
||||
value={horizon}
|
||||
onChange={(e) => setHorizon(e.target.value as Horizon)}
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
||||
>
|
||||
<option value="now">اکنون (۱۴۰۳-۱۴۰۵)</option>
|
||||
<option value="near">نزدیک (۱۴۰۵-۱۴۰۸)</option>
|
||||
<option value="mid">میانمدت (۱۴۰۸-۱۴۱۱)</option>
|
||||
<option value="long">بلندمدت (۱۴۱۱-۱۴۱۵)</option>
|
||||
</select>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>افق زمانی</Label>
|
||||
<Select value={horizon} onValueChange={(v) => setHorizon(v as Horizon)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HORIZONS.map((h) => (
|
||||
<SelectItem key={h} value={h}>
|
||||
{getHorizonLabelFa(h)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">وضعیت رصد:</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as ItemStatus)}
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
||||
>
|
||||
<option value="active">فعال (Active)</option>
|
||||
<option value="emerging">نوظهور (Emerging)</option>
|
||||
<option value="monitoring">تحت پایش (Monitoring)</option>
|
||||
<option value="critical">بحرانی / فوری (Critical)</option>
|
||||
</select>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>وضعیت</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as ItemStatus)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(STATUS_LABELS) as ItemStatus[]).map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_LABELS[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row: Impact Score & Scope */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">ضریب تأثیر (۱ تا ۵):</label>
|
||||
<input
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="impact">شدت اثر (۱ تا ۵)</Label>
|
||||
<Input
|
||||
id="impact"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={impactScore}
|
||||
onChange={(e) => setImpactScore(Number(e.target.value))}
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2 text-white outline-none focus:border-teal-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">عدم قطعیت (۱ تا ۵):</label>
|
||||
<input
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="uncertainty">عدم قطعیت (۱ تا ۵)</Label>
|
||||
<Input
|
||||
id="uncertainty"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={uncertaintyScore}
|
||||
onChange={(e) => setUncertaintyScore(Number(e.target.value))}
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2 text-white outline-none focus:border-teal-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">دامنه جغرافیایی:</label>
|
||||
<select
|
||||
value={geographicScope}
|
||||
onChange={(e) => setGeographicScope(e.target.value as any)}
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>دامنه</Label>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(v) => setScope(v as (typeof SCOPES)[number])}
|
||||
>
|
||||
<option value="ایران">ایران</option>
|
||||
<option value="منطقهای">منطقهای (خاورمیانه / استان اصفهان)</option>
|
||||
<option value="جهانی">جهانی</option>
|
||||
</select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">خلاصه مدیریتی (Executive Summary):</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="summary">خلاصه</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
required
|
||||
rows={2}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="توضیح کوتاه در ۲ الی ۳ جمله درباره این پدیده..."
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Why It Matters */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">چرا برای آموزش عالی اهمیت دارد؟ (Why it matters):</label>
|
||||
<textarea
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="why">چرا اهمیت دارد؟</Label>
|
||||
<Textarea
|
||||
id="why"
|
||||
rows={2}
|
||||
value={whyItMatters}
|
||||
onChange={(e) => setWhyItMatters(e.target.value)}
|
||||
placeholder="چرا دانشگاه باید به این موضوع واکنش نشان دهد..."
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Relevance to University */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-slate-300 font-semibold">ارتباط مستقیم با دانشگاه اصفهان:</label>
|
||||
<input
|
||||
type="text"
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="relevance">ارتباط با دانشگاه اصفهان</Label>
|
||||
<Input
|
||||
id="relevance"
|
||||
value={relevance}
|
||||
onChange={(e) => setRelevance(e.target.value)}
|
||||
placeholder="مثال: تأثیر بر گروههای آموزشی مهندسی و علوم پایه دانشگاه اصفهان"
|
||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn btn-secondary text-xs py-2 px-4"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary text-xs py-2 px-5 flex items-center gap-2"
|
||||
>
|
||||
<Save size={15} />
|
||||
<span>{isEditing ? 'ذخیره تغییرات' : 'ثبت در رادار هوشمندی'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save />
|
||||
{isEditing ? 'ذخیره' : 'ثبت'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,86 +1,87 @@
|
|||
import React, { useState } from 'react';
|
||||
import { X, CheckCircle, Mail } from 'lucide-react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({ isOpen, onClose }) => {
|
||||
export const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({
|
||||
isOpen,
|
||||
onClose
|
||||
}) => {
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!identifier.trim()) return;
|
||||
setIsSubmitted(true);
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
// Reset only after the dialog has finished animating out.
|
||||
setTimeout(() => {
|
||||
setSent(false);
|
||||
setIdentifier('');
|
||||
}, 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="modal-content p-6 flex flex-col gap-4 text-right max-w-md animate-fadeIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-white font-black text-sm">بازیابی گذرواژه دانشگاهی</h3>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSubmitted ? (
|
||||
<div className="flex flex-col items-center text-center py-6 gap-3">
|
||||
<CheckCircle size={40} className="text-teal-400" />
|
||||
<h4 className="text-white font-bold text-sm">پیامک / ایمیل بازیابی ارسال شد</h4>
|
||||
<p className="text-slate-300 text-xs leading-relaxed max-w-xs">
|
||||
لینک اختصاصی تعیین رمز عبور جدید به شناسه رسمی شما در پورتال دانشگاه اصفهان ارسال گردید.
|
||||
</p>
|
||||
<button onClick={onClose} className="btn btn-primary text-xs py-2 px-6 mt-2">
|
||||
بازگشت به صفحه ورود
|
||||
</button>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<CheckCircle2 className="size-9 text-primary" strokeWidth={1.8} />
|
||||
<DialogTitle className="text-[15px]">لینک بازیابی ارسال شد</DialogTitle>
|
||||
<DialogDescription className="max-w-xs">
|
||||
لینک تعیین رمز عبور جدید به شناسه شما ارسال شد.
|
||||
</DialogDescription>
|
||||
<Button onClick={handleClose} className="mt-2">
|
||||
بازگشت به ورود
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 text-xs">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
لطفاً پست الکترونیکی دانشگاهی (با پسوند ui.ac.ir@) یا شماره تلفن همراه متصل به کارتابل اداری خود را وارد نمایید.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (identifier.trim()) setSent(true);
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>بازیابی رمز عبور</DialogTitle>
|
||||
<DialogDescription>
|
||||
ایمیل دانشگاهی یا شماره تلفن همراه خود را وارد کنید.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-slate-400 font-semibold">پست الکترونیکی یا شماره همراه:</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
<div className="my-4 flex flex-col gap-1.5">
|
||||
<Label htmlFor="recovery">ایمیل یا شماره همراه</Label>
|
||||
<Input
|
||||
id="recovery"
|
||||
required
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="name@ui.ac.ir یا 0913xxxxxxx"
|
||||
className="w-full bg-[#091122] border border-slate-800 rounded-lg p-2.5 pl-8 text-white outline-none focus:border-teal-500 text-xs"
|
||||
placeholder="name@ui.ac.ir"
|
||||
/>
|
||||
<Mail size={15} className="absolute left-2.5 top-3 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn btn-secondary text-xs py-2 px-4"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary text-xs py-2 px-5"
|
||||
>
|
||||
ارسال لینک بازیابی
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Button type="submit">ارسال لینک</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../../types/futures';
|
||||
import React from 'react';
|
||||
import {
|
||||
toPersianDigits,
|
||||
getCategoryLabelFa,
|
||||
getCategoryColor,
|
||||
getHorizonLabelFa
|
||||
} from '../../utils/persianNumbers';
|
||||
import { Search, X, Clock, Zap, ArrowLeft, Target, Briefcase, Brain } from 'lucide-react';
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
JobEntity,
|
||||
SkillEntity
|
||||
} from '../../types/futures';
|
||||
import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
|
||||
import { CATEGORY_META } from '../../utils/categories';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from '@/components/ui/command';
|
||||
import { Target, Briefcase, Brain } from 'lucide-react';
|
||||
|
||||
interface GlobalSearchModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -20,6 +32,31 @@ interface GlobalSearchModalProps {
|
|||
onSelectJob: () => void;
|
||||
}
|
||||
|
||||
/** Cap each group so the palette stays scannable on a laptop screen. */
|
||||
const GROUP_LIMIT = 6;
|
||||
|
||||
/**
|
||||
* Persian text arrives with mixed Arabic/Persian codepoints depending on the
|
||||
* keyboard that produced it, plus zero-width non-joiners inside compound
|
||||
* words. Normalising both sides makes «ميشود» match «می شود».
|
||||
*/
|
||||
const normalise = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[يﻱﻲ]/g, 'ی') // Arabic yeh -> Persian yeh
|
||||
.replace(/[كﻙﻚ]/g, 'ک') // Arabic kaf -> Persian kaf
|
||||
.replace(//g, ' ') // zero-width non-joiner
|
||||
.replace(/[ً-ْ]/g, '') // harakat
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* cmdk's default scorer is fuzzy — on Persian it matches almost everything.
|
||||
* Plain substring matching is what people actually expect from a search box.
|
||||
*/
|
||||
const substringFilter = (value: string, search: string) =>
|
||||
normalise(value).includes(normalise(search)) ? 1 : 0;
|
||||
|
||||
export const GlobalSearchModal: React.FC<GlobalSearchModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
|
@ -30,260 +67,97 @@ export const GlobalSearchModal: React.FC<GlobalSearchModalProps> = ({
|
|||
onSelectItem,
|
||||
onSelectDimension,
|
||||
onSelectJob
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'items' | 'dimensions' | 'jobs' | 'skills'>('all');
|
||||
}) => (
|
||||
<CommandDialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title="جستجو"
|
||||
description="جستجو در موارد رصد، ویژگیها، مشاغل و مهارتها"
|
||||
className="max-w-xl"
|
||||
>
|
||||
<Command filter={substringFilter}>
|
||||
<CommandInput placeholder="جستجو…" />
|
||||
|
||||
// Keyboard shortcut listener for Esc
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
// Return top priority items if query is empty
|
||||
return {
|
||||
matchedItems: items.slice(0, 5),
|
||||
matchedDimensions: dimensions.slice(0, 3),
|
||||
matchedJobs: jobs.slice(0, 3),
|
||||
matchedSkills: skills.slice(0, 3)
|
||||
};
|
||||
}
|
||||
|
||||
const matchedItems = items.filter(
|
||||
(i) =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.executiveSummary.toLowerCase().includes(q) ||
|
||||
i.whyItMatters.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
const matchedDimensions = dimensions.filter(
|
||||
(d) =>
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
d.definition.toLowerCase().includes(q) ||
|
||||
d.vision.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
const matchedJobs = jobs.filter(
|
||||
(j) =>
|
||||
j.title.toLowerCase().includes(q) ||
|
||||
j.description.toLowerCase().includes(q) ||
|
||||
j.discipline.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
const matchedSkills = skills.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return { matchedItems, matchedDimensions, matchedJobs, matchedSkills };
|
||||
}, [query, items, dimensions, jobs, skills]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
<CommandList>
|
||||
<CommandEmpty>نتیجهای یافت نشد.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="موارد رصد">
|
||||
{items.slice(0, 40).map((item) => {
|
||||
const meta = CATEGORY_META[item.category];
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="modal-content p-5 flex flex-col gap-4 text-right animate-fadeIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Input Bar */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Search size={20} className="text-teal-400" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="جستجو در تمام موجودیتهای رادار، ابعاد ۱۴۱۵، مشاغل و مهارتها..."
|
||||
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className={`px-3 py-1 rounded-full transition-all ${
|
||||
activeFilter === 'all' ? 'bg-teal-500 text-slate-950 font-bold' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
همه نتایج
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('items')}
|
||||
className={`px-3 py-1 rounded-full transition-all ${
|
||||
activeFilter === 'items' ? 'bg-teal-500 text-slate-950 font-bold' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
روندها و فناوریها ({toPersianDigits(results.matchedItems.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('dimensions')}
|
||||
className={`px-3 py-1 rounded-full transition-all ${
|
||||
activeFilter === 'dimensions' ? 'bg-teal-500 text-slate-950 font-bold' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
ابعاد ۱۴۱۵ ({toPersianDigits(results.matchedDimensions.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('jobs')}
|
||||
className={`px-3 py-1 rounded-full transition-all ${
|
||||
activeFilter === 'jobs' ? 'bg-teal-500 text-slate-950 font-bold' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
مشاغل ({toPersianDigits(results.matchedJobs.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('skills')}
|
||||
className={`px-3 py-1 rounded-full transition-all ${
|
||||
activeFilter === 'skills' ? 'bg-teal-500 text-slate-950 font-bold' : 'bg-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
مهارتها ({toPersianDigits(results.matchedSkills.length)})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* Intelligence Items (Trends, Tech, Policies, Signals) */}
|
||||
{(activeFilter === 'all' || activeFilter === 'items') && results.matchedItems.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[11px] font-bold text-slate-400 px-1">روندها، فناوریها و سیگنالها:</div>
|
||||
{results.matchedItems.map((item) => (
|
||||
<div
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
onSelectItem(item);
|
||||
onClose();
|
||||
}}
|
||||
className="bg-[#0b1428] hover:bg-[#11203d] border border-slate-800 hover:border-teal-500/40 p-3 rounded-xl cursor-pointer transition-all flex flex-col gap-1"
|
||||
value={`${item.title} ${item.executiveSummary}`}
|
||||
onSelect={() => onSelectItem(item)}
|
||||
className="gap-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${getCategoryColor(item.category)}22`,
|
||||
color: getCategoryColor(item.category)
|
||||
}}
|
||||
>
|
||||
{getCategoryLabelFa(item.category)}
|
||||
<span className="cat-dot" style={{ background: meta.color }} />
|
||||
<span className="min-w-0 flex-1 truncate">{item.title}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{getHorizonShortFa(item.horizon)}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-slate-400 flex items-center gap-1">
|
||||
<Clock size={11} />
|
||||
{getHorizonLabelFa(item.horizon)}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="text-white text-xs font-bold mt-1">{item.title}</h5>
|
||||
<p className="text-slate-400 text-[11px] line-clamp-2">{item.executiveSummary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1415 Dimensions */}
|
||||
{(activeFilter === 'all' || activeFilter === 'dimensions') && results.matchedDimensions.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="text-[11px] font-bold text-teal-400 px-1 flex items-center gap-1">
|
||||
<Target size={13} />
|
||||
ابعاد دهگانه دانشگاه اصفهان ۱۴۱۵:
|
||||
</div>
|
||||
{results.matchedDimensions.map((dim) => (
|
||||
<div
|
||||
key={dim.id}
|
||||
onClick={() => {
|
||||
onSelectDimension(dim);
|
||||
onClose();
|
||||
}}
|
||||
className="bg-[#0b1428] hover:bg-[#11203d] border border-teal-500/30 p-3 rounded-xl cursor-pointer transition-all flex items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
<h5 className="text-white text-xs font-bold">{dim.title}</h5>
|
||||
<p className="text-slate-400 text-[11px] line-clamp-1 mt-0.5">{dim.definition}</p>
|
||||
</div>
|
||||
<div className="text-left shrink-0 text-xs">
|
||||
<span className="text-rose-400 font-bold">شکاف: {toPersianDigits(dim.gap)}</span>
|
||||
<div className="text-[10px] text-teal-400 font-semibold">{toPersianDigits(dim.readinessPercentage)}٪ آمادگی</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs */}
|
||||
{(activeFilter === 'all' || activeFilter === 'jobs') && results.matchedJobs.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="text-[11px] font-bold text-amber-400 px-1 flex items-center gap-1">
|
||||
<Briefcase size={13} />
|
||||
مشاغل آینده آموزش عالی:
|
||||
</div>
|
||||
{results.matchedJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
onClick={() => {
|
||||
onSelectJob();
|
||||
onClose();
|
||||
}}
|
||||
className="bg-[#0b1428] hover:bg-[#11203d] border border-slate-800 p-2.5 rounded-xl cursor-pointer transition-all flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<span className="text-white font-bold">{job.title}</span>
|
||||
<span className="text-slate-400 text-[11px] block">{job.discipline}</span>
|
||||
</div>
|
||||
<span className={`badge ${job.type === 'growing' ? 'badge-teal' : 'badge-rose'}`}>
|
||||
{job.type === 'growing' ? `+${toPersianDigits(job.growthRatePercent)}٪ رشد` : `${toPersianDigits(job.automationRiskPercent)}٪ ریسک اتوماسیون`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{(activeFilter === 'all' || activeFilter === 'skills') && results.matchedSkills.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="text-[11px] font-bold text-purple-400 px-1 flex items-center gap-1">
|
||||
<Brain size={13} />
|
||||
شایستگیها و مهارتهای آینده:
|
||||
</div>
|
||||
{results.matchedSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
onClick={() => {
|
||||
onSelectJob();
|
||||
onClose();
|
||||
}}
|
||||
className="bg-[#0b1428] hover:bg-[#11203d] border border-slate-800 p-2.5 rounded-xl cursor-pointer transition-all flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-slate-200">{skill.title}</span>
|
||||
<span className="text-slate-500 text-[11px]">رده: {skill.category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.matchedItems.length === 0 &&
|
||||
results.matchedDimensions.length === 0 &&
|
||||
results.matchedJobs.length === 0 && (
|
||||
<div className="text-center py-8 text-slate-500 text-xs">
|
||||
موردی منطبق با «{query}» در پایگاه هوشمندی یافت نشد.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="ویژگیهای ۱۴۱۵">
|
||||
{dimensions.slice(0, GROUP_LIMIT + 4).map((dim) => (
|
||||
<CommandItem
|
||||
key={dim.id}
|
||||
value={`${dim.title} ${dim.titleEn}`}
|
||||
onSelect={() => onSelectDimension(dim)}
|
||||
className="gap-2.5"
|
||||
>
|
||||
<Target className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{dim.title}</span>
|
||||
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||
شکاف {toPersianDigits(dim.gap)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="مشاغل">
|
||||
{jobs.slice(0, 30).map((job) => (
|
||||
<CommandItem
|
||||
key={job.id}
|
||||
value={`${job.title} ${job.discipline}`}
|
||||
onSelect={onSelectJob}
|
||||
className="gap-2.5"
|
||||
>
|
||||
<Briefcase className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{job.title}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{job.discipline}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="مهارتها">
|
||||
{skills.slice(0, 30).map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.id}
|
||||
value={`${skill.title} ${skill.description}`}
|
||||
onSelect={onSelectJob}
|
||||
className="gap-2.5"
|
||||
>
|
||||
<Brain className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{skill.title}</span>
|
||||
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(skill.importanceScore)}/۵
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-start text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-muted data-[selected=true]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[selected=true]:*:[svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:border-l data-[orientation=vertical]:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[side=bottom]:data-[state=open]:slide-in-from-bottom-10 data-[side=left]:data-[state=open]:slide-in-from-left-10 data-[side=right]:data-[state=open]:slide-in-from-right-10 data-[side=top]:data-[state=open]:slide-in-from-top-10 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:data-[state=closed]:slide-out-to-bottom-10 data-[side=left]:data-[state=closed]:slide-out-to-left-10 data-[side=right]:data-[state=closed]:slide-out-to-right-10 data-[side=top]:data-[state=closed]:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
|
|
@ -0,0 +1,702 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-[state=checked]:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-[state=checked]:translate-x-[calc(100%-2px)] dark:data-[state=checked]:bg-primary-foreground group-data-[size=default]/switch:data-[state=unchecked]:translate-x-0 group-data-[size=sm]/switch:data-[state=unchecked]:translate-x-0 dark:data-[state=unchecked]:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-start align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { useState } from 'react';
|
||||
import { University1415Dimension } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { HORIZON_END } from '../../utils/horizon';
|
||||
|
||||
interface DimensionBloomProps {
|
||||
dimensions: University1415Dimension[];
|
||||
onSelect: (dim: University1415Dimension) => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const MAX_SCORE = 10;
|
||||
|
||||
/**
|
||||
* Petal colour encodes readiness rather than identity: the eye should land on
|
||||
* the short petals first, because those are the dimensions in trouble. Ten
|
||||
* arbitrary hues would say nothing.
|
||||
*/
|
||||
const readinessColor = (pct: number) =>
|
||||
`color-mix(in oklab, var(--positive) ${Math.round(pct)}%, var(--negative))`;
|
||||
|
||||
/** Wedge between two radii, spanning [a0, a1]. */
|
||||
const sectorPath = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
rInner: number,
|
||||
rOuter: number,
|
||||
a0: number,
|
||||
a1: number
|
||||
) => {
|
||||
const p = (r: number, a: number) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
return [
|
||||
`M ${p(rInner, a0)}`,
|
||||
`L ${p(rOuter, a0)}`,
|
||||
`A ${rOuter} ${rOuter} 0 ${large} 1 ${p(rOuter, a1)}`,
|
||||
`L ${p(rInner, a1)}`,
|
||||
`A ${rInner} ${rInner} 0 ${large} 0 ${p(rInner, a0)}`,
|
||||
'Z'
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
export const DimensionBloom: React.FC<DimensionBloomProps> = ({
|
||||
dimensions,
|
||||
onSelect,
|
||||
height = 560
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
const size = 720;
|
||||
const center = size / 2;
|
||||
const rInner = 82;
|
||||
const rOuter = 268;
|
||||
const count = dimensions.length;
|
||||
|
||||
if (!count) return null;
|
||||
|
||||
const sweep = (2 * Math.PI) / count;
|
||||
const padding = 0.035; // keeps a hairline of space between petals
|
||||
const scaleFor = (score: number) =>
|
||||
rInner + (Math.max(0, Math.min(MAX_SCORE, score)) / MAX_SCORE) * (rOuter - rInner);
|
||||
|
||||
const avgReadiness = Math.round(
|
||||
dimensions.reduce((a, d) => a + d.readinessPercentage, 0) / count
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex w-full select-none items-center justify-center"
|
||||
style={{ minHeight: height }}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="h-auto w-full max-w-[660px] overflow-visible"
|
||||
role="img"
|
||||
aria-label={`بلوغ ده ویژگی دانشگاه در افق ${toPersianDigits(HORIZON_END)}`}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="bloomCore" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.18" />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx={center} cy={center} r={rOuter + 26} fill="url(#bloomCore)" />
|
||||
|
||||
{/* Score rings at 2/4/6/8/10 */}
|
||||
{[2, 4, 6, 8, 10].map((level) => (
|
||||
<circle
|
||||
key={level}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={scaleFor(level)}
|
||||
fill="none"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray={level === MAX_SCORE ? 'none' : '2 6'}
|
||||
strokeOpacity="0.8"
|
||||
/>
|
||||
))}
|
||||
|
||||
{dimensions.map((dim, i) => {
|
||||
// Start at 12 o'clock and run clockwise.
|
||||
const a0 = i * sweep - Math.PI / 2 + padding;
|
||||
const a1 = (i + 1) * sweep - Math.PI / 2 - padding;
|
||||
const mid = (a0 + a1) / 2;
|
||||
|
||||
const rCurrent = scaleFor(dim.currentScore);
|
||||
const rTarget = scaleFor(dim.targetScore);
|
||||
const isHovered = hovered === i;
|
||||
|
||||
const labelR = rOuter + 34;
|
||||
const lx = center + labelR * Math.cos(mid);
|
||||
const ly = center + labelR * Math.sin(mid);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={dim.id}
|
||||
className="bloom-petal cursor-pointer"
|
||||
style={{
|
||||
transformBox: 'view-box',
|
||||
transformOrigin: `${center}px ${center}px`,
|
||||
animationDelay: `${i * 70}ms`
|
||||
}}
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(i)}
|
||||
onBlur={() => setHovered(null)}
|
||||
onClick={() => onSelect(dim)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(dim);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`${dim.title}: آمادگی ${dim.readinessPercentage} درصد`}
|
||||
>
|
||||
{/* Shortfall — the space still to be covered by ۱۴۱۵. */}
|
||||
<path
|
||||
d={sectorPath(center, center, rCurrent, rTarget, a0, a1)}
|
||||
fill="var(--muted-foreground)"
|
||||
fillOpacity={isHovered ? 0.16 : 0.08}
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
|
||||
{/* Achieved */}
|
||||
<path
|
||||
d={sectorPath(center, center, rInner, rCurrent, a0, a1)}
|
||||
fill={readinessColor(dim.readinessPercentage)}
|
||||
fillOpacity={isHovered ? 1 : 0.85}
|
||||
stroke="var(--card)"
|
||||
strokeWidth="1.5"
|
||||
style={{ transition: 'fill-opacity 180ms ease' }}
|
||||
/>
|
||||
|
||||
{/* Target tick */}
|
||||
<path
|
||||
d={`M ${center + rTarget * Math.cos(a0)} ${center + rTarget * Math.sin(a0)}
|
||||
A ${rTarget} ${rTarget} 0 0 1 ${center + rTarget * Math.cos(a1)} ${center + rTarget * Math.sin(a1)}`}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
strokeOpacity={isHovered ? 1 : 0.6}
|
||||
/>
|
||||
|
||||
<text
|
||||
x={lx}
|
||||
y={ly}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="12"
|
||||
fontWeight={isHovered ? 700 : 600}
|
||||
fill={isHovered ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
className="pointer-events-none"
|
||||
style={{ transition: 'fill 180ms ease' }}
|
||||
>
|
||||
{dim.shortTag}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Core: the aggregate figure the petals add up to. */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={rInner - 8}
|
||||
fill="var(--card)"
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={center - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="30"
|
||||
fontWeight="700"
|
||||
fill="var(--foreground)"
|
||||
className="tnum pointer-events-none"
|
||||
>
|
||||
{toPersianDigits(avgReadiness)}٪
|
||||
</text>
|
||||
<text
|
||||
x={center}
|
||||
y={center + 16}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fill="var(--muted-foreground)"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
میانگین آمادگی
|
||||
</text>
|
||||
<text
|
||||
x={center}
|
||||
y={center + 33}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="var(--muted-foreground)"
|
||||
opacity="0.8"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
افق {toPersianDigits(HORIZON_END)}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{hovered !== null && (
|
||||
<div className="pointer-events-none absolute bottom-0 start-0 w-64 rounded-lg border bg-popover p-3 text-right shadow-xl animate-in fade-in slide-in-from-bottom-1 duration-150">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: dimensions[hovered].pillarColor }}
|
||||
/>
|
||||
<span className="text-[13px] font-bold">{dimensions[hovered].title}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[10.5px] text-muted-foreground">
|
||||
{dimensions[hovered].titleEn}
|
||||
</p>
|
||||
|
||||
<div className="tnum mt-2 flex items-center justify-between gap-2 rounded-md border bg-muted/50 px-2.5 py-1.5 text-[11.5px]">
|
||||
<span>
|
||||
موجود{' '}
|
||||
<b className="font-semibold">
|
||||
{toPersianDigits(dimensions[hovered].currentScore)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="text-primary">
|
||||
هدف{' '}
|
||||
<b className="font-semibold">
|
||||
{toPersianDigits(dimensions[hovered].targetScore)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="text-negative">
|
||||
شکاف{' '}
|
||||
<b className="font-semibold">{toPersianDigits(dimensions[hovered].gap)}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import React from 'react';
|
||||
import { University1415Dimension } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { HORIZON_END } from '../../utils/horizon';
|
||||
|
||||
interface DimensionGapTrackProps {
|
||||
dimensions: University1415Dimension[];
|
||||
onSelect: (dim: University1415Dimension) => void;
|
||||
}
|
||||
|
||||
const MAX_SCORE = 10;
|
||||
|
||||
/**
|
||||
* One track per dimension, scored 0–10. The filled part is where the
|
||||
* university is; the dashed part is the distance still to cover. Sorted by
|
||||
* gap, so the work stacks up from the top.
|
||||
*/
|
||||
export const DimensionGapTrack: React.FC<DimensionGapTrackProps> = ({
|
||||
dimensions,
|
||||
onSelect
|
||||
}) => {
|
||||
const sorted = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Scale */}
|
||||
<div className="mb-2 flex items-center gap-3 ps-[8.5rem] pe-14">
|
||||
<div className="tnum relative h-4 flex-1 text-[10px] text-muted-foreground">
|
||||
{[0, 2, 4, 6, 8, 10].map((n) => (
|
||||
<span
|
||||
key={n}
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ insetInlineStart: `${(n / MAX_SCORE) * 100}%` }}
|
||||
>
|
||||
{toPersianDigits(n)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y">
|
||||
{sorted.map((dim, i) => {
|
||||
const current = (dim.currentScore / MAX_SCORE) * 100;
|
||||
const target = (dim.targetScore / MAX_SCORE) * 100;
|
||||
|
||||
return (
|
||||
<li key={dim.id}>
|
||||
<button
|
||||
onClick={() => onSelect(dim)}
|
||||
className="group flex w-full items-center gap-3 py-2.5 text-right transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex w-[8.5rem] shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: dim.pillarColor }}
|
||||
/>
|
||||
<span className="truncate text-[12.5px] font-medium transition-colors group-hover:text-primary">
|
||||
{dim.shortTag}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="relative h-6 flex-1">
|
||||
{/* Track */}
|
||||
<span className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
|
||||
|
||||
{/* Distance still to cover */}
|
||||
<span
|
||||
className="gap-track-fill absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full"
|
||||
style={{
|
||||
insetInlineStart: `${current}%`,
|
||||
width: `${target - current}%`,
|
||||
background:
|
||||
'repeating-linear-gradient(90deg, var(--negative) 0 4px, transparent 4px 9px)',
|
||||
opacity: 0.65,
|
||||
animationDelay: `${i * 55}ms`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Achieved */}
|
||||
<span
|
||||
className="gap-track-fill absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary"
|
||||
style={{
|
||||
insetInlineStart: 0,
|
||||
width: `${current}%`,
|
||||
animationDelay: `${i * 55}ms`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Current marker */}
|
||||
<span
|
||||
className="gap-track-dot absolute top-1/2 size-3 -translate-y-1/2 rounded-full border-2 bg-primary"
|
||||
style={{
|
||||
insetInlineStart: `calc(${current}% - 6px)`,
|
||||
borderColor: 'var(--card)',
|
||||
animationDelay: `${i * 55 + 220}ms`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Target marker */}
|
||||
<span
|
||||
className="gap-track-dot absolute top-1/2 size-3 -translate-y-1/2 rounded-full border-2"
|
||||
style={{
|
||||
insetInlineStart: `calc(${target}% - 6px)`,
|
||||
background: 'var(--card)',
|
||||
borderColor: 'var(--primary)',
|
||||
animationDelay: `${i * 55 + 300}ms`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="tnum w-14 shrink-0 text-left">
|
||||
<span className="block text-[12.5px] font-semibold text-negative">
|
||||
{toPersianDigits(dim.gap)}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">شکاف</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-[11.5px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-5 rounded-full bg-primary" />
|
||||
وضع موجود
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-1.5 w-5 rounded-full opacity-65"
|
||||
style={{
|
||||
background:
|
||||
'repeating-linear-gradient(90deg, var(--negative) 0 4px, transparent 4px 9px)'
|
||||
}}
|
||||
/>
|
||||
فاصله تا هدف {toPersianDigits(HORIZON_END)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,16 +1,9 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { IntelligenceItem, Category, Horizon } from '../../types/futures';
|
||||
import {
|
||||
calculateItemRadarCoords,
|
||||
getHorizonRadiusRatio
|
||||
} from '../../utils/radarMath';
|
||||
import {
|
||||
toPersianDigits,
|
||||
getCategoryLabelFa,
|
||||
getHorizonLabelFa,
|
||||
getCategoryColor
|
||||
} from '../../utils/persianNumbers';
|
||||
import { Eye, Clock, Zap } from 'lucide-react';
|
||||
import { layoutRadarItems, getHorizonBand } from '../../utils/radarMath';
|
||||
import { toPersianDigits, getHorizonLabelFa } from '../../utils/persianNumbers';
|
||||
import { CATEGORY_META, CATEGORY_ORDER } from '../../utils/categories';
|
||||
import { horizonRangeFa } from '../../utils/horizon';
|
||||
|
||||
interface FuturesRadarProps {
|
||||
items: IntelligenceItem[];
|
||||
|
|
@ -20,355 +13,347 @@ interface FuturesRadarProps {
|
|||
height?: number;
|
||||
}
|
||||
|
||||
const HORIZONS: Horizon[] = ['now', 'near', 'mid', 'long'];
|
||||
|
||||
/** Where each quadrant's name sits, as a fraction of the plot radius. */
|
||||
const LABEL_ANCHOR: Record<Category, { x: number; y: number }> = {
|
||||
trend: { x: 0.62, y: -0.72 },
|
||||
technology: { x: -0.62, y: -0.72 },
|
||||
policy: { x: 0.62, y: 0.78 },
|
||||
weak_signal: { x: -0.62, y: 0.78 }
|
||||
};
|
||||
|
||||
/** Dot size encodes impact (1–5). */
|
||||
const dotRadius = (item: IntelligenceItem) => 6 + item.impactScore * 1.6;
|
||||
|
||||
export const FuturesRadar: React.FC<FuturesRadarProps> = ({
|
||||
items,
|
||||
selectedCategory = 'all',
|
||||
selectedHorizon = 'all',
|
||||
onSelectItem,
|
||||
height = 640
|
||||
height = 480
|
||||
}) => {
|
||||
const [hoveredItem, setHoveredItem] = useState<{
|
||||
const [hovered, setHovered] = useState<{
|
||||
item: IntelligenceItem;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const size = 720;
|
||||
const size = 760;
|
||||
const center = size / 2;
|
||||
const maxRadius = size / 2 - 36;
|
||||
const maxRadius = size / 2 - 54;
|
||||
|
||||
// Filter items according to props
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((item) => {
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((item) => {
|
||||
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
||||
if (selectedHorizon !== 'all' && item.horizon !== selectedHorizon) return false;
|
||||
return true;
|
||||
});
|
||||
}, [items, selectedCategory, selectedHorizon]);
|
||||
}),
|
||||
[items, selectedCategory, selectedHorizon]
|
||||
);
|
||||
|
||||
// Group items by category to calculate non-overlapping angles
|
||||
const itemsWithCoords = useMemo(() => {
|
||||
const groups: Record<Category, IntelligenceItem[]> = {
|
||||
trend: [],
|
||||
technology: [],
|
||||
policy: [],
|
||||
weak_signal: []
|
||||
};
|
||||
|
||||
filteredItems.forEach((item) => {
|
||||
groups[item.category].push(item);
|
||||
});
|
||||
|
||||
const result: { item: IntelligenceItem; x: number; y: number; radius: number }[] = [];
|
||||
|
||||
(Object.keys(groups) as Category[]).forEach((cat) => {
|
||||
const list = groups[cat];
|
||||
list.forEach((item, idx) => {
|
||||
const coords = calculateItemRadarCoords(
|
||||
item.id,
|
||||
item.category,
|
||||
item.horizon,
|
||||
idx,
|
||||
list.length,
|
||||
// Packed so dots never sit on top of one another.
|
||||
const placed = useMemo(
|
||||
() =>
|
||||
layoutRadarItems(
|
||||
filtered,
|
||||
(i) => i.category,
|
||||
(i) => i.horizon,
|
||||
dotRadius,
|
||||
center,
|
||||
maxRadius
|
||||
),
|
||||
[filtered, center, maxRadius]
|
||||
);
|
||||
result.push({
|
||||
item,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
radius: coords.radius
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [filteredItems, center, maxRadius]);
|
||||
|
||||
const rings: { horizon: Horizon; label: string; ratio: number }[] = [
|
||||
{ horizon: 'now', label: 'اکنون (۱۴۰۳-۱۴۰۵)', ratio: getHorizonRadiusRatio('now') },
|
||||
{ horizon: 'near', label: 'نزدیک (۱۴۰۵-۱۴۰۸)', ratio: getHorizonRadiusRatio('near') },
|
||||
{ horizon: 'mid', label: 'میانمدت (۱۴۰۸-۱۴۱۱)', ratio: getHorizonRadiusRatio('mid') },
|
||||
{ horizon: 'long', label: 'بلندمدت (۱۴۱۱-۱۴۱۵)', ratio: getHorizonRadiusRatio('long') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col items-center justify-center select-none" style={{ minHeight: height }}>
|
||||
<div
|
||||
className="relative flex w-full select-none flex-col items-center justify-center"
|
||||
style={{ minHeight: height }}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="w-full max-w-[680px] h-auto drop-shadow-md"
|
||||
style={{ overflow: 'visible' }}
|
||||
className="h-auto w-full max-w-[680px] overflow-visible"
|
||||
role="img"
|
||||
aria-label="رادار آینده: موارد رصدشده بر اساس افق زمانی و دسته"
|
||||
>
|
||||
<defs>
|
||||
{/* Radial radar background gradient for Neo-Clean Bento canvas */}
|
||||
<radialGradient id="radarBgGrad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#ffffff" stopOpacity="1" />
|
||||
<stop offset="70%" stopColor="#fbfcfe" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#f3f6fa" stopOpacity="1" />
|
||||
{/* Plot backdrop: brighter at the centre so depth reads outward. */}
|
||||
<radialGradient id="radarField" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.1" />
|
||||
<stop offset="45%" stopColor="var(--primary)" stopOpacity="0.035" />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
{/* Sweeping radar scan gradient */}
|
||||
<linearGradient id="scanGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#00b894" stopOpacity="0.25" />
|
||||
<stop offset="60%" stopColor="#00b894" stopOpacity="0.04" />
|
||||
<stop offset="100%" stopColor="#00b894" stopOpacity="0" />
|
||||
{/* Sweep: a trailing wedge that fades away from the leading edge. */}
|
||||
<linearGradient id="radarSweep" x1="100%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.28" />
|
||||
<stop offset="45%" stopColor="var(--primary)" stopOpacity="0.07" />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow Filters for high-impact dots */}
|
||||
<filter id="radarShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#0f172a" floodOpacity="0.12" />
|
||||
{/* Soft halo behind each dot. */}
|
||||
<filter id="dotGlow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Keeps the sweep inside the plot circle. */}
|
||||
<clipPath id="radarClip">
|
||||
<circle cx={center} cy={center} r={maxRadius} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Outer Circular Bounds */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={maxRadius}
|
||||
fill="url(#radarBgGrad)"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="1.5"
|
||||
filter="url(#radarShadow)"
|
||||
fill="var(--card)"
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<circle cx={center} cy={center} r={maxRadius} fill="url(#radarField)" />
|
||||
|
||||
{/* Quadrant Sector Pastel Tint Washes */}
|
||||
{/* Top-Right: Trends (Soft Sky) */}
|
||||
{/* One faint wash per quadrant so each category owns a region. */}
|
||||
<g clipPath="url(#radarClip)">
|
||||
{CATEGORY_ORDER.map((cat, i) => {
|
||||
const a0 = (i * Math.PI) / 2;
|
||||
const a1 = a0 + Math.PI / 2;
|
||||
return (
|
||||
<path
|
||||
d={`M ${center} ${center} L ${center + maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center - maxRadius} Z`}
|
||||
fill="rgba(2, 132, 199, 0.035)"
|
||||
/>
|
||||
{/* Top-Left: Technologies (Soft Mint) */}
|
||||
<path
|
||||
d={`M ${center} ${center} L ${center} ${center - maxRadius} A ${maxRadius} ${maxRadius} 0 0 0 ${center - maxRadius} ${center} Z`}
|
||||
fill="rgba(0, 184, 148, 0.035)"
|
||||
/>
|
||||
{/* Bottom-Left: Weak Signals (Soft Coral/Rose) */}
|
||||
<path
|
||||
d={`M ${center} ${center} L ${center - maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center + maxRadius} Z`}
|
||||
fill="rgba(244, 63, 94, 0.035)"
|
||||
/>
|
||||
{/* Bottom-Right: Policies (Soft Amber) */}
|
||||
<path
|
||||
d={`M ${center} ${center} L ${center} ${center + maxRadius} A ${maxRadius} ${maxRadius} 0 0 0 ${center + maxRadius} ${center} Z`}
|
||||
fill="rgba(245, 158, 11, 0.035)"
|
||||
key={cat}
|
||||
d={`M ${center} ${center} L ${center + maxRadius * Math.cos(a0)} ${
|
||||
center + maxRadius * Math.sin(a0)
|
||||
} A ${maxRadius} ${maxRadius} 0 0 1 ${
|
||||
center + maxRadius * Math.cos(a1)
|
||||
} ${center + maxRadius * Math.sin(a1)} Z`}
|
||||
fill={CATEGORY_META[cat].color}
|
||||
fillOpacity="0.04"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sweeping Radar Scan Line */}
|
||||
<g className="radar-sweep-line pointer-events-none">
|
||||
<path
|
||||
d={`M ${center} ${center} L ${center + maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center - maxRadius} Z`}
|
||||
fill="url(#scanGradient)"
|
||||
fill="url(#radarSweep)"
|
||||
/>
|
||||
<line
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={center + maxRadius}
|
||||
y2={center}
|
||||
stroke="#00b894"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.75"
|
||||
strokeOpacity="0.55"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Concentric Horizon Rings */}
|
||||
{rings.map((ring, idx) => {
|
||||
const r = maxRadius * ring.ratio;
|
||||
const isHighlighted = selectedHorizon === ring.horizon;
|
||||
{/* Horizon rings, labelled with their year band. */}
|
||||
{HORIZONS.map((horizon, idx) => {
|
||||
const band = getHorizonBand(horizon);
|
||||
const r = maxRadius * band.outer;
|
||||
const active = selectedHorizon === horizon;
|
||||
return (
|
||||
<g key={ring.horizon}>
|
||||
<g key={horizon}>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={isHighlighted ? '#00b894' : '#e2e8f0'}
|
||||
strokeWidth={isHighlighted ? '2' : '1'}
|
||||
strokeDasharray={idx === 0 ? 'none' : '4,4'}
|
||||
opacity={isHighlighted ? 1 : 0.85}
|
||||
stroke={active ? 'var(--primary)' : 'var(--border)'}
|
||||
strokeWidth={active ? 1.5 : 1}
|
||||
strokeDasharray={idx === HORIZONS.length - 1 ? 'none' : '3 6'}
|
||||
strokeOpacity={active ? 0.9 : 0.7}
|
||||
/>
|
||||
{/* Ring Label (Horizon text on vertical axis) */}
|
||||
<text
|
||||
x={center}
|
||||
y={center - r + 13}
|
||||
fill="#64748b"
|
||||
fontSize="9.5"
|
||||
fontWeight="600"
|
||||
y={center - r + 15}
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="10.5"
|
||||
fontWeight="500"
|
||||
textAnchor="middle"
|
||||
opacity="0.75"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
{ring.label}
|
||||
{horizonRangeFa(horizon)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Quadrant Divider Axes */}
|
||||
{/* Quadrant dividers */}
|
||||
<line
|
||||
x1={center - maxRadius}
|
||||
y1={center}
|
||||
x2={center + maxRadius}
|
||||
y2={center}
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="4,4"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="2 7"
|
||||
/>
|
||||
<line
|
||||
x1={center}
|
||||
y1={center - maxRadius}
|
||||
x2={center}
|
||||
y2={center + maxRadius}
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="4,4"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="2 7"
|
||||
/>
|
||||
|
||||
{/* Center Institutional Hub */}
|
||||
<circle cx={center} cy={center} r={20} fill="#ffffff" stroke="#00b894" strokeWidth="2.5" filter="url(#radarShadow)" />
|
||||
<circle cx={center} cy={center} r={7} fill="#00b894" className="anim-pulse" />
|
||||
{/* Quadrant names, set outside the plot so they never sit under dots. */}
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
const a = LABEL_ANCHOR[cat];
|
||||
return (
|
||||
<text
|
||||
key={cat}
|
||||
x={center + maxRadius * a.x}
|
||||
y={center + maxRadius * a.y}
|
||||
fill={meta.color}
|
||||
fontSize="13"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
{meta.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The university sits at the centre; distance from it is time. */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={26}
|
||||
fill="var(--primary)"
|
||||
fillOpacity="0.1"
|
||||
className="anim-pulse"
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={16}
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<circle cx={center} cy={center} r={5} fill="var(--primary)" />
|
||||
<text
|
||||
x={center}
|
||||
y={center + 32}
|
||||
fill="#1e293b"
|
||||
fontSize="9.5"
|
||||
fontWeight="800"
|
||||
y={center + 33}
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="10.5"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
دانشگاه اصفهان
|
||||
</text>
|
||||
|
||||
{/* Quadrant Corner Badges (Modern Bento Pills) */}
|
||||
{/* Top-Right: Trends */}
|
||||
<g transform={`translate(${center + maxRadius * 0.48}, ${center - maxRadius * 0.78})`}>
|
||||
<rect x="-65" y="-13" width="130" height="26" rx="13" fill="#ffffff" stroke="#bae6fd" strokeWidth="1.5" filter="url(#radarShadow)" />
|
||||
<text textAnchor="middle" y="4.5" fill="#0284c7" fontSize="11" fontWeight="800">روندها (Trends)</text>
|
||||
</g>
|
||||
|
||||
{/* Top-Left: Technologies */}
|
||||
<g transform={`translate(${center - maxRadius * 0.48}, ${center - maxRadius * 0.78})`}>
|
||||
<rect x="-70" y="-13" width="140" height="26" rx="13" fill="#ffffff" stroke="#a7f3d0" strokeWidth="1.5" filter="url(#radarShadow)" />
|
||||
<text textAnchor="middle" y="4.5" fill="#059669" fontSize="11" fontWeight="800">فناوریها (Tech)</text>
|
||||
</g>
|
||||
|
||||
{/* Bottom-Right: Policies */}
|
||||
<g transform={`translate(${center + maxRadius * 0.48}, ${center + maxRadius * 0.82})`}>
|
||||
<rect x="-65" y="-13" width="130" height="26" rx="13" fill="#ffffff" stroke="#fde68a" strokeWidth="1.5" filter="url(#radarShadow)" />
|
||||
<text textAnchor="middle" y="4.5" fill="#d97706" fontSize="11" fontWeight="800">سیاستها (Policies)</text>
|
||||
</g>
|
||||
|
||||
{/* Bottom-Left: Weak Signals */}
|
||||
<g transform={`translate(${center - maxRadius * 0.48}, ${center + maxRadius * 0.82})`}>
|
||||
<rect x="-75" y="-13" width="150" height="26" rx="13" fill="#ffffff" stroke="#fecdd3" strokeWidth="1.5" filter="url(#radarShadow)" />
|
||||
<text textAnchor="middle" y="4.5" fill="#e11d48" fontSize="11" fontWeight="800">سیگنالهای آینده (Signals)</text>
|
||||
</g>
|
||||
|
||||
{/* Radar Item Blips */}
|
||||
{itemsWithCoords.map(({ item, x, y }) => {
|
||||
const color = getCategoryColor(item.category);
|
||||
// Dot radius based on impactScore (1 to 5)
|
||||
const dotR = 6 + item.impactScore * 2;
|
||||
const isCritical = item.status === 'critical' || item.impactScore === 5;
|
||||
const isHovered = hoveredItem?.item.id === item.id;
|
||||
{/* Blips */}
|
||||
{placed.map(({ item, x, y }, idx) => {
|
||||
const color = CATEGORY_META[item.category].color;
|
||||
const r = dotRadius(item);
|
||||
const isCritical = item.status === 'critical';
|
||||
const isHovered = hovered?.item.id === item.id;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={item.id}
|
||||
transform={`translate(${x}, ${y})`}
|
||||
className="cursor-pointer transition-transform duration-150"
|
||||
style={{ transformBox: 'fill-box' }}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectItem(item)}
|
||||
onMouseEnter={() => setHoveredItem({ item, x, y })}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
onMouseEnter={() => setHovered({ item, x, y })}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={item.title}
|
||||
onFocus={() => setHovered({ item, x, y })}
|
||||
onBlur={() => setHovered(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectItem(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Pulsing outer aura for critical signals */}
|
||||
<g
|
||||
className="radar-blip"
|
||||
style={{ animationDelay: `${Math.min(idx * 14, 500)}ms` }}
|
||||
>
|
||||
{/* Critical items keep a slow pulse so they read at a glance. */}
|
||||
{isCritical && (
|
||||
<circle
|
||||
r={dotR + 6}
|
||||
r={r + 7}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.25"
|
||||
opacity="0.4"
|
||||
className="anim-pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover Highlight Ring */}
|
||||
{isHovered && (
|
||||
<circle
|
||||
r={dotR + 7}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeWidth="2"
|
||||
opacity="0.7"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Core Dot (Vibrant color with crisp white border) */}
|
||||
<circle
|
||||
r={dotR}
|
||||
r={isHovered ? r + 2 : r}
|
||||
fill={color}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2.5"
|
||||
filter="url(#radarShadow)"
|
||||
stroke="var(--card)"
|
||||
strokeWidth="2"
|
||||
filter={isHovered ? 'url(#dotGlow)' : undefined}
|
||||
style={{ transition: 'r 160ms cubic-bezier(0.16,1,0.3,1)' }}
|
||||
/>
|
||||
|
||||
{/* Impact Number */}
|
||||
<text
|
||||
y="3.5"
|
||||
y="3.6"
|
||||
textAnchor="middle"
|
||||
fill="#ffffff"
|
||||
fontSize={dotR > 10 ? '9.5' : '8.5'}
|
||||
fontWeight="900"
|
||||
fontSize={r > 11 ? '10.5' : '9.5'}
|
||||
fontWeight="700"
|
||||
fill="#fff"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
{toPersianDigits(item.impactScore)}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Floating Hover Tooltip Card (Bento Light Style) */}
|
||||
{hoveredItem && (
|
||||
{hovered && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-50 bg-white border border-slate-200 p-4 rounded-2xl shadow-xl max-w-xs text-right animate-fadeIn"
|
||||
className="pointer-events-none absolute z-50 w-60 rounded-lg border bg-popover p-3 text-right shadow-xl animate-in fade-in zoom-in-95 duration-150"
|
||||
style={{
|
||||
top: Math.max(10, hoveredItem.y - 120),
|
||||
left: Math.max(10, Math.min(size - 220, hoveredItem.x - 90))
|
||||
top: `${(hovered.y / size) * 100}%`,
|
||||
left: `${(1 - hovered.x / size) * 100}%`,
|
||||
transform: 'translate(-50%, calc(-100% - 16px))'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${getCategoryColor(hoveredItem.item.category)}18`,
|
||||
color: getCategoryColor(hoveredItem.item.category)
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-[10.5px] font-semibold"
|
||||
style={{ color: CATEGORY_META[hovered.item.category].color }}
|
||||
>
|
||||
{getCategoryLabelFa(hoveredItem.item.category)}
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{ background: CATEGORY_META[hovered.item.category].color }}
|
||||
/>
|
||||
{CATEGORY_META[hovered.item.category].label}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-slate-400">
|
||||
{getHorizonLabelFa(hoveredItem.item.horizon)}
|
||||
<span className="text-[10.5px] text-muted-foreground">
|
||||
{getHorizonLabelFa(hovered.item.horizon)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h5 className="text-slate-900 font-extrabold text-xs leading-snug mb-1">
|
||||
{hoveredItem.item.title}
|
||||
</h5>
|
||||
|
||||
<p className="text-slate-500 text-[11px] line-clamp-2 leading-relaxed mb-2">
|
||||
{hoveredItem.item.whyItMatters}
|
||||
<p className="text-[12px] font-bold leading-snug">{hovered.item.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{hovered.item.whyItMatters}
|
||||
</p>
|
||||
<p className="tnum mt-2 border-t pt-1.5 text-[10.5px] text-muted-foreground">
|
||||
شدت اثر {toPersianDigits(hovered.item.impactScore)} از ۵
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] pt-1.5 border-t border-slate-100 font-bold">
|
||||
<span className="text-teal-600 flex items-center gap-1">
|
||||
<Zap size={11} />
|
||||
شدت اثر: {toPersianDigits(hoveredItem.item.impactScore)} از ۵
|
||||
</span>
|
||||
<span className="text-slate-400 flex items-center gap-0.5">
|
||||
کلیک جهت مشاهده پرونده ↗
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
import React from 'react';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import {
|
||||
WEF_HEADLINES,
|
||||
GROWING_ROLES,
|
||||
DECLINING_ROLES,
|
||||
NEWLY_DECLINING,
|
||||
RISING_SKILLS,
|
||||
DECLINING_SKILLS,
|
||||
BEST_MAJORS,
|
||||
WORST_MAJORS,
|
||||
GRADUATE_CONTEXT,
|
||||
PAY_PARADOX,
|
||||
SOURCE_WEF,
|
||||
SOURCE_NYFED,
|
||||
type GlobalRole,
|
||||
type SourceRef
|
||||
} from '../../data/globalLaborMarket';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ExternalLink, TrendingUp, TrendingDown, Info } from 'lucide-react';
|
||||
|
||||
/** Renders a figure's provenance under the block it belongs to. */
|
||||
const Source: React.FC<{ source: SourceRef }> = ({ source }) => (
|
||||
<p className="mt-3 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 border-t pt-2.5 text-[10.5px] text-muted-foreground">
|
||||
<span>منبع:</span>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
{source.label}
|
||||
<ExternalLink className="size-2.5" />
|
||||
</a>
|
||||
<span>· {source.asOf}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
/**
|
||||
* Diverging bar: growth runs one way from the centre line, decline the other.
|
||||
* Both sides share a scale so a +40% and a −40% bar are the same length.
|
||||
*/
|
||||
const DivergingRow: React.FC<{
|
||||
role: GlobalRole;
|
||||
scale: number;
|
||||
direction: 'up' | 'down';
|
||||
}> = ({ role, scale, direction }) => {
|
||||
const pct = role.changePercent;
|
||||
const known = pct !== null;
|
||||
const width = known ? (Math.abs(pct) / scale) * 100 : 0;
|
||||
const color = direction === 'up' ? 'var(--positive)' : 'var(--negative)';
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2.5 py-1.5">
|
||||
<span className="tnum w-4 shrink-0 text-[10.5px] text-muted-foreground">
|
||||
{toPersianDigits(role.rank)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-[12px]" title={role.title}>
|
||||
{role.title}
|
||||
</span>
|
||||
|
||||
<span className="flex h-4 w-[38%] shrink-0 items-center">
|
||||
{known ? (
|
||||
<span
|
||||
className="h-1.5 rounded-full transition-[width] duration-700"
|
||||
style={{ width: `${width}%`, background: color, minWidth: 4 }}
|
||||
/>
|
||||
) : (
|
||||
// The source ranks this role but publishes no figure — say so
|
||||
// rather than inventing a bar.
|
||||
<span
|
||||
className="h-1.5 w-full rounded-full opacity-40"
|
||||
style={{
|
||||
background: `repeating-linear-gradient(90deg, ${color} 0 3px, transparent 3px 7px)`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="tnum w-11 shrink-0 text-left text-[11.5px] font-semibold"
|
||||
style={{ color: known ? color : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{known ? `${pct > 0 ? '+' : '−'}${toPersianDigits(Math.abs(pct))}٪` : '—'}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const MajorRow: React.FC<{
|
||||
title: string;
|
||||
value: number;
|
||||
scale: number;
|
||||
color: string;
|
||||
}> = ({ title, value, scale, color }) => (
|
||||
<li className="flex items-center gap-2.5 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px]">{title}</span>
|
||||
<span className="flex h-4 w-[42%] shrink-0 items-center">
|
||||
<span
|
||||
className="h-1.5 rounded-full transition-[width] duration-700"
|
||||
style={{ width: `${(value / scale) * 100}%`, background: color, minWidth: 4 }}
|
||||
/>
|
||||
</span>
|
||||
<span className="tnum w-11 shrink-0 text-left text-[11.5px] font-semibold" style={{ color }}>
|
||||
{toPersianDigits(value.toFixed(1))}٪
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
export const GlobalOutlook: React.FC = () => {
|
||||
const roleScale = Math.max(
|
||||
...GROWING_ROLES.map((r) => Math.abs(r.changePercent ?? 0)),
|
||||
...DECLINING_ROLES.map((r) => Math.abs(r.changePercent ?? 0))
|
||||
);
|
||||
const majorScale = Math.max(...WORST_MAJORS.map((m) => m.unemployment));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Headline figures */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{WEF_HEADLINES.map((stat) => (
|
||||
<Card data-card key={stat.key} className="gap-0 p-3.5">
|
||||
<span className="text-[11.5px] text-muted-foreground">{stat.label}</span>
|
||||
<span
|
||||
className="kpi-number mt-1.5 text-xl font-bold"
|
||||
style={{
|
||||
color:
|
||||
stat.tone === 'positive'
|
||||
? 'var(--positive)'
|
||||
: stat.tone === 'negative'
|
||||
? 'var(--negative)'
|
||||
: 'var(--foreground)'
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10.5px] text-muted-foreground">{stat.note}</span>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Occupations */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-[14px]">
|
||||
<TrendingUp className="size-4" style={{ color: 'var(--positive)' }} />
|
||||
پرشتابترین مشاغل رو به رشد جهان
|
||||
</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
تغییر پیشبینیشده تعداد شاغلان، ۲۰۲۵ تا ۲۰۳۰.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ul className="divide-y">
|
||||
{GROWING_ROLES.map((role) => (
|
||||
<DivergingRow
|
||||
key={role.title}
|
||||
role={role}
|
||||
scale={roleScale}
|
||||
direction="up"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<Source source={SOURCE_WEF} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-[14px]">
|
||||
<TrendingDown className="size-4" style={{ color: 'var(--negative)' }} />
|
||||
پرشتابترین مشاغل رو به کاهش جهان
|
||||
</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
رتبهبندی گزارش؛ درصد فقط برای سه مورد نخست منتشر شده است.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ul className="divide-y">
|
||||
{DECLINING_ROLES.map((role) => (
|
||||
<DivergingRow
|
||||
key={role.title}
|
||||
role={role}
|
||||
scale={roleScale}
|
||||
direction="down"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-3 flex items-start gap-2 rounded-lg border bg-muted/40 p-2.5">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
برای نخستین بار{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{NEWLY_DECLINING.join(' و ')}
|
||||
</span>{' '}
|
||||
وارد فهرست مشاغل رو به کاهش شدهاند؛ نشانه رسیدن هوش مصنوعی مولد به
|
||||
کارهای دانشی.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Source source={SOURCE_WEF} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Majors */}
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">
|
||||
نرخ بیکاری فارغالتحصیلان تازه، بر حسب رشته
|
||||
</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
فارغالتحصیلان ۲۲ تا ۲۷ ساله، در {toPersianDigits(GRADUATE_CONTEXT.majorsTracked)}{' '}
|
||||
رشته. میانگین کل: بیکاری {GRADUATE_CONTEXT.unemployment} و اشتغال
|
||||
نامتناسب {GRADUATE_CONTEXT.underemployment}.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-1 text-[12.5px] font-semibold" style={{ color: 'var(--positive)' }}>
|
||||
کمترین بیکاری
|
||||
</h4>
|
||||
<ul className="divide-y">
|
||||
{BEST_MAJORS.map((m) => (
|
||||
<MajorRow
|
||||
key={m.title}
|
||||
title={m.title}
|
||||
value={m.unemployment}
|
||||
scale={majorScale}
|
||||
color="var(--positive)"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-1 text-[12.5px] font-semibold" style={{ color: 'var(--negative)' }}>
|
||||
بیشترین بیکاری
|
||||
</h4>
|
||||
<ul className="divide-y">
|
||||
{WORST_MAJORS.map((m) => (
|
||||
<MajorRow
|
||||
key={m.title}
|
||||
title={m.title}
|
||||
value={m.unemployment}
|
||||
scale={majorScale}
|
||||
color="var(--negative)"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The finding that matters for curriculum planning. */}
|
||||
<div className="mt-4 rounded-lg border p-3.5" style={{ background: 'var(--muted)' }}>
|
||||
<h4 className="mb-1.5 text-[12.5px] font-semibold">
|
||||
نکتهای که برای بازنگری رشتهها اهمیت دارد
|
||||
</h4>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
پردرآمدترین رشته،{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{PAY_PARADOX.topPayingMajor}
|
||||
</span>{' '}
|
||||
با درآمد میانه{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{PAY_PARADOX.topPayingWage}
|
||||
</span>
|
||||
، همزمان یکی از بالاترین نرخهای بیکاری را دارد (
|
||||
<span className="tnum font-semibold" style={{ color: 'var(--negative)' }}>
|
||||
{toPersianDigits(PAY_PARADOX.topPayingUnemployment)}٪
|
||||
</span>
|
||||
). در مقابل، کمریسکترین رشتهها — عمدتاً تعلیم و تربیت — کمترین درآمد
|
||||
اولیه را دارند. امنیت شغلی و درآمد در دادههای امسال همجهت نیستند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Source source={SOURCE_NYFED} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">مهارتهای رو به رشد تا ۲۰۳۰</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
به ترتیب اهمیتی که کارفرمایان اعلام کردهاند.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-3.5">
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{RISING_SKILLS.map((skill, i) => (
|
||||
<li key={skill.title} className="flex items-center gap-2.5">
|
||||
<span className="tnum w-4 shrink-0 text-[10.5px] text-muted-foreground">
|
||||
{toPersianDigits(i + 1)}
|
||||
</span>
|
||||
<span className="flex-1 text-[12.5px]">{skill.title}</span>
|
||||
<span className="shrink-0 rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
|
||||
{skill.kind}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Source source={SOURCE_WEF} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">مهارتهای رو به کاهش</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
مهارتهایی که کارفرمایان کاهش اهمیت آنها را پیشبینی میکنند.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-3.5">
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{DECLINING_SKILLS.map((skill) => (
|
||||
<li key={skill.title} className="rounded-lg border bg-muted/40 p-2.5">
|
||||
<div className="text-[12.5px] font-medium">{skill.title}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{skill.note}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Source source={SOURCE_WEF} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,131 +4,67 @@ import { toPersianDigits } from '../../utils/persianNumbers';
|
|||
import {
|
||||
GraduationCap,
|
||||
Microscope,
|
||||
Cpu,
|
||||
Settings2,
|
||||
Users,
|
||||
UserCog,
|
||||
Briefcase,
|
||||
Target,
|
||||
Sparkles
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ImpactGaugesProps {
|
||||
impact: ImpactAssessment;
|
||||
}
|
||||
|
||||
interface DimensionConfig {
|
||||
const AREAS: {
|
||||
key: keyof ImpactAssessment;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{ key: 'education', title: 'آموزش و برنامه درسی', icon: GraduationCap },
|
||||
{ key: 'research', title: 'پژوهش', icon: Microscope },
|
||||
{ key: 'operations', title: 'فرآیندهای اداری', icon: Settings2 },
|
||||
{ key: 'students', title: 'دانشجویان', icon: Users },
|
||||
{ key: 'faculty', title: 'اعضای هیئت علمی', icon: UserCog },
|
||||
{ key: 'laborMarket', title: 'بازار کار', icon: Briefcase },
|
||||
{ key: 'strategicImportance', title: 'اهمیت راهبردی', icon: Target }
|
||||
];
|
||||
|
||||
export const ImpactGauges: React.FC<ImpactGaugesProps> = ({ impact }) => {
|
||||
const dimensions: DimensionConfig[] = [
|
||||
{
|
||||
key: 'education',
|
||||
title: 'آموزش و برنامههای درسی',
|
||||
desc: 'میزان دگرگونی در شیوههای تدریس، یادگیری و امتحانات',
|
||||
icon: <GraduationCap size={16} />,
|
||||
color: '#38bdf8'
|
||||
},
|
||||
{
|
||||
key: 'research',
|
||||
title: 'پژوهش و تولید علم',
|
||||
desc: 'تأثیر بر سرعت تحقیقات، روششناسی و آزمایشگاهها',
|
||||
icon: <Microscope size={16} />,
|
||||
color: '#a855f7'
|
||||
},
|
||||
{
|
||||
key: 'operations',
|
||||
title: 'عملیات و فرآیندهای اداری',
|
||||
desc: 'تغییر در خدمات دانشجویی، مالی و مدیریت پردیس',
|
||||
icon: <Cpu size={16} />,
|
||||
color: '#00d4aa'
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'تجربه و آینده دانشجویان',
|
||||
desc: 'اثر بر مهارتها، انگیزه، سلامت روان و هویت دانشجو',
|
||||
icon: <Users size={16} />,
|
||||
color: '#fbbf24'
|
||||
},
|
||||
{
|
||||
key: 'faculty',
|
||||
title: 'اعضای هیئت علمی',
|
||||
desc: 'تحول در نقش استاد، بار کاری و معیارهای ارتقا',
|
||||
icon: <Sparkles size={16} />,
|
||||
color: '#ec4899'
|
||||
},
|
||||
{
|
||||
key: 'laborMarket',
|
||||
title: 'بازار کار و اشتغالپذیری',
|
||||
desc: 'تغییر در تقاضای کارفرمایان و مهارتهای شغلی موردنیاز',
|
||||
icon: <Briefcase size={16} />,
|
||||
color: '#f97316'
|
||||
},
|
||||
{
|
||||
key: 'strategicImportance',
|
||||
title: 'اهمیت راهبردی برای دانشگاه اصفهان',
|
||||
desc: 'وزن در تصمیمگیری هیئت رئیسه و اولویتبندی بودجه',
|
||||
icon: <Target size={16} />,
|
||||
color: '#f43f5e'
|
||||
}
|
||||
];
|
||||
const MAX = 5;
|
||||
|
||||
/**
|
||||
* Seven areas on one shared 1–5 scale. A segmented meter rather than a bar,
|
||||
* so the reader can count the score without reading the number.
|
||||
*/
|
||||
export const ImpactGauges: React.FC<ImpactGaugesProps> = ({ impact }) => (
|
||||
<ul className="divide-y">
|
||||
{AREAS.map((area) => {
|
||||
const score = impact[area.key] || 1;
|
||||
const Icon = area.icon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{dimensions.map((dim) => {
|
||||
const score = impact[dim.key] || 1;
|
||||
const percent = (score / 5) * 100;
|
||||
<li key={area.key} className="flex items-center gap-3 py-2.5">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" strokeWidth={1.9} />
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dim.key}
|
||||
className="bg-[#0b1429] border border-slate-800/80 rounded-xl p-3.5 flex flex-col gap-2 hover:border-slate-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-slate-200">
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px]">{area.title}</span>
|
||||
|
||||
<span className="flex shrink-0 items-center gap-1" aria-hidden>
|
||||
{Array.from({ length: MAX }, (_, i) => (
|
||||
<span
|
||||
className="p-1.5 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${dim.color}1a`, color: dim.color }}
|
||||
>
|
||||
{dim.icon}
|
||||
</span>
|
||||
<span>{dim.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="font-extrabold text-white text-sm">
|
||||
{toPersianDigits(score)}
|
||||
</span>
|
||||
<span className="text-slate-500 text-[11px]">از ۵</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400">{dim.desc}</p>
|
||||
|
||||
{/* Score Bar */}
|
||||
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
key={i}
|
||||
className="h-1.5 w-4 rounded-full transition-colors"
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
backgroundColor: dim.color
|
||||
background: i < score ? 'var(--primary)' : 'var(--muted)'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
{/* 5 Dots Indicator */}
|
||||
<div className="flex items-center justify-between px-1 text-[9px] text-slate-500">
|
||||
<span>خیلی کم (۱)</span>
|
||||
<span>کم (۲)</span>
|
||||
<span>متوسط (۳)</span>
|
||||
<span>زیاد (۴)</span>
|
||||
<span>بسیار زیاد (۵)</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="tnum w-8 shrink-0 text-left text-[12px] font-semibold">
|
||||
{toPersianDigits(score)}
|
||||
<span className="text-muted-foreground">/۵</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</ul>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
JobEntity,
|
||||
SkillEntity
|
||||
} from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { CATEGORY_META } from '../../utils/categories';
|
||||
import { HORIZON_END } from '../../utils/horizon';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, ArrowLeft, MousePointerClick } from 'lucide-react';
|
||||
|
||||
interface ImpactNetworkProps {
|
||||
items: IntelligenceItem[];
|
||||
dimensions: University1415Dimension[];
|
||||
jobs: JobEntity[];
|
||||
skills: SkillEntity[];
|
||||
onOpenItem: (item: IntelligenceItem) => void;
|
||||
onOpenDimension: (dim: University1415Dimension) => void;
|
||||
}
|
||||
|
||||
type Layer = 'item' | 'skill' | 'job' | 'dimension';
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
layer: Layer;
|
||||
label: string;
|
||||
color: string;
|
||||
angle: number;
|
||||
radius: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const LAYERS: { key: Layer; label: string; ratio: number }[] = [
|
||||
{ key: 'dimension', label: `ابعاد ${toPersianDigits(HORIZON_END)}`, ratio: 0.32 },
|
||||
{ key: 'job', label: 'مشاغل', ratio: 0.56 },
|
||||
{ key: 'skill', label: 'مهارتها', ratio: 0.79 },
|
||||
{ key: 'item', label: 'پیشرانها', ratio: 1 }
|
||||
];
|
||||
|
||||
const LAYER_COLOR: Record<Layer, string> = {
|
||||
dimension: 'var(--primary)',
|
||||
job: 'var(--cat-policy)',
|
||||
skill: 'var(--cat-trend)',
|
||||
item: 'var(--muted-foreground)'
|
||||
};
|
||||
|
||||
const LAYER_LABEL: Record<Layer, string> = {
|
||||
dimension: `ابعاد ${toPersianDigits(HORIZON_END)}`,
|
||||
job: 'مشاغل',
|
||||
skill: 'مهارتها',
|
||||
item: 'پیشرانها'
|
||||
};
|
||||
|
||||
/** Quadratic curve bowed toward the centre, so links read as flowing inward. */
|
||||
const curve = (ax: number, ay: number, bx: number, by: number, cx: number, cy: number) => {
|
||||
const mx = (ax + bx) / 2;
|
||||
const my = (ay + by) / 2;
|
||||
const qx = mx + (cx - mx) * 0.33;
|
||||
const qy = my + (cy - my) * 0.33;
|
||||
return `M ${ax} ${ay} Q ${qx} ${qy} ${bx} ${by}`;
|
||||
};
|
||||
|
||||
export const ImpactNetwork: React.FC<ImpactNetworkProps> = ({
|
||||
items,
|
||||
dimensions,
|
||||
jobs,
|
||||
skills,
|
||||
onOpenItem,
|
||||
onOpenDimension
|
||||
}) => {
|
||||
/** Pinned by click — survives the pointer leaving, so touch works. */
|
||||
const [pinned, setPinned] = useState<string | null>(null);
|
||||
/** Transient hover, desktop only. */
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
const active = hovered ?? pinned;
|
||||
|
||||
const size = 860;
|
||||
const center = size / 2;
|
||||
const maxRadius = size / 2 - 108;
|
||||
|
||||
const { nodes, links, nodeById, neighbours } = useMemo(() => {
|
||||
const links: Link[] = [];
|
||||
const dimIndex = new Map(dimensions.map((d, i) => [d.id, i]));
|
||||
|
||||
const usedSkills = new Set<string>();
|
||||
const usedJobs = new Set<string>();
|
||||
const usedDims = new Set<string>();
|
||||
const usedItems = new Set<string>();
|
||||
|
||||
// Which dimension each entity ultimately feeds. Used purely for ordering:
|
||||
// placing related nodes at similar angles turns a hairball into spokes.
|
||||
const anchor = new Map<string, number>();
|
||||
|
||||
for (const item of items) {
|
||||
const s = (item.relatedSkillIds ?? []).filter((id) => skills.some((x) => x.id === id));
|
||||
const j = (item.relatedJobIds ?? []).filter((id) => jobs.some((x) => x.id === id));
|
||||
const d = (item.relatedDimensionIds ?? []).filter((id) =>
|
||||
dimensions.some((x) => x.id === id)
|
||||
);
|
||||
if (!s.length && !j.length && !d.length) continue;
|
||||
|
||||
usedItems.add(item.id);
|
||||
const primary = d.length ? (dimIndex.get(d[0]) ?? 99) : 99;
|
||||
if (!anchor.has(item.id)) anchor.set(item.id, primary);
|
||||
|
||||
s.forEach((id) => {
|
||||
usedSkills.add(id);
|
||||
if (!anchor.has(id)) anchor.set(id, primary);
|
||||
links.push({ from: item.id, to: id });
|
||||
});
|
||||
j.forEach((id) => {
|
||||
usedJobs.add(id);
|
||||
if (!anchor.has(id)) anchor.set(id, primary);
|
||||
links.push({ from: item.id, to: id });
|
||||
});
|
||||
d.forEach((id) => {
|
||||
usedDims.add(id);
|
||||
anchor.set(id, dimIndex.get(id) ?? 99);
|
||||
links.push({ from: item.id, to: id });
|
||||
});
|
||||
}
|
||||
|
||||
const byAnchor = (a: { id: string }, b: { id: string }) =>
|
||||
(anchor.get(a.id) ?? 99) - (anchor.get(b.id) ?? 99) || a.id.localeCompare(b.id);
|
||||
|
||||
const built: Node[] = [];
|
||||
|
||||
const place = (
|
||||
layer: Layer,
|
||||
entries: { id: string; label: string; color: string; size: number }[]
|
||||
) => {
|
||||
const ratio = LAYERS.find((l) => l.key === layer)!.ratio;
|
||||
const radius = maxRadius * ratio;
|
||||
const n = entries.length || 1;
|
||||
// Leave a wedge at due west free for the ring labels.
|
||||
const arc = 2 * Math.PI - 0.34;
|
||||
entries.forEach((entry, i) => {
|
||||
const angle = -Math.PI / 2 + 0.17 + (arc * i) / n;
|
||||
built.push({
|
||||
...entry,
|
||||
layer,
|
||||
angle,
|
||||
radius,
|
||||
x: center + radius * Math.cos(angle),
|
||||
y: center + radius * Math.sin(angle)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
place(
|
||||
'dimension',
|
||||
dimensions
|
||||
.filter((d) => usedDims.has(d.id))
|
||||
.sort(byAnchor)
|
||||
.map((d) => ({ id: d.id, label: d.shortTag, color: d.pillarColor, size: 7 }))
|
||||
);
|
||||
place(
|
||||
'job',
|
||||
jobs
|
||||
.filter((j) => usedJobs.has(j.id))
|
||||
.sort(byAnchor)
|
||||
.map((j) => ({ id: j.id, label: j.title, color: LAYER_COLOR.job, size: 4.5 }))
|
||||
);
|
||||
place(
|
||||
'skill',
|
||||
skills
|
||||
.filter((s) => usedSkills.has(s.id))
|
||||
.sort(byAnchor)
|
||||
.map((s) => ({ id: s.id, label: s.title, color: LAYER_COLOR.skill, size: 4.5 }))
|
||||
);
|
||||
place(
|
||||
'item',
|
||||
items
|
||||
.filter((i) => usedItems.has(i.id))
|
||||
.sort(byAnchor)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.title,
|
||||
color: CATEGORY_META[i.category].color,
|
||||
size: 3.5 + i.impactScore * 0.9
|
||||
}))
|
||||
);
|
||||
|
||||
const nodeById = new Map(built.map((n) => [n.id, n]));
|
||||
|
||||
const neighbours = new Map<string, Set<string>>();
|
||||
const connect = (a: string, b: string) => {
|
||||
if (!neighbours.has(a)) neighbours.set(a, new Set());
|
||||
neighbours.get(a)!.add(b);
|
||||
};
|
||||
for (const l of links) {
|
||||
connect(l.from, l.to);
|
||||
connect(l.to, l.from);
|
||||
}
|
||||
|
||||
return { nodes: built, links, nodeById, neighbours };
|
||||
}, [items, dimensions, jobs, skills, center, maxRadius]);
|
||||
|
||||
const activeNode = active ? nodeById.get(active) : null;
|
||||
const related = active ? [...(neighbours.get(active) ?? [])] : [];
|
||||
const relatedSet = new Set(related);
|
||||
|
||||
const isLit = (id: string) => !active || id === active || relatedSet.has(id);
|
||||
const linkLit = (l: Link) => !active || l.from === active || l.to === active;
|
||||
|
||||
/** Labels drawn on the canvas: the active node plus everything it touches. */
|
||||
const labelled = activeNode
|
||||
? [activeNode, ...related.map((id) => nodeById.get(id)!).filter(Boolean)]
|
||||
: [];
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const out: Record<Layer, Node[]> = { item: [], skill: [], job: [], dimension: [] };
|
||||
related.forEach((id) => {
|
||||
const n = nodeById.get(id);
|
||||
if (n) out[n.layer].push(n);
|
||||
});
|
||||
return out;
|
||||
}, [related, nodeById]);
|
||||
|
||||
const openActive = () => {
|
||||
if (!activeNode) return;
|
||||
if (activeNode.layer === 'item') {
|
||||
const item = items.find((i) => i.id === activeNode.id);
|
||||
if (item) onOpenItem(item);
|
||||
} else if (activeNode.layer === 'dimension') {
|
||||
const dim = dimensions.find((d) => d.id === activeNode.id);
|
||||
if (dim) onOpenDimension(dim);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start">
|
||||
<div className="relative min-w-0 flex-1 select-none">
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="h-auto w-full overflow-visible"
|
||||
role="img"
|
||||
aria-label="شبکه روابط: از پیشران بیرونی تا شکاف قابلیت دانشگاه"
|
||||
onClick={() => setPinned(null)}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="netCore" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.14" />
|
||||
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx={center} cy={center} r={maxRadius * 0.44} fill="url(#netCore)" />
|
||||
|
||||
{/* Rings. Labels sit in the reserved wedge at due west. */}
|
||||
{LAYERS.map((layer) => {
|
||||
const r = maxRadius * layer.ratio;
|
||||
return (
|
||||
<g key={layer.key}>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="2 7"
|
||||
/>
|
||||
<rect
|
||||
x={center - r - 40}
|
||||
y={center - 9}
|
||||
width="80"
|
||||
height="18"
|
||||
rx="5"
|
||||
fill="var(--card)"
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<text
|
||||
x={center - r}
|
||||
y={center + 4}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontWeight="600"
|
||||
fill="var(--muted-foreground)"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
{layer.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Links */}
|
||||
<g fill="none">
|
||||
{links.map((l, i) => {
|
||||
const a = nodeById.get(l.from);
|
||||
const b = nodeById.get(l.to);
|
||||
if (!a || !b) return null;
|
||||
const lit = linkLit(l);
|
||||
return (
|
||||
<path
|
||||
key={`${l.from}-${l.to}-${i}`}
|
||||
d={curve(a.x, a.y, b.x, b.y, center, center)}
|
||||
stroke={lit ? a.color : 'var(--border)'}
|
||||
strokeWidth={active && lit ? 1.8 : 0.7}
|
||||
strokeOpacity={lit ? (active ? 0.9 : 0.14) : 0.04}
|
||||
className="net-link"
|
||||
style={{
|
||||
animationDelay: `${Math.min(i * 3, 600)}ms`,
|
||||
transition: 'stroke-opacity 200ms ease, stroke-width 200ms ease'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Nodes */}
|
||||
{nodes.map((node, i) => {
|
||||
const lit = isLit(node.id);
|
||||
const isActive = active === node.id;
|
||||
const isPinned = pinned === node.id;
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className="net-node cursor-pointer"
|
||||
style={{ animationDelay: `${Math.min(i * 5, 700)}ms` }}
|
||||
onMouseEnter={() => setHovered(node.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onFocus={() => setHovered(node.id)}
|
||||
onBlur={() => setHovered(null)}
|
||||
onClick={(e) => {
|
||||
// Selecting a node never navigates — it reveals its chain.
|
||||
e.stopPropagation();
|
||||
setHovered(null);
|
||||
setPinned((cur) => (cur === node.id ? null : node.id));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setPinned((cur) => (cur === node.id ? null : node.id));
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-pressed={isPinned}
|
||||
aria-label={`${node.label} — ${LAYER_LABEL[node.layer]}`}
|
||||
>
|
||||
{isActive && (
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.size + 9}
|
||||
fill={node.color}
|
||||
fillOpacity="0.2"
|
||||
className="anim-pulse"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={isActive ? node.size + 2.5 : node.size}
|
||||
fill={node.color}
|
||||
fillOpacity={lit ? 1 : 0.14}
|
||||
stroke={isPinned ? 'var(--foreground)' : 'var(--card)'}
|
||||
strokeWidth={isPinned ? 2.5 : 1.5}
|
||||
style={{ transition: 'fill-opacity 200ms ease' }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Names for the active node and everything it touches. */}
|
||||
{labelled.map((node) => {
|
||||
const outward = node.layer === 'item' ? 1 : -1;
|
||||
const dist = node.radius + outward * 16;
|
||||
const lx = center + dist * Math.cos(node.angle);
|
||||
const ly = center + dist * Math.sin(node.angle);
|
||||
const onLeft = Math.cos(node.angle) < 0;
|
||||
const text =
|
||||
node.label.length > 26 ? `${node.label.slice(0, 25)}…` : node.label;
|
||||
const w = text.length * 6.2 + 14;
|
||||
|
||||
return (
|
||||
<g key={`lbl-${node.id}`} className="pointer-events-none net-label">
|
||||
<rect
|
||||
x={onLeft ? lx - w : lx}
|
||||
y={ly - 9}
|
||||
width={w}
|
||||
height="18"
|
||||
rx="5"
|
||||
fill="var(--popover)"
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<text
|
||||
x={onLeft ? lx - w / 2 : lx + w / 2}
|
||||
y={ly + 4}
|
||||
textAnchor="middle"
|
||||
fontSize="10.5"
|
||||
fontWeight={node.id === active ? 700 : 500}
|
||||
fill="var(--popover-foreground)"
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Centre */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={32}
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<text
|
||||
x={center}
|
||||
y={center + 4}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontWeight="700"
|
||||
fill="var(--primary)"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
دانشگاه
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-[11.5px] text-muted-foreground">
|
||||
{LAYERS.slice()
|
||||
.reverse()
|
||||
.map((l) => (
|
||||
<span key={l.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ background: LAYER_COLOR[l.key] }}
|
||||
/>
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-page detail — nothing here navigates unless you ask it to. */}
|
||||
<aside className="w-full shrink-0 xl:w-72">
|
||||
{!activeNode ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-center">
|
||||
<MousePointerClick className="size-5 text-muted-foreground" />
|
||||
<p className="text-[12.5px] font-medium">یک گره را انتخاب کنید</p>
|
||||
<p className="text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
با انتخاب هر گره، زنجیره آن روشن میشود و نام موارد مرتبط همینجا
|
||||
نمایش داده میشود.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border animate-in fade-in slide-in-from-bottom-1 duration-200">
|
||||
<div className="flex items-start justify-between gap-2 border-b p-3.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: activeNode.color }}
|
||||
/>
|
||||
<span className="text-[10.5px] text-muted-foreground">
|
||||
{LAYER_LABEL[activeNode.layer]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] font-bold leading-snug">
|
||||
{activeNode.label}
|
||||
</p>
|
||||
<p className="tnum mt-1 text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(related.length)} پیوند
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pinned && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label="بستن"
|
||||
onClick={() => setPinned(null)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[26rem] overflow-y-auto p-3.5">
|
||||
{(['dimension', 'job', 'skill', 'item'] as Layer[]).map((layer) => {
|
||||
const list = grouped[layer];
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<div key={layer} className="mb-3 last:mb-0">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
|
||||
<span
|
||||
className="inline-block size-1.5 rounded-full"
|
||||
style={{ background: LAYER_COLOR[layer] }}
|
||||
/>
|
||||
{LAYER_LABEL[layer]}
|
||||
<span className="tnum opacity-70">
|
||||
{toPersianDigits(list.length)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{list.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
onClick={() => setPinned(n.id)}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="w-full rounded-md px-2 py-1 text-right text-[12px] leading-relaxed transition-colors hover:bg-muted"
|
||||
>
|
||||
{n.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(activeNode.layer === 'item' || activeNode.layer === 'dimension') && (
|
||||
<div className="border-t p-3">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={openActive}>
|
||||
باز کردن پرونده کامل
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,158 +1,179 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { JobEntity, SkillEntity } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { TrendingUp, AlertTriangle, Brain, Sparkles, Code, Users } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Brain, Code, Users, Wrench } from 'lucide-react';
|
||||
|
||||
interface LaborChartsProps {
|
||||
jobs: JobEntity[];
|
||||
skills: SkillEntity[];
|
||||
}
|
||||
|
||||
const SKILL_CATEGORIES = [
|
||||
{ key: 'cognitive', title: 'شناختی', icon: Brain },
|
||||
{ key: 'digital', title: 'دیجیتال', icon: Code },
|
||||
{ key: 'social', title: 'اجتماعی', icon: Users },
|
||||
{ key: 'technical', title: 'فنی', icon: Wrench }
|
||||
] as const;
|
||||
|
||||
/** Horizontal bar with the value written at the end of the row. */
|
||||
const BarRow: React.FC<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
value: string;
|
||||
ratio: number;
|
||||
color: string;
|
||||
}> = ({ title, subtitle, value, ratio, color }) => (
|
||||
<li className="py-2">
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||
<span className="truncate text-[12.5px] font-semibold">{title}</span>
|
||||
<span className="tnum shrink-0 text-[12px] font-bold" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${Math.min(100, ratio)}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{subtitle}</p>
|
||||
</li>
|
||||
);
|
||||
|
||||
export const LaborCharts: React.FC<LaborChartsProps> = ({ jobs, skills }) => {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'skills'>('jobs');
|
||||
const [tab, setTab] = useState<'jobs' | 'skills'>('jobs');
|
||||
|
||||
const growingJobs = jobs.filter((j) => j.type === 'growing').slice(0, 8);
|
||||
const transformingJobs = jobs.filter((j) => j.type === 'transforming').slice(0, 8);
|
||||
const topGrowing = useMemo(
|
||||
() =>
|
||||
jobs
|
||||
.filter((j) => j.type === 'growing')
|
||||
.sort((a, b) => b.growthRatePercent - a.growthRatePercent)
|
||||
.slice(0, 6),
|
||||
[jobs]
|
||||
);
|
||||
|
||||
const skillCategories = [
|
||||
{ key: 'cognitive', title: 'شناختی و تحلیلی', icon: <Brain size={15} />, color: '#8b5cf6', count: 10 },
|
||||
{ key: 'digital', title: 'دیجیتال و هوش مصنوعی', icon: <Code size={15} />, color: '#00b894', count: 12 },
|
||||
{ key: 'social', title: 'اجتماعی و انسانی', icon: <Users size={15} />, color: '#f59e0b', count: 8 },
|
||||
{ key: 'technical', title: 'فنی و مهندسی', icon: <Sparkles size={15} />, color: '#0284c7', count: 6 }
|
||||
];
|
||||
const topAtRisk = useMemo(
|
||||
() =>
|
||||
jobs
|
||||
.filter((j) => j.type === 'transforming')
|
||||
.sort((a, b) => b.automationRiskPercent - a.automationRiskPercent)
|
||||
.slice(0, 6),
|
||||
[jobs]
|
||||
);
|
||||
|
||||
// Counted from the data rather than hard-coded, so it stays true as the
|
||||
// dataset grows.
|
||||
const skillBreakdown = useMemo(
|
||||
() =>
|
||||
SKILL_CATEGORIES.map((cat) => {
|
||||
const matching = skills.filter((s) => s.category === cat.key);
|
||||
return {
|
||||
...cat,
|
||||
total: matching.length,
|
||||
growing: matching.filter((s) => s.type === 'growing').length
|
||||
};
|
||||
}),
|
||||
[skills]
|
||||
);
|
||||
|
||||
const maxGrowth = topGrowing[0]?.growthRatePercent || 1;
|
||||
const maxSkillCount = Math.max(1, ...skillBreakdown.map((c) => c.total));
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-5 animate-fadeIn">
|
||||
{/* Tab Switcher (Pill Style) */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-200 pb-3">
|
||||
<div className="flex items-center bg-slate-100 p-1 rounded-full text-xs shadow-sm">
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`py-1.5 px-4 rounded-full font-bold flex items-center gap-1.5 transition-all ${
|
||||
activeTab === 'jobs'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp size={14} />
|
||||
نقشه رشد و ریسک اتوماسیون مشاغل
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('skills')}
|
||||
className={`py-1.5 px-4 rounded-full font-bold flex items-center gap-1.5 transition-all ${
|
||||
activeTab === 'skills'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Brain size={14} />
|
||||
توزیع شایستگیهای نوظهور و افول
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="jobs">مشاغل</TabsTrigger>
|
||||
<TabsTrigger value="skills">مهارتها</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<span className="text-slate-400 text-xs font-semibold hidden md:inline">
|
||||
تحلیل انطباق با زیستبوم صنعتی و فناوری استان اصفهان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeTab === 'jobs' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 animate-fadeIn">
|
||||
{/* Top Growing Jobs Ranking */}
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h4 className="text-teal-700 font-extrabold text-sm flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-teal-600" />
|
||||
بیشترین نرخ رشد تقاضای شغلی (افق تا ۱۴۱۵)
|
||||
</h4>
|
||||
<span className="badge badge-teal">رشد تصاعدی</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 mt-1">
|
||||
{growingJobs.map((j) => (
|
||||
<div key={j.id} className="bg-slate-50 p-3 rounded-2xl border border-slate-200 text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-extrabold text-slate-900">{j.title}</span>
|
||||
<span className="font-bold text-teal-700 font-mono text-[13px]">
|
||||
+{toPersianDigits(j.growthRatePercent)}٪
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 font-medium">
|
||||
<span>رشته مرتبط: {j.discipline}</span>
|
||||
<span className="text-slate-400">
|
||||
ریسک اتوماسیون: {toPersianDigits(j.automationRiskPercent)}٪
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-200 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, j.growthRatePercent * 1.8)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'jobs' && (
|
||||
<div className="mt-3 grid animate-in grid-cols-1 gap-3 fade-in duration-200 lg:grid-cols-2">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">بیشترین رشد تقاضا</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
نرخ رشد پیشبینیشده تا ۱۴۱۵
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1">
|
||||
<ul className="divide-y">
|
||||
{topGrowing.map((j) => (
|
||||
<BarRow
|
||||
key={j.id}
|
||||
title={j.title}
|
||||
subtitle={j.discipline}
|
||||
value={`+${toPersianDigits(j.growthRatePercent)}٪`}
|
||||
ratio={(j.growthRatePercent / maxGrowth) * 100}
|
||||
color="var(--positive)"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Transforming Jobs & Automation Vulnerability */}
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h4 className="text-rose-700 font-extrabold text-sm flex items-center gap-2">
|
||||
<AlertTriangle size={16} className="text-rose-600" />
|
||||
بیشترین ریسک اتوماسیون و نیاز به بازآفرینی
|
||||
</h4>
|
||||
<span className="badge badge-rose">در معرض تغییر</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 mt-1">
|
||||
{transformingJobs.map((j) => (
|
||||
<div key={j.id} className="bg-slate-50 p-3 rounded-2xl border border-slate-200 text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-extrabold text-slate-900">{j.title}</span>
|
||||
<span className="font-bold text-rose-700 font-mono text-[13px]">
|
||||
{toPersianDigits(j.automationRiskPercent)}٪ خطر اتوماسیون
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 font-medium">
|
||||
<span>حوزه: {j.discipline}</span>
|
||||
<span className="text-amber-700 font-bold">نیاز مبرم به بازآموزی</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-200 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-rose-500 rounded-full"
|
||||
style={{ width: `${j.automationRiskPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">بیشترین ریسک اتوماسیون</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
سهم وظایف قابل خودکارسازی
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1">
|
||||
<ul className="divide-y">
|
||||
{topAtRisk.map((j) => (
|
||||
<BarRow
|
||||
key={j.id}
|
||||
title={j.title}
|
||||
subtitle={j.discipline}
|
||||
value={`${toPersianDigits(j.automationRiskPercent)}٪`}
|
||||
ratio={j.automationRiskPercent}
|
||||
color="var(--negative)"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'skills' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 animate-fadeIn">
|
||||
{skillCategories.map((cat) => (
|
||||
<div key={cat.key} className="intel-card bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col justify-between gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-900">{cat.title}</span>
|
||||
<span
|
||||
className="p-2 rounded-xl"
|
||||
style={{ backgroundColor: `${cat.color}15`, color: cat.color }}
|
||||
>
|
||||
{cat.icon}
|
||||
{tab === 'skills' && (
|
||||
<div className="mt-3 grid animate-in grid-cols-2 gap-3 fade-in duration-200 lg:grid-cols-4">
|
||||
{skillBreakdown.map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
return (
|
||||
<Card data-card key={cat.key} className="gap-0 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
{cat.title}
|
||||
</span>
|
||||
<Icon className="size-4 text-muted-foreground/70" strokeWidth={1.9} />
|
||||
</div>
|
||||
<div className="my-1">
|
||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(cat.count)}
|
||||
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="kpi-number text-2xl font-bold">
|
||||
{toPersianDigits(cat.total)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 mr-1 font-bold">مهارت کلیدی</span>
|
||||
<span className="text-[11px] text-muted-foreground">مهارت</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 border-t border-slate-100 pt-2 font-medium">
|
||||
تأکید در کوریکولوم دانشگاه اصفهان
|
||||
</span>
|
||||
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${(cat.total / maxSkillCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="tnum mt-1.5 text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(cat.growing)} مورد رو به رشد
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||
import { University1415Dimension } from '../../types/futures';
|
||||
import { getSpiderCoordinates, pointsToSvgPath } from '../../utils/radarMath';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { HORIZON_END } from '../../utils/horizon';
|
||||
|
||||
interface SpiderChartProps {
|
||||
dimensions: University1415Dimension[];
|
||||
|
|
@ -9,89 +10,90 @@ interface SpiderChartProps {
|
|||
height?: number;
|
||||
}
|
||||
|
||||
const GRID_LEVELS = [2, 4, 6, 8, 10];
|
||||
const MAX_SCORE = 10;
|
||||
|
||||
/**
|
||||
* Current state vs. the ۱۴۱۵ target across the ten characteristics.
|
||||
* The gap between the two rings is the whole point of the chart, so the
|
||||
* target is drawn as an outline and the current state as the filled shape —
|
||||
* the unfilled band between them *is* the shortfall.
|
||||
*/
|
||||
export const SpiderChart: React.FC<SpiderChartProps> = ({
|
||||
dimensions,
|
||||
onSelectDimension,
|
||||
height = 580
|
||||
height = 560
|
||||
}) => {
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
const size = 640;
|
||||
const center = size / 2;
|
||||
const maxRadius = size / 2 - 70;
|
||||
const numAxes = dimensions.length; // 10
|
||||
const maxRadius = size / 2 - 76;
|
||||
const axes = dimensions.length;
|
||||
|
||||
const currentScores = dimensions.map((d) => d.currentScore);
|
||||
const targetScores = dimensions.map((d) => d.targetScore);
|
||||
if (!axes) return null;
|
||||
|
||||
const currentPoints = getSpiderCoordinates(currentScores, 10, center, maxRadius);
|
||||
const targetPoints = getSpiderCoordinates(targetScores, 10, center, maxRadius);
|
||||
|
||||
const currentPath = pointsToSvgPath(currentPoints);
|
||||
const targetPath = pointsToSvgPath(targetPoints);
|
||||
|
||||
// Concentric polygon grids (2, 4, 6, 8, 10)
|
||||
const gridLevels = [2, 4, 6, 8, 10];
|
||||
const currentPoints = getSpiderCoordinates(
|
||||
dimensions.map((d) => d.currentScore),
|
||||
MAX_SCORE,
|
||||
center,
|
||||
maxRadius
|
||||
);
|
||||
const targetPoints = getSpiderCoordinates(
|
||||
dimensions.map((d) => d.targetScore),
|
||||
MAX_SCORE,
|
||||
center,
|
||||
maxRadius
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col items-center justify-center select-none" style={{ minHeight: height }}>
|
||||
<div
|
||||
className="relative flex w-full select-none flex-col items-center"
|
||||
style={{ minHeight: height }}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="w-full max-w-[620px] h-auto drop-shadow-2xl"
|
||||
style={{ overflow: 'visible' }}
|
||||
className="h-auto w-full max-w-[600px]"
|
||||
role="img"
|
||||
aria-label={`نمودار عنکبوتی شکاف وضع موجود و هدف ${toPersianDigits(HORIZON_END)}`}
|
||||
>
|
||||
<defs>
|
||||
{/* Gradients */}
|
||||
<linearGradient id="spiderTargetGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.38" />
|
||||
<stop offset="100%" stopColor="#0284c7" stopOpacity="0.22" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="spiderCurrentGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Concentric Web Grids */}
|
||||
{gridLevels.map((lvl) => {
|
||||
const gridValues = Array(numAxes).fill(lvl);
|
||||
const pts = getSpiderCoordinates(gridValues, 10, center, maxRadius);
|
||||
const path = pointsToSvgPath(pts);
|
||||
{/* Web */}
|
||||
{GRID_LEVELS.map((level) => {
|
||||
const path = pointsToSvgPath(
|
||||
getSpiderCoordinates(Array(axes).fill(level), MAX_SCORE, center, maxRadius)
|
||||
);
|
||||
const isOuter = level === MAX_SCORE;
|
||||
return (
|
||||
<g key={lvl}>
|
||||
<g key={level}>
|
||||
<path
|
||||
d={path}
|
||||
fill={lvl === 10 ? 'rgba(241, 245, 249, 0.5)' : 'none'}
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth={lvl === 10 ? '1.5' : '1'}
|
||||
strokeDasharray={lvl === 10 ? 'none' : '3,3'}
|
||||
fill={isOuter ? 'var(--muted)' : 'none'}
|
||||
fillOpacity={isOuter ? 0.4 : 0}
|
||||
stroke="var(--border)"
|
||||
strokeDasharray={isOuter ? 'none' : '3 4'}
|
||||
/>
|
||||
<text
|
||||
x={center + 5}
|
||||
y={center - (lvl / 10) * maxRadius + 11}
|
||||
fill="#94a3b8"
|
||||
x={center + 6}
|
||||
y={center - (level / MAX_SCORE) * maxRadius + 11}
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
opacity="0.7"
|
||||
>
|
||||
{toPersianDigits(lvl)}
|
||||
{toPersianDigits(level)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Radial Axis Lines */}
|
||||
{/* Axes + labels */}
|
||||
{dimensions.map((dim, idx) => {
|
||||
const angle = (2 * Math.PI * idx) / numAxes - Math.PI / 2;
|
||||
const angle = (2 * Math.PI * idx) / axes - Math.PI / 2;
|
||||
const endX = center + maxRadius * Math.cos(angle);
|
||||
const endY = center + maxRadius * Math.sin(angle);
|
||||
|
||||
// Label positions (slightly outside maxRadius)
|
||||
const labelDist = maxRadius + 36;
|
||||
const labelDist = maxRadius + 34;
|
||||
const lx = center + labelDist * Math.cos(angle);
|
||||
const ly = center + labelDist * Math.sin(angle);
|
||||
|
||||
const isHovered = hoveredIdx === idx;
|
||||
const isHovered = hovered === idx;
|
||||
|
||||
return (
|
||||
<g key={dim.id}>
|
||||
|
|
@ -100,35 +102,30 @@ export const SpiderChart: React.FC<SpiderChartProps> = ({
|
|||
y1={center}
|
||||
x2={endX}
|
||||
y2={endY}
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="1.2"
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
|
||||
{/* Interactive Label on Axis End */}
|
||||
<g
|
||||
transform={`translate(${lx}, ${ly})`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectDimension && onSelectDimension(dim)}
|
||||
onMouseEnter={() => setHoveredIdx(idx)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
onClick={() => onSelectDimension?.(dim)}
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
<rect
|
||||
x="-50"
|
||||
y="-12"
|
||||
width="100"
|
||||
height="24"
|
||||
rx="8"
|
||||
fill={isHovered ? '#e6f7f3' : '#ffffff'}
|
||||
stroke={isHovered ? '#00b894' : '#cbd5e1'}
|
||||
strokeWidth="1.2"
|
||||
filter="drop-shadow(0 1px 3px rgba(0,0,0,0.06))"
|
||||
x="-46"
|
||||
y="-11"
|
||||
width="92"
|
||||
height="22"
|
||||
rx="6"
|
||||
fill={isHovered ? 'var(--primary)' : 'var(--card)'}
|
||||
stroke={isHovered ? 'var(--primary)' : 'var(--border)'}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y="4"
|
||||
fill={isHovered ? '#008f73' : '#1e293b'}
|
||||
fontSize="9.5"
|
||||
fontWeight={isHovered ? '900' : '700'}
|
||||
fontSize="10"
|
||||
fontWeight="600"
|
||||
fill={isHovered ? 'var(--primary-foreground)' : 'var(--foreground)'}
|
||||
>
|
||||
{dim.shortTag}
|
||||
</text>
|
||||
|
|
@ -137,104 +134,96 @@ export const SpiderChart: React.FC<SpiderChartProps> = ({
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Target 1415 Polygon (دانشگاه اصفهان ۱۴۱۵) */}
|
||||
{/* Target ring — outline only. */}
|
||||
<path
|
||||
d={targetPath}
|
||||
fill="url(#spiderTargetGrad)"
|
||||
stroke="#00b894"
|
||||
strokeWidth="2.5"
|
||||
className="transition-all duration-300"
|
||||
d={pointsToSvgPath(targetPoints)}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 4"
|
||||
/>
|
||||
|
||||
{/* Current State Polygon (وضع موجود) */}
|
||||
{/* Current state — filled, so the shortfall reads as empty space. */}
|
||||
<path
|
||||
d={currentPath}
|
||||
fill="url(#spiderCurrentGrad)"
|
||||
stroke="#f59e0b"
|
||||
d={pointsToSvgPath(currentPoints)}
|
||||
fill="var(--primary)"
|
||||
fillOpacity="0.16"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2.5"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Target 1415 Vertex Dots */}
|
||||
{targetPoints.map((pt, idx) => (
|
||||
<circle
|
||||
key={`target-pt-${idx}`}
|
||||
key={`t-${idx}`}
|
||||
cx={pt.x}
|
||||
cy={pt.y}
|
||||
r="4.5"
|
||||
fill="#00b894"
|
||||
stroke="#ffffff"
|
||||
r="3.5"
|
||||
fill="var(--card)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
className="hover:scale-150 transition-transform cursor-pointer"
|
||||
onMouseEnter={() => setHoveredIdx(idx)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Current State Vertex Dots */}
|
||||
{currentPoints.map((pt, idx) => (
|
||||
<circle
|
||||
key={`curr-pt-${idx}`}
|
||||
key={`c-${idx}`}
|
||||
cx={pt.x}
|
||||
cy={pt.y}
|
||||
r="4.5"
|
||||
fill="#f59e0b"
|
||||
stroke="#ffffff"
|
||||
r="4"
|
||||
fill="var(--primary)"
|
||||
stroke="var(--card)"
|
||||
strokeWidth="2"
|
||||
className="hover:scale-150 transition-transform cursor-pointer"
|
||||
onMouseEnter={() => setHoveredIdx(idx)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Center Hub Marker */}
|
||||
<circle cx={center} cy={center} r="4" fill="#64748b" />
|
||||
</svg>
|
||||
|
||||
{/* Interactive Hover Tooltip (Light Bento Card) */}
|
||||
{hoveredIdx !== null && (
|
||||
<div className="absolute top-4 left-4 z-40 bg-white border border-slate-200 p-4 rounded-2xl shadow-xl max-w-xs text-right animate-fadeIn">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-black text-slate-900">{dimensions[hoveredIdx].title}</span>
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${dimensions[hoveredIdx].pillarColor}15`,
|
||||
color: dimensions[hoveredIdx].pillarColor
|
||||
}}
|
||||
>
|
||||
آمادگی: {toPersianDigits(dimensions[hoveredIdx].readinessPercentage)}٪
|
||||
{hovered !== null && (
|
||||
<div className="absolute start-0 top-0 z-40 max-w-[17rem] rounded-lg border bg-popover p-3 text-right shadow-lg animate-in fade-in zoom-in-95 duration-150">
|
||||
<p className="text-[13px] font-bold">{dimensions[hovered].title}</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{dimensions[hovered].titleEn}
|
||||
</p>
|
||||
|
||||
<div className="tnum mt-2 flex items-center justify-between gap-2 rounded-md border bg-muted/50 px-2.5 py-1.5 text-[11.5px]">
|
||||
<span>
|
||||
موجود{' '}
|
||||
<b className="font-semibold">
|
||||
{toPersianDigits(dimensions[hovered].currentScore)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="text-primary">
|
||||
هدف{' '}
|
||||
<b className="font-semibold">
|
||||
{toPersianDigits(dimensions[hovered].targetScore)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="text-negative">
|
||||
شکاف{' '}
|
||||
<b className="font-semibold">{toPersianDigits(dimensions[hovered].gap)}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs my-2 bg-slate-50 p-2.5 rounded-xl border border-slate-100 font-bold">
|
||||
<span className="text-amber-600 font-bold">
|
||||
وضع موجود: {toPersianDigits(dimensions[hoveredIdx].currentScore)}
|
||||
</span>
|
||||
<span className="text-teal-600 font-bold">
|
||||
هدف ۱۴۱۵: {toPersianDigits(dimensions[hoveredIdx].targetScore)}
|
||||
</span>
|
||||
<span className="text-rose-600 font-extrabold">
|
||||
شکاف: {toPersianDigits(dimensions[hoveredIdx].gap)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 line-clamp-2 leading-relaxed">
|
||||
{dimensions[hoveredIdx].vision}
|
||||
<p className="mt-2 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{dimensions[hovered].vision}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spider Chart Legend */}
|
||||
<div className="flex items-center gap-6 mt-4 text-xs font-bold">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-teal-500 border border-white shadow-sm"></span>
|
||||
<span className="text-slate-800">هدف دانشگاه اصفهان ۱۴۱۵ (وضع مطلوب)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-amber-500 border border-white shadow-sm"></span>
|
||||
<span className="text-slate-800">وضعیت موجود دانشگاه (مبنای سنجش)</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[12px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-4 rounded-sm bg-primary/25 ring-1 ring-primary" />
|
||||
وضع موجود
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-0 w-4 border-t-2 border-dashed border-primary" />
|
||||
هدف {toPersianDigits(HORIZON_END)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import React, { useState } from 'react';
|
||||
import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import { ArrowLeft, GitFork, Cpu, GraduationCap, Briefcase, Target } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
JobEntity,
|
||||
SkillEntity
|
||||
} from '../../types/futures';
|
||||
import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
|
||||
import { CATEGORY_META } from '../../utils/categories';
|
||||
import { HORIZON_END } from '../../utils/horizon';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ChevronLeft, Cpu, Brain, Briefcase, Target } from 'lucide-react';
|
||||
|
||||
interface StrategicGraphProps {
|
||||
items: IntelligenceItem[];
|
||||
|
|
@ -11,6 +20,13 @@ interface StrategicGraphProps {
|
|||
onSelectEntity?: (type: string, id: string) => void;
|
||||
}
|
||||
|
||||
type Chain = {
|
||||
item: IntelligenceItem;
|
||||
skill?: SkillEntity;
|
||||
job?: JobEntity;
|
||||
dimension?: University1415Dimension;
|
||||
};
|
||||
|
||||
export const StrategicGraph: React.FC<StrategicGraphProps> = ({
|
||||
items,
|
||||
dimensions,
|
||||
|
|
@ -18,221 +34,183 @@ export const StrategicGraph: React.FC<StrategicGraphProps> = ({
|
|||
skills,
|
||||
onSelectEntity
|
||||
}) => {
|
||||
// Pre-configured end-to-end strategic disruption pathways for demonstration
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'sc-1',
|
||||
title: 'زنجیره هوش مصنوعی مولد و دانشگاه هوشمند (AI-Native Disruption)',
|
||||
tech: items.find((i) => i.id === 'tc-1') || items[0],
|
||||
trend: items.find((i) => i.id === 'tr-1') || items[0],
|
||||
skill: skills.find((s) => s.id === 'sk-1') || skills[0],
|
||||
job: jobs.find((j) => j.id === 'job-1') || jobs[0],
|
||||
dimension: dimensions.find((d) => d.id === 'dim-1') || dimensions[0],
|
||||
gapAnalysis: 'شکاف ۵.۳ نمرهای در آمادگی زیرساخت هوش مصنوعی و نیاز به ۳۰ سرور پردازش گرافیکی بومی.'
|
||||
},
|
||||
{
|
||||
id: 'sc-2',
|
||||
title: 'زنجیره بحران آب، فرونشست زمین و دانشگاه مسئلهمحور (Water & Subsidence Mission)',
|
||||
tech: items.find((i) => i.id === 'tc-12') || items[1],
|
||||
trend: items.find((i) => i.id === 'tr-10') || items[1],
|
||||
skill: skills.find((s) => s.id === 'sk-4') || skills[1],
|
||||
job: jobs.find((j) => j.id === 'job-16') || jobs[1],
|
||||
dimension: dimensions.find((d) => d.id === 'dim-10') || dimensions[1],
|
||||
gapAnalysis: 'شکاف ۴.۴ نمرهای در پژوهشهای مأموریتگرا و فقدان آزمایشگاه متمرکز پایش فرونشست.'
|
||||
},
|
||||
{
|
||||
id: 'sc-3',
|
||||
title: 'زنجیره ریزمدرکها، دورههای صنعتی Co-op و دانشگاه مهارتمحور (Skills & Co-op Pathway)',
|
||||
tech: items.find((i) => i.id === 'tc-5') || items[2],
|
||||
trend: items.find((i) => i.id === 'tr-2') || items[2],
|
||||
skill: skills.find((s) => s.id === 'sk-8') || skills[2],
|
||||
job: jobs.find((j) => j.id === 'job-2') || jobs[2],
|
||||
dimension: dimensions.find((d) => d.id === 'dim-3') || dimensions[2],
|
||||
gapAnalysis: 'شکاف ۴.۶ نمرهای در آموزشهای مبتنی بر شایستگی و مقاومت در حذف دروس تئوری غیرکاربردی.'
|
||||
}
|
||||
];
|
||||
// Chains are built from the links stored on each item, not hard-coded, so
|
||||
// they stay correct when the dataset changes.
|
||||
const chains: Chain[] = useMemo(() => {
|
||||
return items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.relatedDimensionIds?.length &&
|
||||
(item.relatedSkillIds?.length || item.relatedJobIds?.length)
|
||||
)
|
||||
.sort((a, b) => b.impactScore - a.impactScore)
|
||||
.slice(0, 6)
|
||||
.map((item) => ({
|
||||
item,
|
||||
skill: skills.find((s) => item.relatedSkillIds?.includes(s.id)),
|
||||
job: jobs.find((j) => item.relatedJobIds?.includes(j.id)),
|
||||
dimension: dimensions.find((d) => item.relatedDimensionIds?.includes(d.id))
|
||||
}));
|
||||
}, [items, skills, jobs, dimensions]);
|
||||
|
||||
const [activeScenarioIdx, setActiveScenarioIdx] = useState(0);
|
||||
const activeScenario = scenarios[activeScenarioIdx];
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const active = chains[activeIdx];
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<p className="py-12 text-center text-[13px] text-muted-foreground">
|
||||
زنجیرهای برای نمایش وجود ندارد.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = CATEGORY_META[active.item.category];
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{/* Scenario Selector Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 bg-[#0d162a] border border-slate-800 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-white font-bold text-sm">
|
||||
<GitFork size={18} className="text-teal-400" />
|
||||
<span>زنجیره ارزش تحول راهبردی:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scenarios.map((sc, idx) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Chain picker */}
|
||||
<div className="scroll-x">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{chains.map((chain, idx) => (
|
||||
<button
|
||||
key={sc.id}
|
||||
onClick={() => setActiveScenarioIdx(idx)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||||
activeScenarioIdx === idx
|
||||
? 'bg-teal-500 text-slate-950 shadow-md shadow-teal-500/20 font-bold'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
key={chain.item.id}
|
||||
onClick={() => setActiveIdx(idx)}
|
||||
className={`inline-flex max-w-[16rem] shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
activeIdx === idx
|
||||
? 'border-transparent bg-foreground text-background'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{sc.title.split(' ')[1]} {sc.title.split(' ')[2]}
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{
|
||||
background:
|
||||
activeIdx === idx
|
||||
? 'currentColor'
|
||||
: CATEGORY_META[chain.item.category].color
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{chain.item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disruption Chain Visual Flow */}
|
||||
<div className="intel-card border-slate-700/80 p-6 lg:p-8 flex flex-col gap-6">
|
||||
<div className="border-b border-slate-800 pb-3">
|
||||
<h3 className="text-white font-black text-base lg:text-lg">
|
||||
{activeScenario.title}
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
ردگیری مسیر تبدیل سیگنال/فناوری بیرونی به شکاف قابلیت در دانشگاه اصفهان
|
||||
</p>
|
||||
</div>
|
||||
{/* The chain itself */}
|
||||
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ChainStep
|
||||
step={1}
|
||||
label="پیشران"
|
||||
icon={Cpu}
|
||||
color={meta.color}
|
||||
title={active.item.title}
|
||||
body={active.item.executiveSummary}
|
||||
footer={`${meta.label} · ${getHorizonShortFa(active.item.horizon)} · اثر ${toPersianDigits(
|
||||
active.item.impactScore
|
||||
)}/۵`}
|
||||
onClick={() => onSelectEntity?.('item', active.item.id)}
|
||||
/>
|
||||
|
||||
{/* Linear Step Flow Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 relative">
|
||||
{/* Step 1: Technology / Signal */}
|
||||
<div
|
||||
className="bg-[#091122] border border-teal-500/40 p-4 rounded-xl flex flex-col justify-between cursor-pointer hover:border-teal-400 transition-all"
|
||||
onClick={() => onSelectEntity && onSelectEntity('item', activeScenario.tech.id)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-teal-400 font-bold mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu size={13} />
|
||||
۱. پیشران فناورانه
|
||||
</span>
|
||||
<span className="badge badge-teal">محرک بیرونی</span>
|
||||
</div>
|
||||
<h5 className="text-white font-bold text-xs leading-snug mb-1">
|
||||
{activeScenario.tech.title}
|
||||
</h5>
|
||||
<p className="text-slate-400 text-[11px] line-clamp-3">
|
||||
{activeScenario.tech.executiveSummary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 mt-3 border-t border-slate-800/80 text-[10px] text-slate-500">
|
||||
افق: {toPersianDigits(activeScenario.tech.horizon)} | تأثیر: {toPersianDigits(activeScenario.tech.impactScore)} از ۵
|
||||
</div>
|
||||
</div>
|
||||
<ChainStep
|
||||
step={2}
|
||||
label="مهارت موردنیاز"
|
||||
icon={Brain}
|
||||
color="var(--cat-trend)"
|
||||
title={active.skill?.title ?? '—'}
|
||||
body={active.skill?.description ?? 'مهارت مرتبطی ثبت نشده است.'}
|
||||
footer={
|
||||
active.skill
|
||||
? `اهمیت ${toPersianDigits(active.skill.importanceScore)}/۵`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Step 2: Educational Disruption */}
|
||||
<div
|
||||
className="bg-[#091122] border border-sky-500/40 p-4 rounded-xl flex flex-col justify-between cursor-pointer hover:border-sky-400 transition-all"
|
||||
onClick={() => onSelectEntity && onSelectEntity('item', activeScenario.trend.id)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-sky-400 font-bold mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap size={13} />
|
||||
۲. اختلال آموزشی و پژوهشی
|
||||
</span>
|
||||
<span className="badge badge-blue">روند تغییر</span>
|
||||
</div>
|
||||
<h5 className="text-white font-bold text-xs leading-snug mb-1">
|
||||
{activeScenario.trend.title}
|
||||
</h5>
|
||||
<p className="text-slate-400 text-[11px] line-clamp-3">
|
||||
{activeScenario.trend.whyItMatters}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 mt-3 border-t border-slate-800/80 text-[10px] text-slate-500">
|
||||
تغییر مدل آموزش و ارزشیابی
|
||||
</div>
|
||||
</div>
|
||||
<ChainStep
|
||||
step={3}
|
||||
label="شغل متأثر"
|
||||
icon={Briefcase}
|
||||
color="var(--cat-policy)"
|
||||
title={active.job?.title ?? '—'}
|
||||
body={active.job?.description ?? 'شغل مرتبطی ثبت نشده است.'}
|
||||
footer={active.job?.discipline}
|
||||
/>
|
||||
|
||||
{/* Step 3: Required Skills */}
|
||||
<div className="bg-[#091122] border border-purple-500/40 p-4 rounded-xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-purple-400 font-bold mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowLeft size={13} />
|
||||
۳. شایستگی نوظهور
|
||||
</span>
|
||||
<span className="badge badge-purple">مهارت الزامی</span>
|
||||
</div>
|
||||
<h5 className="text-white font-bold text-xs leading-snug mb-1">
|
||||
{activeScenario.skill.title}
|
||||
</h5>
|
||||
<p className="text-slate-400 text-[11px] line-clamp-3">
|
||||
{activeScenario.skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 mt-3 border-t border-slate-800/80 text-[10px] text-slate-500">
|
||||
رده: {activeScenario.skill.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Transformed Jobs */}
|
||||
<div className="bg-[#091122] border border-amber-500/40 p-4 rounded-xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-amber-400 font-bold mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Briefcase size={13} />
|
||||
۴. بازار کار آینده
|
||||
</span>
|
||||
<span className="badge badge-amber">مشاغل هدف</span>
|
||||
</div>
|
||||
<h5 className="text-white font-bold text-xs leading-snug mb-1">
|
||||
{activeScenario.job.title}
|
||||
</h5>
|
||||
<p className="text-slate-400 text-[11px] line-clamp-3">
|
||||
{activeScenario.job.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 mt-3 border-t border-slate-800/80 text-[10px] text-slate-500">
|
||||
رشته: {activeScenario.job.discipline}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 5: University 1415 Capability Gap */}
|
||||
<div
|
||||
className="bg-[#091122] border-2 border-rose-500/60 p-4 rounded-xl flex flex-col justify-between cursor-pointer hover:border-rose-400 transition-all bg-rose-950/10"
|
||||
onClick={() => onSelectEntity && onSelectEntity('dimension', activeScenario.dimension.id)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-rose-400 font-bold mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Target size={13} />
|
||||
۵. شکاف قابلیت ۱۴۱۵
|
||||
</span>
|
||||
<span className="badge badge-rose">نقطه مداخله</span>
|
||||
</div>
|
||||
<h5 className="text-white font-bold text-xs leading-snug mb-1">
|
||||
{activeScenario.dimension.title}
|
||||
</h5>
|
||||
<p className="text-rose-200/90 text-[11px] font-medium leading-relaxed">
|
||||
{activeScenario.gapAnalysis}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 mt-3 border-t border-slate-800/80 flex items-center justify-between text-[11px] text-rose-300 font-bold">
|
||||
<span>وضع موجود: {toPersianDigits(activeScenario.dimension.currentScore)}</span>
|
||||
<span>هدف: {toPersianDigits(activeScenario.dimension.targetScore)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategic Takeaway Memo Box */}
|
||||
<div className="bg-[#0c1833] border border-teal-500/30 rounded-xl p-4 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-teal-400 font-bold block mb-0.5">
|
||||
نتیجهگیری سیاستی برای هیئت رئیسه دانشگاه اصفهان:
|
||||
</span>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
این پیشران بیرونی در صورت انفعال دانشگاه، به تهدیدی برای تعطیلی گرایشهای مرتبط و افت جذب دانشجو تبدیل خواهد شد؛ اما با بازنگری هدفمند سرفصلها و تجهیز آزمایشگاهها میتواند به مزیت رقابتی دانشگاه اصفهان در منطقه مرکزی تبدیل شود.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelectEntity && onSelectEntity('dimension', activeScenario.dimension.id)}
|
||||
className="btn btn-primary text-xs py-2 px-3.5 self-end md:self-auto shrink-0"
|
||||
>
|
||||
مشاهده برنامه اقدام بعد ۱۴۱۵
|
||||
</button>
|
||||
</div>
|
||||
<ChainStep
|
||||
step={4}
|
||||
label={`شکاف قابلیت ${toPersianDigits(HORIZON_END)}`}
|
||||
icon={Target}
|
||||
color="var(--negative)"
|
||||
title={active.dimension?.title ?? '—'}
|
||||
body={active.dimension?.vision ?? 'بعد مرتبطی ثبت نشده است.'}
|
||||
footer={
|
||||
active.dimension
|
||||
? `موجود ${toPersianDigits(
|
||||
active.dimension.currentScore
|
||||
)} · هدف ${toPersianDigits(
|
||||
active.dimension.targetScore
|
||||
)} · شکاف ${toPersianDigits(active.dimension.gap)}`
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
active.dimension
|
||||
? () => onSelectEntity?.('dimension', active.dimension!.id)
|
||||
: undefined
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChainStep: React.FC<{
|
||||
step: number;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
title: string;
|
||||
body: string;
|
||||
footer?: string;
|
||||
onClick?: () => void;
|
||||
isLast?: boolean;
|
||||
}> = ({ step, label, icon: Icon, color, title, body, footer, onClick, isLast }) => (
|
||||
<div className="relative flex">
|
||||
<Card
|
||||
data-card
|
||||
onClick={onClick}
|
||||
className={`flex w-full flex-col gap-0 p-3.5 transition-[box-shadow,border-color,transform] duration-200 ${
|
||||
onClick ? 'cursor-pointer hover:border-foreground/15 hover:shadow-md' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0" style={{ color }} strokeWidth={2} />
|
||||
<span className="text-[11px] font-semibold" style={{ color }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="tnum ms-auto text-[10.5px] text-muted-foreground">
|
||||
{toPersianDigits(step)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-[12.5px] font-bold leading-relaxed">{title}</h3>
|
||||
|
||||
<p className="mt-1.5 line-clamp-3 flex-1 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
{body}
|
||||
</p>
|
||||
|
||||
{footer && (
|
||||
<p className="tnum mt-2.5 border-t pt-2 text-[10.5px] text-muted-foreground">
|
||||
{footer}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Connector arrow — RTL, so the flow runs right to left. */}
|
||||
{!isLast && (
|
||||
<ChevronLeft
|
||||
aria-hidden
|
||||
className="absolute -start-2 top-1/2 hidden size-4 -translate-y-1/2 text-muted-foreground/50 xl:block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,404 +1,248 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { FuturesForce, ForceType } from '../../types/futures';
|
||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||
import {
|
||||
Compass,
|
||||
ArrowUpRight,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TriangleViewProps {
|
||||
forces: FuturesForce[];
|
||||
onSelectForce?: (force: FuturesForce) => void;
|
||||
}
|
||||
|
||||
/** The three vertices, worded and ordered as in the brief. */
|
||||
const GROUPS: {
|
||||
type: ForceType;
|
||||
label: string;
|
||||
latin: string;
|
||||
hint: string;
|
||||
color: string;
|
||||
soft: string;
|
||||
}[] = [
|
||||
{
|
||||
type: 'pull',
|
||||
label: 'کشش آینده',
|
||||
latin: 'PULL',
|
||||
hint: 'تصویر مطلوبی که دانشگاه را به جلو میکشد.',
|
||||
color: 'var(--cat-tech)',
|
||||
soft: 'var(--cat-tech-soft)'
|
||||
},
|
||||
{
|
||||
type: 'push',
|
||||
label: 'فشارهای حال',
|
||||
latin: 'PUSH',
|
||||
hint: 'نیروهایی که همین امروز تغییر را تحمیل میکنند.',
|
||||
color: 'var(--cat-trend)',
|
||||
soft: 'var(--cat-trend-soft)'
|
||||
},
|
||||
{
|
||||
type: 'weight',
|
||||
label: 'وزن گذشته',
|
||||
latin: 'WEIGHT',
|
||||
hint: 'ساختارها و عادتهایی که حرکت را کند میکنند.',
|
||||
color: 'var(--cat-policy)',
|
||||
soft: 'var(--cat-policy-soft)'
|
||||
}
|
||||
];
|
||||
|
||||
// Triangle geometry in a 640×470 viewBox.
|
||||
const APEX = { x: 320, y: 46 };
|
||||
const RIGHT = { x: 588, y: 414 };
|
||||
const LEFT = { x: 52, y: 414 };
|
||||
|
||||
export const TriangleView: React.FC<TriangleViewProps> = ({ forces, onSelectForce }) => {
|
||||
const [selectedForce, setSelectedForce] = useState<FuturesForce>(forces[0]);
|
||||
const [activeTab, setActiveTab] = useState<ForceType>('pull');
|
||||
const [selected, setSelected] = useState<FuturesForce | null>(forces[0] ?? null);
|
||||
|
||||
const pullForces = forces.filter((f) => f.forceType === 'pull');
|
||||
const pushForces = forces.filter((f) => f.forceType === 'push');
|
||||
const weightForces = forces.filter((f) => f.forceType === 'weight');
|
||||
const grouped = useMemo(
|
||||
() => ({
|
||||
pull: forces.filter((f) => f.forceType === 'pull'),
|
||||
push: forces.filter((f) => f.forceType === 'push'),
|
||||
weight: forces.filter((f) => f.forceType === 'weight')
|
||||
}),
|
||||
[forces]
|
||||
);
|
||||
|
||||
const handleForceClick = (f: FuturesForce) => {
|
||||
setSelectedForce(f);
|
||||
setActiveTab(f.forceType);
|
||||
if (onSelectForce) onSelectForce(f);
|
||||
const strength = (type: ForceType) => {
|
||||
const list = grouped[type];
|
||||
if (!list.length) return 0;
|
||||
return list.reduce((sum, f) => sum + f.strength, 0) / list.length;
|
||||
};
|
||||
|
||||
// Sleek Equilateral Geometry in 800x560 coordinate space
|
||||
// Base: 650 - 150 = 500, Height: 480 - 50 = 430 (Proportion ~0.86, perfectly sleek)
|
||||
const apex = { x: 400, y: 50 };
|
||||
const rightCorner = { x: 650, y: 480 };
|
||||
const leftCorner = { x: 150, y: 480 };
|
||||
const centroid = { x: 400, y: 336 };
|
||||
const pick = (force: FuturesForce) => {
|
||||
setSelected(force);
|
||||
onSelectForce?.(force);
|
||||
};
|
||||
|
||||
if (!forces.length) return null;
|
||||
|
||||
const selectedGroup = selected
|
||||
? GROUPS.find((g) => g.type === selected.forceType)!
|
||||
: GROUPS[0];
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-6 animate-fadeIn">
|
||||
{/* Visual Triangle Composition Canvas */}
|
||||
<div className="relative w-full bg-white border border-slate-200 rounded-3xl p-6 lg:p-8 shadow-bento flex flex-col items-center">
|
||||
{/* Subtle Ambient Background Gradients */}
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 w-[400px] h-[200px] bg-purple-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div className="absolute bottom-12 right-12 w-64 h-64 bg-sky-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div className="absolute bottom-12 left-12 w-64 h-64 bg-rose-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
{/* 1. TOP VERTEX APEX CLUSTER: PULL OF THE FUTURE (کشش آینده) */}
|
||||
<div className="w-full flex flex-col items-center mb-3 z-10">
|
||||
<div className="flex items-center gap-2 bg-purple-50 border border-purple-200 px-4 py-1.5 rounded-full shadow-sm text-purple-950 font-black text-xs">
|
||||
<Sparkles size={16} className="text-purple-600" />
|
||||
<span>کشش آینده — تصویر آرمانی دانشگاه</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 font-semibold mt-1">
|
||||
جاذبهها، چشماندازهای نو و الگوهای مطلوب آینده
|
||||
</span>
|
||||
|
||||
{/* Pull Force Pills */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2.5 max-w-xl">
|
||||
{pullForces.map((f) => {
|
||||
const isSelected = selectedForce.id === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => handleForceClick(f)}
|
||||
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
|
||||
isSelected
|
||||
? 'bg-slate-900 text-white shadow-md scale-105'
|
||||
: 'bg-white text-slate-800 hover:bg-purple-50 hover:text-purple-900 border border-purple-200'
|
||||
}`}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card data-card>
|
||||
<CardContent className="p-3 sm:p-6">
|
||||
<svg
|
||||
viewBox="0 0 640 470"
|
||||
className="mx-auto h-auto w-full max-w-[620px]"
|
||||
role="img"
|
||||
aria-label="مثلث آینده: کشش آینده، فشارهای حال و وزن گذشته"
|
||||
>
|
||||
<span>{f.title}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
|
||||
isSelected ? 'bg-slate-800 text-purple-300' : 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{toPersianDigits(f.strength)}
|
||||
</span>
|
||||
</button>
|
||||
{/* The triangle itself */}
|
||||
<polygon
|
||||
points={`${APEX.x},${APEX.y} ${RIGHT.x},${RIGHT.y} ${LEFT.x},${LEFT.y}`}
|
||||
fill="var(--muted)"
|
||||
fillOpacity="0.5"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Each vertex is scaled by the average strength of its group,
|
||||
so the shape shows which way the university is being pulled. */}
|
||||
{GROUPS.map((group, i) => {
|
||||
const vertex = [APEX, RIGHT, LEFT][i];
|
||||
const avg = strength(group.type);
|
||||
const r = 26 + avg * 2.6;
|
||||
const count = grouped[group.type].length;
|
||||
|
||||
return (
|
||||
<g key={group.type}>
|
||||
<circle
|
||||
cx={vertex.x}
|
||||
cy={vertex.y}
|
||||
r={r}
|
||||
fill={group.color}
|
||||
fillOpacity="0.12"
|
||||
stroke={group.color}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<text
|
||||
x={vertex.x}
|
||||
y={vertex.y - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
fill={group.color}
|
||||
>
|
||||
{group.label}
|
||||
</text>
|
||||
<text
|
||||
x={vertex.x}
|
||||
y={vertex.y + 13}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{toPersianDigits(count)} نیرو · {toPersianDigits(avg.toFixed(1))}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. SLEEK PROPORTIONAL GEOMETRIC TRIANGLE SVG CANVAS */}
|
||||
<div className="relative w-full max-w-[720px] my-2 select-none">
|
||||
<svg
|
||||
viewBox="0 0 800 550"
|
||||
className="w-full h-auto drop-shadow-sm"
|
||||
style={{ overflow: 'visible' }}
|
||||
{/* The university sits at the centroid, between the three pulls. */}
|
||||
<circle cx="320" cy="292" r="5" fill="var(--primary)" />
|
||||
<text
|
||||
x="320"
|
||||
y="313"
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
<defs>
|
||||
{/* Triangular Gradient Fill */}
|
||||
<linearGradient id="futuresTriangleGrad" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.12" />
|
||||
<stop offset="60%" stopColor="#0284c7" stopOpacity="0.08" />
|
||||
<stop offset="100%" stopColor="#f43f5e" stopOpacity="0.12" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Edge Gradient: Left (PULL -> WEIGHT) */}
|
||||
<linearGradient id="pullToWeightGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#8b5cf6" />
|
||||
<stop offset="100%" stopColor="#f43f5e" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Edge Gradient: Right (PULL -> PUSH) */}
|
||||
<linearGradient id="pullToPushGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#8b5cf6" />
|
||||
<stop offset="100%" stopColor="#0284c7" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Edge Gradient: Bottom (WEIGHT <-> PUSH) */}
|
||||
<linearGradient id="weightToPushGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#f43f5e" />
|
||||
<stop offset="100%" stopColor="#0284c7" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filter */}
|
||||
<filter id="nodeShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#0f172a" floodOpacity="0.1" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* REAL SOLID SLEEK GEOMETRIC TRIANGLE */}
|
||||
<polygon
|
||||
points={`${apex.x},${apex.y} ${rightCorner.x},${rightCorner.y} ${leftCorner.x},${leftCorner.y}`}
|
||||
fill="url(#futuresTriangleGrad)"
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* INNER EQUILIBRIUM DASHED TRIANGLE */}
|
||||
<polygon
|
||||
points={`${apex.x},${apex.y + 60} ${rightCorner.x - 55},${rightCorner.y - 35} ${leftCorner.x + 55},${leftCorner.y - 35}`}
|
||||
fill="none"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="5,5"
|
||||
opacity="0.6"
|
||||
/>
|
||||
|
||||
{/* CENTROID TETHERS (Lines from centroid to 3 corners) */}
|
||||
<line
|
||||
x1={centroid.x}
|
||||
y1={centroid.y}
|
||||
x2={apex.x}
|
||||
y2={apex.y}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<line
|
||||
x1={centroid.x}
|
||||
y1={centroid.y}
|
||||
x2={rightCorner.x}
|
||||
y2={rightCorner.y}
|
||||
stroke="#0284c7"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<line
|
||||
x1={centroid.x}
|
||||
y1={centroid.y}
|
||||
x2={leftCorner.x}
|
||||
y2={leftCorner.y}
|
||||
stroke="#f43f5e"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
opacity="0.8"
|
||||
/>
|
||||
|
||||
{/* PROMINENT TRIANGLE EDGES (SLEEK & SOLID) */}
|
||||
{/* Right Edge: PULL to PUSH */}
|
||||
<line
|
||||
x1={apex.x}
|
||||
y1={apex.y}
|
||||
x2={rightCorner.x}
|
||||
y2={rightCorner.y}
|
||||
stroke="url(#pullToPushGrad)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Left Edge: PULL to WEIGHT */}
|
||||
<line
|
||||
x1={apex.x}
|
||||
y1={apex.y}
|
||||
x2={leftCorner.x}
|
||||
y2={leftCorner.y}
|
||||
stroke="url(#pullToWeightGrad)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Bottom Edge: WEIGHT to PUSH */}
|
||||
<line
|
||||
x1={leftCorner.x}
|
||||
y1={leftCorner.y}
|
||||
x2={rightCorner.x}
|
||||
y2={rightCorner.y}
|
||||
stroke="url(#weightToPushGrad)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Edge Vector Dynamics Badges */}
|
||||
{/* Right edge midpoint */}
|
||||
<g transform={`translate(${(apex.x + rightCorner.x) / 2 + 35}, ${(apex.y + rightCorner.y) / 2}) rotate(60)`}>
|
||||
<rect x="-65" y="-11" width="130" height="22" rx="11" fill="#ffffff" stroke="#c8e0fd" strokeWidth="1" filter="url(#nodeShadow)" />
|
||||
<text textAnchor="middle" y="4" fill="#0284c7" fontSize="10" fontWeight="800">
|
||||
پویش روندهای زمان حال ←
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Left edge midpoint */}
|
||||
<g transform={`translate(${(apex.x + leftCorner.x) / 2 - 35}, ${(apex.y + leftCorner.y) / 2}) rotate(-60)`}>
|
||||
<rect x="-65" y="-11" width="130" height="22" rx="11" fill="#ffffff" stroke="#f9cbd9" strokeWidth="1" filter="url(#nodeShadow)" />
|
||||
<text textAnchor="middle" y="4" fill="#e11d48" fontSize="10" fontWeight="800">
|
||||
→ مهار توسط لنگرهای گذشته
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Bottom edge midpoint */}
|
||||
<g transform={`translate(${centroid.x}, ${leftCorner.y + 24})`}>
|
||||
<rect x="-90" y="-12" width="180" height="24" rx="12" fill="#ffffff" stroke="#e2e8f0" strokeWidth="1" filter="url(#nodeShadow)" />
|
||||
<text textAnchor="middle" y="4.5" fill="#334155" fontSize="10.5" fontWeight="800">
|
||||
تعارض لختی سنتی و شتاب فناوری
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* CENTRAL HUB: آینده دانشگاه اصفهان (At Centroid) */}
|
||||
<g transform={`translate(${centroid.x}, ${centroid.y})`}>
|
||||
<circle r="44" fill="#ffffff" stroke="#00b894" strokeWidth="3" filter="url(#nodeShadow)" />
|
||||
<circle r="36" fill="#e6f7f3" />
|
||||
<circle r="12" fill="#00b894" className="anim-pulse" opacity="0.3" />
|
||||
<circle r="6" fill="#00b894" />
|
||||
<text textAnchor="middle" y="-12" fill="#0f172a" fontSize="11" fontWeight="900">
|
||||
دانشگاه اصفهان
|
||||
</text>
|
||||
<text textAnchor="middle" y="24" fill="#008f73" fontSize="9.5" fontWeight="800">
|
||||
افق ۱۴۱۵
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* VERTEX CORNER NODES */}
|
||||
{/* Top Vertex (PULL) */}
|
||||
<g transform={`translate(${apex.x}, ${apex.y})`}>
|
||||
<circle r="20" fill="#ffffff" stroke="#8b5cf6" strokeWidth="3.5" filter="url(#nodeShadow)" />
|
||||
<circle r="9" fill="#8b5cf6" />
|
||||
</g>
|
||||
|
||||
{/* Bottom-Right Vertex (PUSH) */}
|
||||
<g transform={`translate(${rightCorner.x}, ${rightCorner.y})`}>
|
||||
<circle r="20" fill="#ffffff" stroke="#0284c7" strokeWidth="3.5" filter="url(#nodeShadow)" />
|
||||
<circle r="9" fill="#0284c7" />
|
||||
</g>
|
||||
|
||||
{/* Bottom-Left Vertex (WEIGHT) */}
|
||||
<g transform={`translate(${leftCorner.x}, ${leftCorner.y})`}>
|
||||
<circle r="20" fill="#ffffff" stroke="#f43f5e" strokeWidth="3.5" filter="url(#nodeShadow)" />
|
||||
<circle r="9" fill="#f43f5e" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. BOTTOM TWO CORNER PANELS (PUSH & WEIGHT) */}
|
||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-5 mt-2 pt-4 border-t border-slate-100 z-10">
|
||||
{/* PUSH PANEL (Bottom-Right Vertex) */}
|
||||
<div className="flex flex-col items-center md:items-start text-center md:text-right p-4 bg-sky-50/60 border border-sky-200 rounded-2xl">
|
||||
<div className="flex items-center gap-2 bg-white border border-sky-300 px-3.5 py-1.5 rounded-full shadow-sm text-sky-950 font-black text-xs mb-1">
|
||||
<TrendingUp size={15} className="text-sky-600" />
|
||||
<span>فشارهای زمان حال</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 font-semibold mb-2.5">
|
||||
روندها، فناوریهای فعال و تحولات جاری
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-center md:justify-start">
|
||||
{pushForces.map((f) => {
|
||||
const isSelected = selectedForce.id === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => handleForceClick(f)}
|
||||
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
|
||||
isSelected
|
||||
? 'bg-slate-900 text-white shadow-md scale-105'
|
||||
: 'bg-white text-slate-800 hover:bg-sky-100 hover:text-sky-900 border border-sky-200'
|
||||
}`}
|
||||
>
|
||||
<span>{f.title}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
|
||||
isSelected ? 'bg-slate-800 text-sky-300' : 'bg-sky-100 text-sky-800'
|
||||
}`}>
|
||||
{toPersianDigits(f.strength)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WEIGHT PANEL (Bottom-Left Vertex) */}
|
||||
<div className="flex flex-col items-center md:items-end text-center md:text-left p-4 bg-rose-50/60 border border-rose-200 rounded-2xl">
|
||||
<div className="flex items-center gap-2 bg-white border border-rose-300 px-3.5 py-1.5 rounded-full shadow-sm text-rose-950 font-black text-xs mb-1">
|
||||
<ShieldAlert size={15} className="text-rose-600" />
|
||||
<span>وزن گذشته</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 font-semibold mb-2.5">
|
||||
موانع ساختاری، آییننامهها و لختی سازمانی
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-center md:justify-end">
|
||||
{weightForces.map((f) => {
|
||||
const isSelected = selectedForce.id === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => handleForceClick(f)}
|
||||
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
|
||||
isSelected
|
||||
? 'bg-slate-900 text-white shadow-md scale-105'
|
||||
: 'bg-white text-slate-800 hover:bg-rose-100 hover:text-rose-900 border border-rose-200'
|
||||
}`}
|
||||
>
|
||||
<span>{f.title}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
|
||||
isSelected ? 'bg-slate-800 text-rose-300' : 'bg-rose-100 text-rose-800'
|
||||
}`}>
|
||||
{toPersianDigits(f.strength)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Force Intelligence Dossier Card */}
|
||||
{selectedForce && (
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col gap-4 animate-fadeIn">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||
{/* The three force lists */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 xl:col-span-7">
|
||||
{GROUPS.map((group) => (
|
||||
<Card data-card key={group.type} className="gap-0 p-0">
|
||||
<CardHeader className="gap-0.5 border-b px-3.5 py-3">
|
||||
<CardTitle className="flex items-center gap-2 text-[13.5px]">
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full shadow-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
selectedForce.forceType === 'pull'
|
||||
? '#8b5cf6'
|
||||
: selectedForce.forceType === 'push'
|
||||
? '#0284c7'
|
||||
: '#f43f5e'
|
||||
}}
|
||||
></span>
|
||||
<h3 className="text-slate-900 font-black text-lg">{selectedForce.title}</h3>
|
||||
<span
|
||||
className={`badge ${
|
||||
selectedForce.forceType === 'pull'
|
||||
? 'badge-purple'
|
||||
: selectedForce.forceType === 'push'
|
||||
? 'badge-blue'
|
||||
: 'badge-rose'
|
||||
}`}
|
||||
>
|
||||
{selectedForce.forceType === 'pull'
|
||||
? 'کشش آینده'
|
||||
: selectedForce.forceType === 'push'
|
||||
? 'فشار زمان حال'
|
||||
: 'وزن گذشته'}
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ background: group.color }}
|
||||
/>
|
||||
{group.label}
|
||||
<span className="text-[10.5px] font-medium tracking-wide text-muted-foreground">
|
||||
{group.latin}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{group.hint}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 font-bold">شدت اثر نیرو:</span>
|
||||
<span className="text-lg font-black text-slate-900 bg-slate-100 px-3 py-0.5 rounded-full border border-slate-200">
|
||||
{toPersianDigits(selectedForce.strength)} از ۱۰
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 p-3.5 rounded-2xl text-xs text-slate-700 leading-relaxed">
|
||||
<span className="text-slate-900 font-bold block mb-1">شواهد و مؤیدهای تجربی:</span>
|
||||
{selectedForce.evidence}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs mt-1">
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl">
|
||||
<span className="text-slate-900 font-bold block mb-1">پیامد راهبردی برای دانشگاه اصفهان:</span>
|
||||
<p className="text-slate-600 leading-relaxed text-[11.5px]">{selectedForce.impact}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-teal-50 border border-teal-200 rounded-2xl">
|
||||
<span className="text-teal-900 font-bold block mb-1">اقدام پیشنهادی جهت غلبه / همافزایی:</span>
|
||||
<p className="text-teal-800 leading-relaxed text-[11.5px] font-medium">{selectedForce.strategicImplication}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-1.5">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{grouped[group.type].map((force) => {
|
||||
const active = selected?.id === force.id;
|
||||
return (
|
||||
<li key={force.id}>
|
||||
<button
|
||||
onClick={() => pick(force)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-right text-[12px] transition-colors',
|
||||
active ? 'bg-muted font-semibold' : 'hover:bg-muted/60'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{force.title}</span>
|
||||
<span
|
||||
className="tnum shrink-0 rounded px-1 py-0.5 text-[10.5px] font-semibold"
|
||||
style={{ background: group.soft, color: group.color }}
|
||||
>
|
||||
{toPersianDigits(force.strength)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail of the selected force */}
|
||||
{selected && (
|
||||
<Card data-card className="xl:col-span-5">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||
style={{ background: selectedGroup.soft, color: selectedGroup.color }}
|
||||
>
|
||||
{selectedGroup.label}
|
||||
</span>
|
||||
<span className="tnum text-[11px] text-muted-foreground">
|
||||
شدت {toPersianDigits(selected.strength)} از ۱۰
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="mt-1.5 text-[15px] leading-relaxed">
|
||||
{selected.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3 pt-4">
|
||||
<DetailBlock title="شواهد" body={selected.evidence} />
|
||||
<DetailBlock title="اثر" body={selected.impact} />
|
||||
<DetailBlock title="جهت حرکت" body={selected.direction} />
|
||||
<DetailBlock title="دلالت راهبردی" body={selected.strategicImplication} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailBlock: React.FC<{ title: string; body: string }> = ({ title, body }) => (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-semibold text-muted-foreground">{title}</div>
|
||||
<p className="text-[12.5px] leading-relaxed">{body}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Global labour-market reference data.
|
||||
*
|
||||
* Every figure here is transcribed from a published source and carries that
|
||||
* source with it, so the UI can always show where a number came from. Nothing
|
||||
* in this file is estimated or interpolated — where a source does not publish
|
||||
* a figure, the field is `null` and the UI says so.
|
||||
*/
|
||||
|
||||
export interface SourceRef {
|
||||
/** Short label shown next to the figure. */
|
||||
label: string;
|
||||
url: string;
|
||||
/** Vintage of the underlying data, in Persian. */
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export const SOURCE_WEF: SourceRef = {
|
||||
label: 'مجمع جهانی اقتصاد — گزارش آینده مشاغل ۲۰۲۵',
|
||||
url: 'https://www.weforum.org/publications/the-future-of-jobs-report-2025/',
|
||||
asOf: 'ژانویه ۲۰۲۵ — نظرسنجی از ۱۰۴۳ کارفرما در ۵۵ کشور'
|
||||
};
|
||||
|
||||
export const SOURCE_NYFED: SourceRef = {
|
||||
label: 'بانک فدرال رزرو نیویورک — بازار کار فارغالتحصیلان',
|
||||
url: 'https://www.newyorkfed.org/research/college-labor-market',
|
||||
asOf: 'انتشار بهمن ۱۴۰۴ (فوریه ۲۰۲۶) بر پایه سرشماری ۲۰۲۴'
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Headline figures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface HeadlineStat {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
tone: 'positive' | 'negative' | 'neutral';
|
||||
}
|
||||
|
||||
export const WEF_HEADLINES: HeadlineStat[] = [
|
||||
{
|
||||
key: 'created',
|
||||
label: 'شغل ایجادشده تا ۲۰۳۰',
|
||||
value: '۱۷۰ میلیون',
|
||||
note: 'معادل ۱۴٪ اشتغال امروز',
|
||||
tone: 'positive'
|
||||
},
|
||||
{
|
||||
key: 'displaced',
|
||||
label: 'شغل ازدسترفته',
|
||||
value: '۹۲ میلیون',
|
||||
note: 'جابهجایی ناشی از فناوری',
|
||||
tone: 'negative'
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
label: 'خالص تغییر',
|
||||
value: '+۷۸ میلیون',
|
||||
note: 'برآیند ایجاد و حذف',
|
||||
tone: 'positive'
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
label: 'مهارتهای دگرگونشونده',
|
||||
value: '۳۹٪',
|
||||
note: 'سهم مهارتهای اصلی تا ۲۰۳۰',
|
||||
tone: 'neutral'
|
||||
}
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Occupations — global, not university-specific */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface GlobalRole {
|
||||
rank: number;
|
||||
title: string;
|
||||
/** Percentage change 2025–2030. `null` where the source publishes only a rank. */
|
||||
changePercent: number | null;
|
||||
/** Broad family, used for grouping and colour. */
|
||||
family: 'فناوری' | 'انرژی و محیط زیست' | 'خدمات' | 'اداری' | 'مالی' | 'خردهفروشی' | 'طراحی';
|
||||
}
|
||||
|
||||
/** Fastest-growing roles by percentage, WEF Future of Jobs 2025. */
|
||||
export const GROWING_ROLES: GlobalRole[] = [
|
||||
{ rank: 1, title: 'متخصص کلانداده', changePercent: 113, family: 'فناوری' },
|
||||
{ rank: 2, title: 'مهندس فینتک', changePercent: 93, family: 'مالی' },
|
||||
{ rank: 3, title: 'متخصص هوش مصنوعی و یادگیری ماشین', changePercent: 82, family: 'فناوری' },
|
||||
{ rank: 4, title: 'توسعهدهنده نرمافزار و اپلیکیشن', changePercent: 57, family: 'فناوری' },
|
||||
{ rank: 5, title: 'متخصص مدیریت امنیت', changePercent: 53, family: 'فناوری' },
|
||||
{ rank: 6, title: 'متخصص انبار داده', changePercent: 49, family: 'فناوری' },
|
||||
{ rank: 7, title: 'متخصص خودروی خودران و برقی', changePercent: 48, family: 'انرژی و محیط زیست' },
|
||||
{ rank: 8, title: 'طراح رابط و تجربه کاربری', changePercent: 48, family: 'طراحی' },
|
||||
{ rank: 9, title: 'راننده وانت و خدمات تحویل', changePercent: 46, family: 'خدمات' },
|
||||
{ rank: 10, title: 'تحلیلگر امنیت اطلاعات', changePercent: 40, family: 'فناوری' },
|
||||
{ rank: 11, title: 'مهندس انرژی تجدیدپذیر', changePercent: 40, family: 'انرژی و محیط زیست' },
|
||||
{ rank: 12, title: 'مهندس محیط زیست', changePercent: 40, family: 'انرژی و محیط زیست' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Fastest-declining roles, WEF Future of Jobs 2025.
|
||||
* The report publishes the full ranking; percentage figures are only quoted
|
||||
* consistently for the top three, so the rest carry `null`.
|
||||
*/
|
||||
export const DECLINING_ROLES: GlobalRole[] = [
|
||||
{ rank: 1, title: 'متصدی خدمات پستی', changePercent: -40, family: 'اداری' },
|
||||
{ rank: 2, title: 'تحویلدار بانک', changePercent: -35, family: 'مالی' },
|
||||
{ rank: 3, title: 'اپراتور ورود داده', changePercent: -34, family: 'اداری' },
|
||||
{ rank: 4, title: 'صندوقدار و فروشنده بلیت', changePercent: null, family: 'خردهفروشی' },
|
||||
{ rank: 5, title: 'منشی اجرایی و دستیار اداری', changePercent: null, family: 'اداری' },
|
||||
{ rank: 6, title: 'کارگر صنعت چاپ', changePercent: null, family: 'خدمات' },
|
||||
{ rank: 7, title: 'کارمند حسابداری و حقوق و دستمزد', changePercent: null, family: 'مالی' },
|
||||
{ rank: 8, title: 'متصدی انبار و ثبت کالا', changePercent: null, family: 'اداری' },
|
||||
{ rank: 9, title: 'مأمور و مهماندار حملونقل', changePercent: null, family: 'خدمات' },
|
||||
{ rank: 10, title: 'فروشنده دورهگرد', changePercent: null, family: 'خردهفروشی' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Roles that entered the declining list for the first time in the 2025
|
||||
* edition — the report attributes this to generative AI reaching knowledge
|
||||
* work, which is directly relevant to curriculum planning.
|
||||
*/
|
||||
export const NEWLY_DECLINING: string[] = ['طراح گرافیک', 'منشی حقوقی'];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Skills */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface SkillTrend {
|
||||
title: string;
|
||||
kind: 'فناورانه' | 'شناختی' | 'انسانی' | 'پایداری' | 'جسمی' | 'پایه';
|
||||
}
|
||||
|
||||
/** Top core skills employers expect to matter in 2030, in the report's order. */
|
||||
export const RISING_SKILLS: SkillTrend[] = [
|
||||
{ title: 'تفکر تحلیلی', kind: 'شناختی' },
|
||||
{ title: 'تابآوری، انعطاف و چابکی', kind: 'انسانی' },
|
||||
{ title: 'رهبری و نفوذ اجتماعی', kind: 'انسانی' },
|
||||
{ title: 'تفکر خلاق', kind: 'شناختی' },
|
||||
{ title: 'انگیزه و خودآگاهی', kind: 'انسانی' },
|
||||
{ title: 'سواد فناورانه', kind: 'فناورانه' },
|
||||
{ title: 'هوش مصنوعی و کلانداده', kind: 'فناورانه' },
|
||||
{ title: 'شبکه و امنیت سایبری', kind: 'فناورانه' },
|
||||
{ title: 'مدیریت زیستمحیطی', kind: 'پایداری' }
|
||||
];
|
||||
|
||||
export const DECLINING_SKILLS: { title: string; note: string }[] = [
|
||||
{
|
||||
title: 'مهارت دستی، دقت و استقامت جسمی',
|
||||
note: '۲۴٪ کارفرمایان کاهش اهمیت را پیشبینی میکنند'
|
||||
},
|
||||
{
|
||||
title: 'خواندن، نوشتن و ریاضیات پایه',
|
||||
note: 'کاهش خالص اندک در اهمیت نسبی'
|
||||
}
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* University majors — outcomes for recent graduates */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface MajorOutcome {
|
||||
title: string;
|
||||
/** Unemployment rate for graduates aged 22–27, percent. */
|
||||
unemployment: number;
|
||||
}
|
||||
|
||||
/** Majors with the lowest recent-graduate unemployment. */
|
||||
export const BEST_MAJORS: MajorOutcome[] = [
|
||||
{ title: 'آموزش استثنایی', unemployment: 0.7 },
|
||||
{ title: 'سایر رشتههای تعلیم و تربیت', unemployment: 1.1 },
|
||||
{ title: 'آموزش ابتدایی', unemployment: 1.2 },
|
||||
{ title: 'کشاورزی', unemployment: 1.4 },
|
||||
{ title: 'زبانهای خارجی', unemployment: 1.6 },
|
||||
{ title: 'جغرافیا', unemployment: 1.6 },
|
||||
{ title: 'فناوریهای مهندسی', unemployment: 1.7 },
|
||||
{ title: 'خدمات اجتماعی', unemployment: 1.9 },
|
||||
{ title: 'پرستاری', unemployment: 2.1 },
|
||||
{ title: 'آموزش متوسطه', unemployment: 2.1 }
|
||||
];
|
||||
|
||||
/** Majors with the highest recent-graduate unemployment. */
|
||||
export const WORST_MAJORS: MajorOutcome[] = [
|
||||
{ title: 'انسانشناسی', unemployment: 7.9 },
|
||||
{ title: 'مهندسی کامپیوتر', unemployment: 7.8 },
|
||||
{ title: 'هنرهای تجسمی', unemployment: 7.7 },
|
||||
{ title: 'هنرهای نمایشی', unemployment: 7.0 },
|
||||
{ title: 'علوم کامپیوتر', unemployment: 7.0 },
|
||||
{ title: 'معماری', unemployment: 6.8 }
|
||||
];
|
||||
|
||||
/** Context figures for the majors panel. */
|
||||
export const GRADUATE_CONTEXT = {
|
||||
unemployment: '۵٫۶٪',
|
||||
underemployment: '۴۲٪',
|
||||
medianEarlyWage: '۵۸٬۰۰۰ دلار',
|
||||
majorsTracked: '۷۳'
|
||||
};
|
||||
|
||||
/**
|
||||
* The finding worth putting in front of a curriculum committee: the highest
|
||||
* paying major is also among the highest for unemployment, and the safest
|
||||
* majors are among the lowest paid. Both figures are from the same release.
|
||||
*/
|
||||
export const PAY_PARADOX = {
|
||||
topPayingMajor: 'مهندسی کامپیوتر',
|
||||
topPayingWage: '۹۰٬۰۰۰ دلار',
|
||||
topPayingUnemployment: 7.8,
|
||||
safestMajor: 'آموزش استثنایی',
|
||||
safestUnemployment: 0.7
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from 'react';
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/**
|
||||
* Counts from 0 up to `target` once, whenever `target` changes.
|
||||
*
|
||||
* Correctness beats the flourish: some environments throttle
|
||||
* requestAnimationFrame to zero (background tabs, embedded webviews), which
|
||||
* would otherwise strand the figure at 0. A timer always settles the final
|
||||
* value even if not a single frame is ever served.
|
||||
*/
|
||||
export function useCountUp(target: number, durationMs = 900, decimals = 0) {
|
||||
const [value, setValue] = React.useState(() =>
|
||||
prefersReducedMotion() ? target : 0
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prefersReducedMotion() || durationMs <= 0) {
|
||||
setValue(target);
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / durationMs);
|
||||
// easeOutCubic — quick off the mark, settles gently on the final figure.
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
setValue(Number((target * eased).toFixed(decimals)));
|
||||
if (t < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(tick);
|
||||
const settle = window.setTimeout(() => setValue(target), durationMs + 120);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.clearTimeout(settle);
|
||||
};
|
||||
}, [target, durationMs, decimals]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
/** True while the viewport is narrower than the `md` breakpoint. */
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
mql.addEventListener('change', onChange)
|
||||
onChange()
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'uniradar-theme';
|
||||
|
||||
const readStoredTheme = (): Theme => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
} catch {
|
||||
// Private mode / blocked storage — fall through to the system preference.
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (theme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Every themed colour is a CSS variable, so flipping the theme would
|
||||
// otherwise animate any element that transitions colour — leaving cards
|
||||
// mid-interpolation. Suppress transitions for the duration of the swap.
|
||||
root.setAttribute('data-theme-switching', '');
|
||||
root.classList.toggle('dark', theme === 'dark');
|
||||
root.style.colorScheme = theme;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
root.removeAttribute('data-theme-switching');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** Light/dark preference, persisted per browser. */
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = React.useState<Theme>(readStoredTheme);
|
||||
|
||||
React.useEffect(() => {
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Not being able to remember the choice is not worth breaking over.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = React.useCallback(
|
||||
() => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
|
||||
[]
|
||||
);
|
||||
|
||||
return { theme, setTheme, toggleTheme };
|
||||
}
|
||||
1254
src/index.css
1254
src/index.css
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,7 @@
|
|||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/** Merge conditional class names, letting later Tailwind utilities win. */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
JobEntity,
|
||||
SkillEntity,
|
||||
Category
|
||||
} from '../types/futures';
|
||||
import { dataService } from '../services/dataService';
|
||||
import { EntityEditorModal } from '../components/modals/EntityEditorModal';
|
||||
import {
|
||||
toPersianDigits,
|
||||
getCategoryLabelFa,
|
||||
getCategoryColor
|
||||
} from '../utils/persianNumbers';
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Download,
|
||||
Upload,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AdminPageProps {
|
||||
items: IntelligenceItem[];
|
||||
dimensions: University1415Dimension[];
|
||||
jobs: JobEntity[];
|
||||
skills: SkillEntity[];
|
||||
onRefreshData: () => void;
|
||||
onSelectItem: (item: IntelligenceItem) => void;
|
||||
}
|
||||
|
||||
export const AdminPage: React.FC<AdminPageProps> = ({
|
||||
items,
|
||||
dimensions,
|
||||
jobs,
|
||||
skills,
|
||||
onRefreshData,
|
||||
onSelectItem
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'items' | 'dimensions' | 'jobs'>('items');
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<IntelligenceItem | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
||||
if (searchQuery.trim() !== '') {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const inTitle = item.title.toLowerCase().includes(q);
|
||||
const inSummary = item.executiveSummary.toLowerCase().includes(q);
|
||||
if (!inTitle && !inSummary) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingItem(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (item: IntelligenceItem) => {
|
||||
setEditingItem(item);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm('آیا از حذف این رکورد از پایگاه داده رادار اطمینان دارید؟')) {
|
||||
dataService.deleteItem(id);
|
||||
onRefreshData();
|
||||
showSuccess('رکورد با موفقیت حذف شد');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveItem = (item: IntelligenceItem) => {
|
||||
if (editingItem) {
|
||||
dataService.updateItem(item);
|
||||
showSuccess('تغییرات با موفقیت ذخیره شد');
|
||||
} else {
|
||||
dataService.createItem(item);
|
||||
showSuccess('رکورد جدید با موفقیت به رادار اضافه شد');
|
||||
}
|
||||
onRefreshData();
|
||||
};
|
||||
|
||||
const handleResetData = () => {
|
||||
if (
|
||||
window.confirm(
|
||||
'آیا میخواهید دادههای پایگاه را به دادههای اولیه سازمانی دانشگاه اصفهان بازگردانید؟ تمامی تغییرات اختصاصی بازنشانی خواهند شد.'
|
||||
)
|
||||
) {
|
||||
dataService.resetToDefault();
|
||||
onRefreshData();
|
||||
showSuccess('پایگاه داده با موفقیت به دادههای معیار دانشگاه اصفهان بازنشانی شد');
|
||||
}
|
||||
};
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setTimeout(() => setSuccessMsg(''), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-purple">پنل مدیریت محتوا و دادهها</span>
|
||||
<span className="text-slate-400 text-xs">عملیات CRUD و کالیبراسیون شاخصها</span>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl text-white">
|
||||
مدیریت پایگاه داده هوشمندی (Intelligence Database Studio)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
||||
ثبت، ویرایش و پایش روندها، فناوریها، سیاستها، سیگنالها، مشاغل آینده و تنظیم نمرات دانشگاه ۱۴۱۵
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="btn btn-primary text-xs py-2 px-3.5 flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>افزودن سیگنال / روند جدید</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetData}
|
||||
className="btn btn-secondary text-xs py-2 px-3 flex items-center gap-1.5 text-rose-300 hover:text-rose-200"
|
||||
title="بازنشانی به دادههای معیار دانشگاه اصفهان"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
<span>بازنشانی دادهها</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMsg && (
|
||||
<div className="p-3 bg-teal-950/40 border border-teal-500/50 rounded-xl text-teal-300 text-xs flex items-center gap-2 animate-fadeIn">
|
||||
<CheckCircle2 size={16} className="text-teal-400" />
|
||||
<span>{successMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setActiveTab('items')}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
|
||||
activeTab === 'items'
|
||||
? 'bg-teal-500 text-slate-950'
|
||||
: 'text-slate-400 hover:text-white bg-[#091122]'
|
||||
}`}
|
||||
>
|
||||
موجودیتهای رادار ({toPersianDigits(items.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('dimensions')}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
|
||||
activeTab === 'dimensions'
|
||||
? 'bg-teal-500 text-slate-950'
|
||||
: 'text-slate-400 hover:text-white bg-[#091122]'
|
||||
}`}
|
||||
>
|
||||
ابعاد دانشگاه ۱۴۱۵ ({toPersianDigits(dimensions.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
|
||||
activeTab === 'jobs'
|
||||
? 'bg-teal-500 text-slate-950'
|
||||
: 'text-slate-400 hover:text-white bg-[#091122]'
|
||||
}`}
|
||||
>
|
||||
مشاغل و مهارتها ({toPersianDigits(jobs.length + skills.length)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Intelligence Items CRUD Table */}
|
||||
{activeTab === 'items' && (
|
||||
<div className="intel-card p-0 flex flex-col gap-4 overflow-hidden">
|
||||
{/* Table Filters */}
|
||||
<div className="p-4 bg-[#091122] border-b border-slate-800 flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-slate-400 font-semibold">فیلتر رده:</span>
|
||||
{(['all', 'trend', 'technology', 'policy', 'weak_signal'] as const).map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-2.5 py-1 rounded-md transition-all ${
|
||||
selectedCategory === cat
|
||||
? 'bg-teal-500 text-slate-950 font-bold'
|
||||
: 'bg-[#101c36] text-slate-300 hover:bg-[#16274a]'
|
||||
}`}
|
||||
>
|
||||
{cat === 'all' ? 'همه' : getCategoryLabelFa(cat)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="جستجو در عناوین..."
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-lg py-1.5 pr-8 pl-2 text-xs text-white outline-none focus:border-teal-500"
|
||||
/>
|
||||
<Search size={13} className="absolute right-2.5 top-2 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#0b1428] text-slate-400 border-b border-slate-800">
|
||||
<th className="p-3">عنوان</th>
|
||||
<th className="p-3">دستهبندی</th>
|
||||
<th className="p-3">افق زمانی</th>
|
||||
<th className="p-3">شدت اثر</th>
|
||||
<th className="p-3">دامنه</th>
|
||||
<th className="p-3">وضعیت</th>
|
||||
<th className="p-3 text-center">عملیات مدیریت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-[#0e1933] transition-colors">
|
||||
<td className="p-3 font-bold text-white max-w-xs">{item.title}</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={{
|
||||
backgroundColor: `${getCategoryColor(item.category)}18`,
|
||||
color: getCategoryColor(item.category)
|
||||
}}
|
||||
>
|
||||
{getCategoryLabelFa(item.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-slate-300">{item.horizon}</td>
|
||||
<td className="p-3 font-bold text-teal-400">{toPersianDigits(item.impactScore)}/۵</td>
|
||||
<td className="p-3 text-slate-300">{item.geographicScope}</td>
|
||||
<td className="p-3">
|
||||
<span className="badge badge-gray">{item.status}</span>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => handleEdit(item)}
|
||||
className="p-1.5 text-sky-400 hover:text-white bg-[#0e1c3a] hover:bg-sky-900/40 rounded-lg transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1.5 text-rose-400 hover:text-white bg-[#1a0e1c] hover:bg-rose-950/40 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: 1415 Dimensions Table */}
|
||||
{activeTab === 'dimensions' && (
|
||||
<div className="intel-card overflow-x-auto p-0">
|
||||
<table className="w-full text-right text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#091122] text-slate-400 border-b border-slate-800">
|
||||
<th className="p-3">کلانویژگی دانشگاه ۱۴۱۵</th>
|
||||
<th className="p-3">نمره وضع موجود (۱-۱۰)</th>
|
||||
<th className="p-3">نمره هدف ۱۴۱۵ (۱-۱۰)</th>
|
||||
<th className="p-3">شکاف راهبردی</th>
|
||||
<th className="p-3">درصد آمادگی</th>
|
||||
<th className="p-3">تعداد شواهد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{dimensions.map((dim) => (
|
||||
<tr key={dim.id} className="hover:bg-[#0e1933] transition-colors">
|
||||
<td className="p-3 font-bold text-white">{dim.title}</td>
|
||||
<td className="p-3 text-amber-400 font-bold font-mono">{toPersianDigits(dim.currentScore)}</td>
|
||||
<td className="p-3 text-teal-400 font-bold font-mono">{toPersianDigits(dim.targetScore)}</td>
|
||||
<td className="p-3 text-rose-400 font-extrabold font-mono">{toPersianDigits(dim.gap)}</td>
|
||||
<td className="p-3 text-teal-300 font-bold font-mono">{toPersianDigits(dim.readinessPercentage)}٪</td>
|
||||
<td className="p-3 text-slate-400">{toPersianDigits(dim.supportingEvidence.length)} مورد</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Jobs & Skills Table */}
|
||||
{activeTab === 'jobs' && (
|
||||
<div className="intel-card overflow-x-auto p-0">
|
||||
<table className="w-full text-right text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#091122] text-slate-400 border-b border-slate-800">
|
||||
<th className="p-3">عنوان شغل</th>
|
||||
<th className="p-3">نوع تحول</th>
|
||||
<th className="p-3">نرخ رشد</th>
|
||||
<th className="p-3">خطر اتوماسیون</th>
|
||||
<th className="p-3">دانشکده مرتبط در دانشگاه اصفهان</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-[#0e1933] transition-colors">
|
||||
<td className="p-3 font-bold text-white">{job.title}</td>
|
||||
<td className="p-3">
|
||||
<span className={`badge ${job.type === 'growing' ? 'badge-teal' : 'badge-rose'}`}>
|
||||
{job.type === 'growing' ? 'رو به رشد' : 'در معرض تغییر'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-teal-400 font-bold">+{toPersianDigits(job.growthRatePercent)}٪</td>
|
||||
<td className="p-3 font-mono text-rose-400">{toPersianDigits(job.automationRiskPercent)}٪</td>
|
||||
<td className="p-3 text-slate-300">{job.discipline}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSave={handleSaveItem}
|
||||
initialItem={editingItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,27 +1,23 @@
|
|||
import React, { useState } from 'react';
|
||||
import { IntelligenceItem, University1415Dimension, Category, Horizon, UserSession } from '../types/futures';
|
||||
import { dataService } from '../services/dataService';
|
||||
import { FuturesRadar } from '../components/visual/FuturesRadar';
|
||||
import { PersonaBanner } from '../components/layout/PersonaBanner';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import {
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Target,
|
||||
ArrowUpRight,
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
Layers,
|
||||
Triangle,
|
||||
GitFork,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
Category,
|
||||
Horizon,
|
||||
UserSession
|
||||
} from '../types/futures';
|
||||
import { FuturesRadar } from '../components/visual/FuturesRadar';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { StatCard } from '../components/common/StatCard';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import { CATEGORY_META, CATEGORY_ORDER } from '../utils/categories';
|
||||
import { HORIZON_START, HORIZON_END } from '../utils/horizon';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ArrowLeft, FileText, Triangle, GitFork, Target } from 'lucide-react';
|
||||
|
||||
interface DashboardProps {
|
||||
currentSession: UserSession | null;
|
||||
|
|
@ -33,7 +29,6 @@ interface DashboardProps {
|
|||
}
|
||||
|
||||
export const Dashboard: React.FC<DashboardProps> = ({
|
||||
currentSession,
|
||||
items,
|
||||
dimensions,
|
||||
onSelectItem,
|
||||
|
|
@ -41,463 +36,308 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
onNavigate
|
||||
}) => {
|
||||
const [radarCategory, setRadarCategory] = useState<Category | 'all'>('all');
|
||||
const [radarHorizon, setRadarHorizon] = useState<Horizon | 'all'>('all');
|
||||
const [radarHorizon] = useState<Horizon | 'all'>('all');
|
||||
|
||||
const kpis = dataService.getExecutiveKpis();
|
||||
|
||||
// Top critical items
|
||||
const criticalItems = items
|
||||
.filter((i) => i.status === 'critical' || i.impactScore === 5)
|
||||
.slice(0, 4);
|
||||
.slice(0, 5);
|
||||
|
||||
// Top gap dimensions
|
||||
const topGaps = [...dimensions].sort((a, b) => b.gap - a.gap).slice(0, 3);
|
||||
const topGaps = [...dimensions].sort((a, b) => b.gap - a.gap).slice(0, 4);
|
||||
|
||||
// Average readiness
|
||||
const avgReadiness = Math.round(
|
||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
||||
);
|
||||
const avgReadiness = dimensions.length
|
||||
? Math.round(
|
||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) /
|
||||
dimensions.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
const countFor = (cat: Category) => items.filter((i) => i.category === cat).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
||||
{/* Persona Context Reminder */}
|
||||
<PersonaBanner currentSession={currentSession} />
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title="داشبورد"
|
||||
description={`رصد روندها، فناوریها، سیاستها و سیگنالهای آینده آموزش عالی در افق ${toPersianDigits(
|
||||
HORIZON_START
|
||||
)} تا ${toPersianDigits(HORIZON_END)}.`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => onNavigate('/reports')}>
|
||||
<FileText />
|
||||
گزارشها
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => onNavigate('/gap-analysis')}>
|
||||
تحلیل شکاف
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Top Welcome & Foresight Mission Bar */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xl font-bold">👋</span>
|
||||
<h1 className="text-2xl lg:text-3xl font-black text-slate-900 tracking-tight">
|
||||
رادار آینده دانشگاه اصفهان
|
||||
</h1>
|
||||
<span className="badge badge-teal font-bold">سال تحصیلی ۱۴۰۳-۱۴۰۴</span>
|
||||
{/* The four observation streams, plus the readiness index. */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
return (
|
||||
<StatCard
|
||||
key={cat}
|
||||
label={meta.label}
|
||||
value={countFor(cat)}
|
||||
meta="مورد رصدشده"
|
||||
accent={meta.color}
|
||||
onClick={() => onNavigate(`/radar?category=${cat}`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<StatCard
|
||||
label="آمادگی ۱۴۱۵"
|
||||
value={`${toPersianDigits(avgReadiness)}٪`}
|
||||
meta="میانگین ۱۰ ویژگی"
|
||||
icon={Target}
|
||||
accent="var(--primary)"
|
||||
onClick={() => onNavigate('/gap-analysis')}
|
||||
className="col-span-2 lg:col-span-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
دیدهبانی مستمر پیشرانها، فناوریها، سیگنالهای ضعیف و سنجش آمادگی دانشگاه در افق ۱۴۱۵
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||
{/* Radar */}
|
||||
<Card data-card className="xl:col-span-7">
|
||||
<CardHeader className="gap-3 border-b pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-[15px]">رادار آینده</CardTitle>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
چهار افق زمانی؛ فاصله از مرکز بر اساس دوری زمانی و اندازه نقطه بر
|
||||
اساس شدت اثر.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 self-start md:self-auto">
|
||||
<button
|
||||
onClick={() => onNavigate('/reports')}
|
||||
className="btn btn-secondary text-xs flex items-center gap-2 py-2 px-4 shadow-sm"
|
||||
<div className="scroll-x">
|
||||
<Tabs
|
||||
value={radarCategory}
|
||||
onValueChange={(v) => setRadarCategory(v as Category | 'all')}
|
||||
>
|
||||
<FileText size={14} className="text-slate-500" />
|
||||
<span>بولتن مدیریتی هیئت رئیسه</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('/gap-analysis')}
|
||||
className="btn btn-primary text-xs flex items-center gap-2 py-2 px-4 shadow-sm"
|
||||
>
|
||||
<span>تحلیل جامع شکاف ۱۴۱۵</span>
|
||||
<ArrowLeft size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="all" className="text-[12px]">
|
||||
همه
|
||||
</TabsTrigger>
|
||||
{CATEGORY_ORDER.map((cat) => (
|
||||
<TabsTrigger key={cat} value={cat} className="text-[12px]">
|
||||
{CATEGORY_META[cat].label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* 6 Bento Pastel KPI Cards (Inspired by Images 1, 2, 3, 5) */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3.5">
|
||||
{/* KPI 1: Active Signals (Blush Pink Card) */}
|
||||
<div
|
||||
onClick={() => onNavigate('/radar?category=weak_signal')}
|
||||
className="intel-card interactive bento-card-pink p-4 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-rose-700">سیگنالهای آینده</span>
|
||||
<div className="circle-action-btn text-rose-600">
|
||||
<ArrowUpRight size={17} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(kpis.totalSignals)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-rose-800 font-semibold pt-1 border-t border-rose-200/60">
|
||||
<span>۹ سیگنال فعال</span>
|
||||
<Sparkles size={13} className="text-rose-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 2: Emerging Trends (Soft Sky Blue Card) */}
|
||||
<div
|
||||
onClick={() => onNavigate('/radar?category=trend')}
|
||||
className="intel-card interactive bento-card-blue p-4 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-sky-700">روندهای نوظهور</span>
|
||||
<div className="circle-action-btn text-sky-600">
|
||||
<ArrowUpRight size={17} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(kpis.emergingTrends)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-sky-800 font-semibold pt-1 border-t border-sky-200/60">
|
||||
<span>رانندههای پداگوژی</span>
|
||||
<TrendingUp size={13} className="text-sky-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 3: Key Technologies (Soft Mint Card) */}
|
||||
<div
|
||||
onClick={() => onNavigate('/radar?category=technology')}
|
||||
className="intel-card interactive bento-card-mint p-4 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-emerald-700">فناوریهای کلیدی</span>
|
||||
<div className="circle-action-btn text-emerald-600">
|
||||
<ArrowUpRight size={17} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(kpis.criticalTech)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-emerald-800 font-semibold pt-1 border-t border-emerald-200/60">
|
||||
<span>اثرگذاری اخلالگر</span>
|
||||
<Cpu size={13} className="text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 4: Policy Shifts (Soft Buttery Yellow Card) */}
|
||||
<div
|
||||
onClick={() => onNavigate('/radar?category=policy')}
|
||||
className="intel-card interactive bento-card-yellow p-4 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-amber-800">تحولات قانونی</span>
|
||||
<div className="circle-action-btn text-amber-600">
|
||||
<ArrowUpRight size={17} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(kpis.policyChanges)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-amber-900 font-semibold pt-1 border-t border-amber-200/60">
|
||||
<span>آییننامهها و مالیات</span>
|
||||
<BookOpen size={13} className="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 5: Labor Market Disruption (Soft Lilac Purple Card) */}
|
||||
<div
|
||||
onClick={() => onNavigate('/labor-market')}
|
||||
className="intel-card interactive bento-card-purple p-4 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-purple-700">تغییرات بازار کار</span>
|
||||
<div className="circle-action-btn text-purple-600">
|
||||
<ArrowUpRight size={17} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(kpis.highImpactLabor)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-purple-800 font-semibold pt-1 border-t border-purple-200/60">
|
||||
<span>شغل در دگرگونی</span>
|
||||
<Briefcase size={13} className="text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 6: Average 1415 Readiness (Crisp White Card with Mini Arc/Bar) */}
|
||||
<div
|
||||
onClick={() => onNavigate('/gap-analysis')}
|
||||
className="intel-card interactive p-4 flex flex-col justify-between border-teal-200"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-teal-800">آمادگی افق ۱۴۱۵</span>
|
||||
<div className="circle-action-btn text-teal-600">
|
||||
<ArrowUpRight size={17} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2">
|
||||
<span className="kpi-number text-3xl font-black text-teal-600">
|
||||
{toPersianDigits(avgReadiness)}٪
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
|
||||
<div className="bg-teal-500 h-full rounded-full" style={{ width: `${avgReadiness}%` }}></div>
|
||||
</div>
|
||||
<span className="text-[10.5px] text-slate-400 font-medium mt-1">فاصله تا وضع مطلوب: ۵۰٪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Centerpiece: Split Layout (Futures Radar on Left + Readiness & Alerts on Right) */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
|
||||
{/* Main Left Column: Interactive Futures Radar Bento Card (7 Cols) */}
|
||||
<div className="xl:col-span-7 intel-card flex flex-col p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-100">
|
||||
<div>
|
||||
<h3 className="text-slate-900 font-black text-base flex items-center gap-2">
|
||||
<Target size={18} className="text-teal-600" />
|
||||
<span>رادار تعاملی آیندهپژوهی آموزش عالی</span>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
موقعیتیابی بر اساس ۴ افق زمانی، شدت اثر و دستهبندی موضوعی
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pill Filter Switcher (Inspired by Image 4 Top Nav Pills) */}
|
||||
<div className="flex items-center bg-slate-100 p-1 rounded-full text-xs">
|
||||
<button
|
||||
onClick={() => setRadarCategory('all')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
radarCategory === 'all'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
همه ({toPersianDigits(items.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRadarCategory('trend')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
radarCategory === 'trend'
|
||||
? 'bg-sky-600 text-white shadow-sm'
|
||||
: 'text-sky-700 hover:text-sky-900'
|
||||
}`}
|
||||
>
|
||||
روندها
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRadarCategory('technology')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
radarCategory === 'technology'
|
||||
? 'bg-teal-600 text-white shadow-sm'
|
||||
: 'text-teal-700 hover:text-teal-900'
|
||||
}`}
|
||||
>
|
||||
فناوریها
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRadarCategory('policy')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
radarCategory === 'policy'
|
||||
? 'bg-amber-600 text-white shadow-sm'
|
||||
: 'text-amber-700 hover:text-amber-900'
|
||||
}`}
|
||||
>
|
||||
سیاستها
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRadarCategory('weak_signal')}
|
||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
||||
radarCategory === 'weak_signal'
|
||||
? 'bg-rose-600 text-white shadow-sm'
|
||||
: 'text-rose-700 hover:text-rose-900'
|
||||
}`}
|
||||
>
|
||||
سیگنالها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Radar Visualization Canvas */}
|
||||
<div className="py-2 flex items-center justify-center">
|
||||
<CardContent className="flex flex-col items-center gap-3 pt-4">
|
||||
<FuturesRadar
|
||||
items={items}
|
||||
selectedCategory={radarCategory}
|
||||
selectedHorizon={radarHorizon}
|
||||
onSelectItem={onSelectItem}
|
||||
height={520}
|
||||
height={480}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Radar Action Footer */}
|
||||
<div className="pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>برای بررسی پرونده تحلیلی، روی هر نقطه کلیک کنید.</span>
|
||||
<div className="flex w-full items-center justify-between border-t pt-3 text-[12px]">
|
||||
<span className="text-muted-foreground">
|
||||
برای دیدن پرونده، روی هر نقطه کلیک کنید.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onNavigate('/radar')}
|
||||
className="text-teal-600 font-bold hover:underline flex items-center gap-1"
|
||||
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
|
||||
>
|
||||
نمایش تمامصفحه رادار با ماتریس تحلیلی
|
||||
<ArrowLeft size={13} />
|
||||
نمای کامل
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right Column: Readiness Arc Meter & Strategic Schedule (5 Cols) */}
|
||||
<div className="xl:col-span-5 flex flex-col gap-5">
|
||||
{/* Card 1: 1415 Readiness Arc & Donut Meter (Inspired by Image 2 TVL & Image 3 Customer Segments) */}
|
||||
<div className="intel-card p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
|
||||
<div>
|
||||
<h4 className="text-slate-900 font-black text-sm flex items-center gap-2">
|
||||
<Target size={17} className="text-teal-600" />
|
||||
<span>شاخص بلوغ و آمادگی دانشگاه در سال ۱۴۱۵</span>
|
||||
</h4>
|
||||
<p className="text-slate-500 text-[11px] mt-0.5">ارزیابی ۱۰ کلانویژگی مصوب چشمانداز</p>
|
||||
</div>
|
||||
<span className="badge badge-teal font-bold">وضع موجود</span>
|
||||
</div>
|
||||
{/* Readiness + critical items */}
|
||||
<div className="flex flex-col gap-4 xl:col-span-5">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-[15px]">آمادگی افق ۱۴۱۵</CardTitle>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
نسبت وضع موجود به وضع مطلوب در ۱۰ ویژگی دانشگاه.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{/* Arc Progress Visualization */}
|
||||
<div className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="relative w-36 h-36 shrink-0 flex items-center justify-center">
|
||||
{/* SVG Semi-Circle Arc Meter */}
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
stroke="#f1f5f9"
|
||||
strokeWidth="10"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
stroke="#00b894"
|
||||
strokeWidth="10"
|
||||
strokeDasharray={`${(avgReadiness / 100) * 251.2} 251.2`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="kpi-number text-2xl font-black text-slate-900">{toPersianDigits(avgReadiness)}٪</span>
|
||||
<span className="text-[10px] text-slate-400 font-bold">کل آمادگی</span>
|
||||
<CardContent className="pt-4">
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<ReadinessDial value={avgReadiness} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
بزرگترین شکافها
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Priority Gaps breakdown */}
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold text-slate-700">بزرگترین شکافهای نیازمند مداخله:</span>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{topGaps.map((dim) => (
|
||||
<div
|
||||
<button
|
||||
key={dim.id}
|
||||
onClick={() => onSelectDimension(dim)}
|
||||
className="p-2 bg-slate-50 hover:bg-slate-100 rounded-xl cursor-pointer transition-colors flex items-center justify-between text-xs border border-slate-200/70"
|
||||
className="group flex items-center gap-2 text-right"
|
||||
>
|
||||
<span className="font-bold text-slate-800">{dim.title}</span>
|
||||
<span className="text-rose-600 font-black">
|
||||
شکاف: {toPersianDigits(dim.gap)}
|
||||
<span className="w-14 shrink-0 text-[11px] text-muted-foreground">
|
||||
{dim.shortTag}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={dim.readinessPercentage}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="tnum w-7 shrink-0 text-[11px] font-semibold text-negative">
|
||||
{toPersianDigits(dim.gap)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => onNavigate('/gap-analysis')}
|
||||
className="w-full py-2 bg-slate-100 hover:bg-slate-200/80 text-slate-800 font-bold rounded-xl text-xs flex items-center justify-center gap-1.5 transition-colors"
|
||||
>
|
||||
<span>مشاهده نمودار عنکبوتی ۱۰ بعدی و جزئیات</span>
|
||||
<ArrowLeft size={13} />
|
||||
</button>
|
||||
</div>
|
||||
نمودار عنکبوتی ۱۰ بعدی
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Immediate Foresight Alerts List */}
|
||||
<div className="intel-card p-5 flex flex-col gap-3.5">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-rose-50 border border-rose-200 text-rose-600 flex items-center justify-center">
|
||||
<AlertTriangle size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-slate-900 font-extrabold text-sm">پیشرانهای با فوریت بحرانی (افق اکنون)</h4>
|
||||
<span className="text-slate-400 text-[11px]">نیازمند تصمیم در شورای دانشگاه</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-rose">فوری</span>
|
||||
</div>
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-[15px]">نیازمند تصمیم</CardTitle>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
مواردی با بیشترین شدت اثر در افق اکنون.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{/* List of Critical Items as Pastel Pill Rows */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{criticalItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
<CardContent className="pt-3">
|
||||
<ul className="divide-y">
|
||||
{criticalItems.map((item) => {
|
||||
const meta = CATEGORY_META[item.category];
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
onClick={() => onSelectItem(item)}
|
||||
className="p-3 bg-slate-50 hover:bg-white hover:border-teal-300 border border-slate-200/80 rounded-2xl cursor-pointer transition-all flex flex-col gap-1 shadow-sm"
|
||||
className="group w-full py-2.5 text-right"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-slate-900 text-xs leading-snug">{item.title}</span>
|
||||
<span className="text-[10px] bg-rose-100 text-rose-700 font-bold px-2 py-0.5 rounded-full shrink-0">
|
||||
تأثیر: {toPersianDigits(item.impactScore)}/۵
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="line-clamp-1 text-[12.5px] font-semibold transition-colors group-hover:text-primary">
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className="tnum shrink-0 rounded px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||
style={{ background: meta.soft, color: meta.color }}
|
||||
>
|
||||
{toPersianDigits(item.impactScore)}/۵
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-[11px] line-clamp-1 mt-0.5 leading-relaxed">
|
||||
<p className="mt-0.5 line-clamp-1 text-[11.5px] text-muted-foreground">
|
||||
{item.whyItMatters}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bento Row: Futures Triangle + Strategic Impact Value Chain */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Futures Triangle Teaser */}
|
||||
<div
|
||||
{/* Two analytical views that sit outside the radar. */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<NavCard
|
||||
icon={Triangle}
|
||||
title="مثلث آینده"
|
||||
description="کشش آینده، فشارهای حال و وزن گذشته — ۱۵ نیروی مؤثر بر مسیر دانشگاه."
|
||||
onClick={() => onNavigate('/futures-triangle')}
|
||||
className="intel-card interactive bento-card-purple p-6 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-purple-200/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-2xl bg-white text-purple-600 flex items-center justify-center shadow-sm">
|
||||
<Triangle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-purple-700 block mb-0.5">پویایی تعادل نیروها</span>
|
||||
<h4 className="text-slate-900 font-black text-base">مثلث هوشمندی آینده دانشگاه اصفهان</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="circle-action-btn text-purple-700">
|
||||
<ArrowUpRight size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-700 text-xs leading-relaxed my-3">
|
||||
سنجش برآیند <strong>کشش آینده</strong> (دانشگاه هوشمند و مادامالعمر)، <strong>فشارهای حال</strong> (شتاب هوش مصنوعی و بحران آب اصفهان) و <strong>وزن گذشته</strong> (دیوانسالاری کهنه و ساختار سنتی)
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-purple-200/60 text-xs font-bold text-purple-900">
|
||||
<span>کاوش تعاملی ۱۵ نیروی کلیدی در مثلث آینده</span>
|
||||
<ArrowLeft size={13} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategic Disruption Chain Teaser */}
|
||||
<div
|
||||
/>
|
||||
<NavCard
|
||||
icon={GitFork}
|
||||
title="شبکه روابط"
|
||||
description="زنجیره اثر: فناوری، آموزش، مهارت، شغل و در نهایت شکاف قابلیت."
|
||||
onClick={() => onNavigate('/network')}
|
||||
className="intel-card interactive bento-card-mint p-6 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-emerald-200/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-10 h-10 rounded-2xl bg-white text-emerald-600 flex items-center justify-center shadow-sm">
|
||||
<GitFork size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-teal-700 block mb-0.5">زنجیره ارزش و سناریوسازی</span>
|
||||
<h4 className="text-slate-900 font-black text-base">شبکه روابط راهبردی: از سیگنال بیرونی تا شکاف قابلیت</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="circle-action-btn text-emerald-700">
|
||||
<ArrowUpRight size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-700 text-xs leading-relaxed my-3">
|
||||
ردگیری زنجیره اخلال: فناوری نوین ← دگرگونی آموزش ← مهارتهای موردنیاز بازار کار ← بازآفرینی مشاغل ← بعد دانشگاه اصفهان ۱۴۱۵ ← شکاف عملکردی
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-emerald-200/60 text-xs font-bold text-emerald-900">
|
||||
<span>مشاهده گراف شبکه و روابط متقابل</span>
|
||||
<ArrowLeft size={13} />
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Circular readiness gauge. */
|
||||
const ReadinessDial: React.FC<{ value: number }> = ({ value }) => {
|
||||
const r = 40;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
|
||||
return (
|
||||
<div className="relative size-28 shrink-0">
|
||||
<svg viewBox="0 0 100 100" className="size-full -rotate-90">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="9"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="9"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(value / 100) * circumference} ${circumference}`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="kpi-number text-xl font-bold">
|
||||
{toPersianDigits(value)}٪
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">آمادگی</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NavCard: React.FC<{
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}> = ({ icon: Icon, title, description, onClick }) => (
|
||||
<Card
|
||||
data-card
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className="group cursor-pointer p-4 transition-[box-shadow,border-color,transform] duration-200 hover:border-foreground/15 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<Icon className="size-4.5" strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-[14px] font-bold">{title}</h3>
|
||||
<ArrowLeft className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:-translate-x-0.5" />
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,72 +1,25 @@
|
|||
import React from 'react';
|
||||
import { FuturesForce } from '../types/futures';
|
||||
import { TriangleView } from '../components/visual/TriangleView';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import { Sparkles, TrendingUp, ShieldAlert } from 'lucide-react';
|
||||
|
||||
interface FuturesTrianglePageProps {
|
||||
forces: FuturesForce[];
|
||||
}
|
||||
|
||||
export const FuturesTrianglePage: React.FC<FuturesTrianglePageProps> = ({ forces }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
||||
{/* Clean Executive Header (Zero AI Slop) */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-slate-400 text-xs font-bold">برنامهریزی راهبردی / تحلیل تعادل نیروها</span>
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-3xl font-black text-slate-900 tracking-tight">
|
||||
مثلث هوشمندی آینده دانشگاه اصفهان
|
||||
</h1>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
سنجش برآیند کشش آینده، فشارهای جاری زمان حال و وزن لنگرهای ساختاری گذشته
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-slate-100 border border-slate-200 text-slate-800 px-3.5 py-1.5 rounded-full font-bold shadow-sm">
|
||||
{toPersianDigits(forces.length)} نیروی کلیدی در تعامل
|
||||
export const FuturesTrianglePage: React.FC<FuturesTrianglePageProps> = ({ forces }) => (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title="مثلث آینده"
|
||||
description="سه دسته نیرویی که مسیر دانشگاه را تعیین میکنند: کشش آینده، فشارهای حال و وزن گذشته."
|
||||
actions={
|
||||
<span className="tnum rounded-md border bg-muted/50 px-2.5 py-1.5 text-[12px] text-muted-foreground">
|
||||
{toPersianDigits(forces.length)} نیرو
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 3 Executive Pastel Bento Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div className="intel-card bento-card-purple p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-purple-950 font-black mb-1">
|
||||
<Sparkles size={16} className="text-purple-600" />
|
||||
<span className="text-sm">کشش آینده — ۵ نیرو</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-[11.5px] leading-relaxed">
|
||||
تصویر مطلوب، دانشگاههای نسل پنجم، آموزش هوشمند و یادگیری مادامالعمر به عنوان نیروهای جاذب.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="intel-card bento-card-blue p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-sky-950 font-black mb-1">
|
||||
<TrendingUp size={16} className="text-sky-600" />
|
||||
<span className="text-sm">فشارهای زمان حال — ۵ نیرو</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-[11.5px] leading-relaxed">
|
||||
شتاب هوش مصنوعی، انقباض هرم جمعیتی، بحران منابع و آب اصفهان و کسری بودجه عمومی.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="intel-card bento-card-pink p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-rose-950 font-black mb-1">
|
||||
<ShieldAlert size={16} className="text-rose-600" />
|
||||
<span className="text-sm">وزن گذشته — ۵ نیرو</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-[11.5px] leading-relaxed">
|
||||
لختی دیوانسالاری، آییننامههای سنتی ارتقا، ابنیه فرسوده و مقاومت فرهنگی در برابر دگرگونی.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sleek Geometric Triangle Visualizer */}
|
||||
<TriangleView forces={forces} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import React, { useState } from 'react';
|
||||
import { University1415Dimension } from '../types/futures';
|
||||
import { SpiderChart } from '../components/visual/SpiderChart';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { StatCard } from '../components/common/StatCard';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import { HORIZON_END } from '../utils/horizon';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Target,
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
Award,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
ArrowUpRight
|
||||
} from 'lucide-react';
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Target, AlertTriangle, Award } from 'lucide-react';
|
||||
|
||||
interface GapAnalysisPageProps {
|
||||
dimensions: University1415Dimension[];
|
||||
|
|
@ -21,254 +28,186 @@ export const GapAnalysisPage: React.FC<GapAnalysisPageProps> = ({
|
|||
dimensions,
|
||||
onSelectDimension
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'spider' | 'breakdown'>('spider');
|
||||
const [tab, setTab] = useState<'spider' | 'table'>('spider');
|
||||
|
||||
// Calculations
|
||||
const sortedByGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||
const sortedByScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
|
||||
if (!dimensions.length) return null;
|
||||
|
||||
const largestGaps = sortedByGap.slice(0, 4);
|
||||
const strongestDims = sortedByScore.slice(0, 3);
|
||||
const byGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||
const byScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
|
||||
|
||||
const avgReadiness = Math.round(
|
||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
||||
);
|
||||
|
||||
const widestGap = byGap[0];
|
||||
const strongest = byScore[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-rose">سنجش بلوغ سازمانی</span>
|
||||
<span className="text-slate-400 text-xs font-semibold">ارزیابی شکاف آمادگی افق ۱۴۱۵</span>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl lg:text-3xl text-slate-900">
|
||||
تحلیل جامع شکاف راهبردی (Gap Analysis)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-500 text-xs mt-1">
|
||||
مقایسه چندبُعدی وضعیت موجود دانشگاه اصفهان با اهداف آرمانی دانشگاه آینده در سال ۱۴۱۵
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title="تحلیل شکاف"
|
||||
description={`فاصله وضع موجود دانشگاه تا هدف ${toPersianDigits(
|
||||
HORIZON_END
|
||||
)} در ۱۰ ویژگی.`}
|
||||
actions={
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="spider">نمودار عنکبوتی</TabsTrigger>
|
||||
<TabsTrigger value="table">جدول</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Pill Tab Switcher */}
|
||||
<div className="flex items-center bg-slate-100 border border-slate-200 p-1 rounded-full text-xs shadow-sm">
|
||||
<button
|
||||
onClick={() => setActiveTab('spider')}
|
||||
className={`px-4 py-1.5 rounded-full transition-all font-bold ${
|
||||
activeTab === 'spider'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
نمودار عنکبوتی ۱۰ بعدی
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('breakdown')}
|
||||
className={`px-4 py-1.5 rounded-full transition-all font-bold ${
|
||||
activeTab === 'breakdown'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
ماتریس اولویتبندی مداخله
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards Row (Bento Pastel Cards matching Image 1 & 3) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Overall Readiness (Mint) */}
|
||||
<div className="intel-card bento-card-mint p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-emerald-800 font-bold mb-2">
|
||||
<span>شاخص کل آمادگی دانشگاه</span>
|
||||
<Target size={17} className="text-emerald-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-black text-slate-900">
|
||||
{toPersianDigits(avgReadiness)}٪
|
||||
</div>
|
||||
<div className="w-full h-2 bg-emerald-200/80 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-600 rounded-full"
|
||||
style={{ width: `${avgReadiness}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-[11px] text-emerald-900 font-semibold mt-1">فاصله تا وضع مطلوب: ۵۴٪</span>
|
||||
</div>
|
||||
|
||||
{/* Largest Gap (Pink) */}
|
||||
<div className="intel-card bento-card-pink p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-rose-800 font-bold mb-1">
|
||||
<span>بزرگترین شکاف عملکردی</span>
|
||||
<AlertTriangle size={17} className="text-rose-600" />
|
||||
</div>
|
||||
<div className="text-sm font-extrabold text-slate-900 mt-1">
|
||||
{largestGaps[0].title}
|
||||
</div>
|
||||
<div className="text-xl font-black text-rose-700 mt-1">
|
||||
شکاف: {toPersianDigits(largestGaps[0].gap)} نمره
|
||||
</div>
|
||||
<span className="text-[11px] text-rose-800 font-semibold">
|
||||
آمادگی: {toPersianDigits(largestGaps[0].readinessPercentage)}٪
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Largest Gap (Yellow) */}
|
||||
<div className="intel-card bento-card-yellow p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-amber-800 font-bold mb-1">
|
||||
<span>دومین اولویت مداخله فوری</span>
|
||||
<TrendingUp size={17} className="text-amber-600" />
|
||||
</div>
|
||||
<div className="text-sm font-extrabold text-slate-900 mt-1">
|
||||
{largestGaps[1].title}
|
||||
</div>
|
||||
<div className="text-xl font-black text-amber-800 mt-1">
|
||||
شکاف: {toPersianDigits(largestGaps[1].gap)} نمره
|
||||
</div>
|
||||
<span className="text-[11px] text-amber-900 font-semibold">
|
||||
آمادگی: {toPersianDigits(largestGaps[1].readinessPercentage)}٪
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Strongest Dimension (Purple) */}
|
||||
<div className="intel-card bento-card-purple p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-purple-800 font-bold mb-1">
|
||||
<span>نقطه قوت و مزیت کنونی</span>
|
||||
<Award size={17} className="text-purple-600" />
|
||||
</div>
|
||||
<div className="text-sm font-extrabold text-slate-900 mt-1">
|
||||
{strongestDims[0].title}
|
||||
</div>
|
||||
<div className="text-xl font-black text-purple-900 mt-1">
|
||||
نمره: {toPersianDigits(strongestDims[0].currentScore)} از ۱۰
|
||||
</div>
|
||||
<span className="text-[11px] text-purple-800 font-semibold">
|
||||
آمادگی: {toPersianDigits(strongestDims[0].readinessPercentage)}٪
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Spider Chart Visualization Container */}
|
||||
{activeTab === 'spider' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
|
||||
<div className="xl:col-span-8 intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col items-center justify-center">
|
||||
<SpiderChart
|
||||
dimensions={dimensions}
|
||||
onSelectDimension={onSelectDimension}
|
||||
height={580}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="آمادگی کل"
|
||||
value={`${toPersianDigits(avgReadiness)}٪`}
|
||||
meta={`فاصله تا هدف ${toPersianDigits(100 - avgReadiness)}٪`}
|
||||
icon={Target}
|
||||
accent="var(--primary)"
|
||||
/>
|
||||
<StatCard
|
||||
label="بزرگترین شکاف"
|
||||
value={toPersianDigits(widestGap.gap)}
|
||||
meta={widestGap.title}
|
||||
icon={AlertTriangle}
|
||||
accent="var(--negative)"
|
||||
onClick={() => onSelectDimension(widestGap)}
|
||||
/>
|
||||
<StatCard
|
||||
label="دومین شکاف"
|
||||
value={toPersianDigits(byGap[1].gap)}
|
||||
meta={byGap[1].title}
|
||||
icon={AlertTriangle}
|
||||
accent="var(--cat-policy)"
|
||||
onClick={() => onSelectDimension(byGap[1])}
|
||||
/>
|
||||
<StatCard
|
||||
label="قویترین بعد"
|
||||
value={`${toPersianDigits(strongest.currentScore)}/۱۰`}
|
||||
meta={strongest.title}
|
||||
icon={Award}
|
||||
accent="var(--positive)"
|
||||
onClick={() => onSelectDimension(strongest)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Strategic Gap Diagnosis & Recommendations */}
|
||||
<div className="xl:col-span-4 flex flex-col gap-4">
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col gap-3">
|
||||
<h3 className="text-slate-900 font-extrabold text-sm border-b border-slate-100 pb-2.5">
|
||||
رتبهبندی شکافهای دهگانه:
|
||||
</h3>
|
||||
{tab === 'spider' && (
|
||||
<div className="mt-4 grid animate-in grid-cols-1 gap-4 fade-in duration-200 xl:grid-cols-12">
|
||||
<Card data-card className="xl:col-span-8">
|
||||
<CardContent className="flex items-center justify-center p-3 sm:p-6">
|
||||
<SpiderChart
|
||||
dimensions={dimensions}
|
||||
onSelectDimension={onSelectDimension}
|
||||
height={540}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedByGap.map((dim, idx) => (
|
||||
<div
|
||||
key={dim.id}
|
||||
<Card data-card className="xl:col-span-4">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">رتبهبندی شکافها</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
از بیشترین فاصله تا کمترین.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ul className="divide-y">
|
||||
{byGap.map((dim, idx) => (
|
||||
<li key={dim.id}>
|
||||
<button
|
||||
onClick={() => onSelectDimension(dim)}
|
||||
className="bg-slate-50 hover:bg-slate-100 border border-slate-200 p-2.5 rounded-2xl cursor-pointer transition-all flex items-center justify-between text-xs"
|
||||
className="group flex w-full items-center gap-2.5 py-2 text-right"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-5 h-5 rounded-full bg-slate-200 text-slate-700 flex items-center justify-center text-[10px] font-bold">
|
||||
<span className="tnum w-5 shrink-0 text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(idx + 1)}
|
||||
</span>
|
||||
<span className="font-extrabold text-slate-800">{dim.shortTag}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-amber-700 font-bold">
|
||||
{toPersianDigits(dim.currentScore)}/۱۰
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-semibold transition-colors group-hover:text-primary">
|
||||
{dim.shortTag}
|
||||
</span>
|
||||
<span className="text-rose-700 font-bold bg-rose-50 border border-rose-200 px-2 py-0.5 rounded-full text-[11px]">
|
||||
شکاف: {toPersianDigits(dim.gap)}
|
||||
<Progress
|
||||
value={dim.readinessPercentage}
|
||||
className="mt-1 h-1.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="tnum shrink-0 rounded px-1.5 py-0.5 text-[11px] font-semibold"
|
||||
style={{
|
||||
background: 'var(--negative-soft)',
|
||||
color: 'var(--negative)'
|
||||
}}
|
||||
>
|
||||
{toPersianDigits(dim.gap)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="intel-card bento-card-mint p-5 rounded-3xl flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-bold text-xs">
|
||||
<Lightbulb size={16} className="text-emerald-600" />
|
||||
<span>فرمان اجرایی اولویتدار:</span>
|
||||
</div>
|
||||
<p className="text-slate-700 text-xs leading-relaxed">
|
||||
برای کاهش شکاف ۵.۳ نمرهای در بعد هوشمصنوعیبنیان، نیاز به خرید ۳۰ سرور پردازشی GPU بومی و تغییر نظام ارزیابی امتحانات در ۳۰ گروه آموزشی تا بهمن ۱۴۰۳ است.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Detailed Matrix Table Breakdown */}
|
||||
{activeTab === 'breakdown' && (
|
||||
<div className="intel-card overflow-x-auto p-0 bg-white border border-slate-200 rounded-3xl shadow-sm animate-fadeIn">
|
||||
<table className="w-full text-right text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-slate-500 border-b border-slate-200">
|
||||
<th className="p-4 font-bold">کلانویژگی دانشگاه ۱۴۱۵</th>
|
||||
<th className="p-4 font-bold">وضع موجود (۱-۱۰)</th>
|
||||
<th className="p-4 font-bold">هدف ۱۴۱۵ (۱-۱۰)</th>
|
||||
<th className="p-4 font-bold">میزان شکاف</th>
|
||||
<th className="p-4 font-bold">درصد آمادگی</th>
|
||||
<th className="p-4 font-bold">اقدام راهبردی اولویتدار</th>
|
||||
<th className="p-4 font-bold">اقدام</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{tab === 'table' && (
|
||||
<Card data-card className="mt-4 animate-in overflow-hidden p-0 fade-in duration-200">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-right">ویژگی</TableHead>
|
||||
<TableHead className="text-right">موجود</TableHead>
|
||||
<TableHead className="text-right">هدف</TableHead>
|
||||
<TableHead className="text-right">شکاف</TableHead>
|
||||
<TableHead className="text-right">آمادگی</TableHead>
|
||||
<TableHead className="text-right">اقدام پیشنهادی</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dimensions.map((dim) => (
|
||||
<tr
|
||||
<TableRow
|
||||
key={dim.id}
|
||||
onClick={() => onSelectDimension(dim)}
|
||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<td className="p-4 font-extrabold text-slate-900">
|
||||
<TableCell className="font-semibold">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: dim.pillarColor }}
|
||||
></span>
|
||||
className="cat-dot"
|
||||
style={{ background: dim.pillarColor }}
|
||||
/>
|
||||
{dim.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-amber-700 font-bold">{toPersianDigits(dim.currentScore)}</td>
|
||||
<td className="p-4 text-teal-700 font-bold">{toPersianDigits(dim.targetScore)}</td>
|
||||
<td className="p-4 text-rose-700 font-extrabold">
|
||||
</TableCell>
|
||||
<TableCell className="tnum">
|
||||
{toPersianDigits(dim.currentScore)}
|
||||
</TableCell>
|
||||
<TableCell className="tnum text-primary">
|
||||
{toPersianDigits(dim.targetScore)}
|
||||
</TableCell>
|
||||
<TableCell className="tnum font-semibold text-negative">
|
||||
{toPersianDigits(dim.gap)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${dim.readinessPercentage}%`,
|
||||
backgroundColor: dim.pillarColor
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-slate-700 font-bold">{toPersianDigits(dim.readinessPercentage)}٪</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-slate-600 max-w-sm line-clamp-1">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2">
|
||||
<Progress value={dim.readinessPercentage} className="h-1.5 w-16" />
|
||||
<span className="tnum text-[11.5px]">
|
||||
{toPersianDigits(dim.readinessPercentage)}٪
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm text-muted-foreground">
|
||||
<span className="line-clamp-1">
|
||||
{dim.strategicRecommendations[0]}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<button className="text-teal-600 font-bold hover:underline">
|
||||
جزئیات بعد ↗
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,30 +6,19 @@ import {
|
|||
SkillEntity
|
||||
} from '../types/futures';
|
||||
import { ImpactGauges } from '../components/visual/ImpactGauges';
|
||||
import {
|
||||
toPersianDigits,
|
||||
getCategoryLabelFa,
|
||||
getCategoryColor,
|
||||
getHorizonLabelFa
|
||||
} from '../utils/persianNumbers';
|
||||
import { toPersianDigits, getHorizonLabelFa } from '../utils/persianNumbers';
|
||||
import { CATEGORY_META } from '../utils/categories';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Zap,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Building,
|
||||
CheckCircle2,
|
||||
AlertOctagon,
|
||||
ShieldCheck,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Target,
|
||||
Briefcase,
|
||||
Brain,
|
||||
Layers,
|
||||
ArrowUpRight
|
||||
Brain
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ItemDetailPageProps {
|
||||
|
|
@ -43,6 +32,12 @@ interface ItemDetailPageProps {
|
|||
onSelectDimension: (dim: University1415Dimension) => void;
|
||||
}
|
||||
|
||||
const CONFIDENCE_LABELS: Record<string, string> = {
|
||||
high: 'بالا',
|
||||
medium: 'متوسط',
|
||||
low: 'پایین'
|
||||
};
|
||||
|
||||
export const ItemDetailPage: React.FC<ItemDetailPageProps> = ({
|
||||
item,
|
||||
allItems,
|
||||
|
|
@ -53,323 +48,324 @@ export const ItemDetailPage: React.FC<ItemDetailPageProps> = ({
|
|||
onSelectItem,
|
||||
onSelectDimension
|
||||
}) => {
|
||||
const catColor = getCategoryColor(item.category);
|
||||
const meta = CATEGORY_META[item.category];
|
||||
|
||||
// Trace related items
|
||||
const relatedItems = allItems.filter((i) => item.relatedItemIds?.includes(i.id));
|
||||
const relatedDimensions = dimensions.filter((d) => item.relatedDimensionIds?.includes(d.id));
|
||||
const relatedDimensions = dimensions.filter((d) =>
|
||||
item.relatedDimensionIds?.includes(d.id)
|
||||
);
|
||||
const relatedJobs = jobs.filter((j) => item.relatedJobIds?.includes(j.id));
|
||||
const relatedSkills = skills.filter((s) => item.relatedSkillIds?.includes(s.id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-fadeIn pb-12">
|
||||
{/* Back Button & Top Navigation */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-200">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="btn btn-secondary text-xs flex items-center gap-2 py-2 px-4 shadow-sm"
|
||||
>
|
||||
<ArrowRight size={15} />
|
||||
<span>بازگشت به رادار هوشمندی</span>
|
||||
</button>
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-3 -ms-2">
|
||||
<ArrowRight />
|
||||
بازگشت
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Overview */}
|
||||
<Card data-card className="mb-4 overflow-hidden p-0">
|
||||
<span
|
||||
className="text-xs font-bold px-3 py-1 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${catColor}15`,
|
||||
color: catColor
|
||||
}}
|
||||
aria-hidden
|
||||
className="block h-0.5 w-full"
|
||||
style={{ background: meta.color }}
|
||||
/>
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11.5px] text-muted-foreground">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 font-semibold"
|
||||
style={{ color: meta.color }}
|
||||
>
|
||||
{getCategoryLabelFa(item.category)}
|
||||
<span className="cat-dot" style={{ background: meta.color }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="badge badge-gray font-mono">شناسه: {item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1. OVERVIEW SECTION (Bento Card) */}
|
||||
<div
|
||||
className="intel-card bg-white border border-slate-200 rounded-3xl p-6 lg:p-8 shadow-sm"
|
||||
style={{ borderRight: `5px solid ${catColor}` }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-500 font-semibold mb-3">
|
||||
<span className="flex items-center gap-1.5 text-teal-700 font-bold">
|
||||
<Zap size={14} className="text-teal-600" />
|
||||
شدت اثر کلی: {toPersianDigits(item.impactScore)} از ۵
|
||||
<span className="tnum">
|
||||
شدت اثر {toPersianDigits(item.impactScore)}/۵
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<Clock size={14} className="text-slate-400" />
|
||||
افق زمانی: {getHorizonLabelFa(item.horizon)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<Globe size={14} className="text-slate-400" />
|
||||
دامنه جغرافیایی: {item.geographicScope}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1.5 text-amber-700">
|
||||
<HelpCircle size={14} className="text-amber-500" />
|
||||
عدم قطعیت: {toPersianDigits(item.uncertaintyScore)} از ۵
|
||||
<span>{getHorizonLabelFa(item.horizon)}</span>
|
||||
<span>{item.geographicScope}</span>
|
||||
<span className="tnum">
|
||||
عدم قطعیت {toPersianDigits(item.uncertaintyScore)}/۵
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-slate-900 font-black text-xl lg:text-2xl leading-tight mb-3">
|
||||
{item.title}
|
||||
</h1>
|
||||
<h1 className="text-lg font-bold leading-relaxed sm:text-xl">{item.title}</h1>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 p-4 rounded-2xl text-xs text-slate-700 leading-relaxed">
|
||||
<span className="text-slate-900 font-bold block mb-1">خلاصه مدیریتی (Executive Summary):</span>
|
||||
<p className="mt-3 rounded-lg border bg-muted/40 p-3.5 text-[12.5px] leading-relaxed">
|
||||
{item.executiveSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 2. WHY DOES IT MATTER & RELEVANCE */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-teal-700 font-extrabold text-sm mb-2">
|
||||
<Lightbulb size={17} className="text-teal-600" />
|
||||
<span>چرا برای آموزش عالی اهمیت حیاتی دارد؟ (Why It Matters)</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-xs leading-relaxed">
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">چرا اهمیت دارد؟</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{item.whyItMatters}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-teal-50 border border-teal-200 rounded-xl text-[11.5px] text-teal-800 font-medium">
|
||||
نقطه عطف: این پدیده ساختارهای یادگیری و سنجش را دگرگون ساخته و انفعال در برابر آن موجب افت تقاضای داوطلبان خواهد شد.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sky-700 font-extrabold text-sm mb-2">
|
||||
<Building size={17} className="text-sky-600" />
|
||||
<span>ارتباط مستقیم با وضعیت دانشگاه اصفهان</span>
|
||||
</div>
|
||||
<p className="text-slate-600 text-xs leading-relaxed">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">ارتباط با دانشگاه اصفهان</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 pt-4">
|
||||
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{item.relevanceToUniversity}
|
||||
</p>
|
||||
{item.implications.affectedDepartments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.implications.affectedDepartments.map((dept, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
|
||||
>
|
||||
{dept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-3 bg-sky-50 border border-sky-200 rounded-xl text-[11.5px] text-sky-800 font-medium">
|
||||
دانشکدهها و واحدهای درگیر: {item.implications.affectedDepartments.join('، ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. MULTI-DIMENSIONAL IMPACT MODEL & TIME HORIZON */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
{/* Impact Model (7 Dimensions) */}
|
||||
<div className="lg:col-span-7 intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="text-slate-900 font-bold text-sm flex items-center gap-2">
|
||||
<Zap size={16} className="text-teal-600" />
|
||||
<span>مدل تحلیلی اثرگذاری چندبُعدی بر ارکان دانشگاه اصفهان</span>
|
||||
</h3>
|
||||
<span className="badge badge-teal">مقیاس ۱ تا ۵</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<Card data-card className="lg:col-span-7">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">اثر بر ارکان دانشگاه</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">مقیاس ۱ تا ۵</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<ImpactGauges impact={item.impact} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time Horizon & Uncertainty Diagnosis */}
|
||||
<div className="lg:col-span-5 intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="border-b border-slate-100 pb-3">
|
||||
<h3 className="text-slate-900 font-bold text-sm flex items-center gap-2">
|
||||
<Clock size={16} className="text-sky-600" />
|
||||
<span>افق زمانی بلوغ و درجه عدم قطعیت</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:col-span-5">
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">سنجهها</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-3">
|
||||
<dl className="divide-y text-[12.5px]">
|
||||
<MetaRow label="افق تحقق" value={getHorizonLabelFa(item.horizon)} />
|
||||
<MetaRow
|
||||
label="عدم قطعیت"
|
||||
value={`${toPersianDigits(item.uncertaintyScore)} از ۵`}
|
||||
/>
|
||||
<MetaRow
|
||||
label="اطمینان شواهد"
|
||||
value={CONFIDENCE_LABELS[item.confidence] ?? item.confidence}
|
||||
/>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200 text-xs flex flex-col gap-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 font-medium">افق تحقق پیشبینیشده:</span>
|
||||
<span className="font-bold text-teal-700">{getHorizonLabelFa(item.horizon)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 font-medium">شاخص عدم قطعیت:</span>
|
||||
<span className="font-bold text-amber-700">{toPersianDigits(item.uncertaintyScore)} از ۵</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 font-medium">درجه اطمینان شواهد:</span>
|
||||
<span className="font-bold text-sky-700">
|
||||
{item.confidence === 'high' ? 'بالا (مبتنی بر اسناد موثق)' : 'متوسط'}
|
||||
</span>
|
||||
<ImplicationList
|
||||
title="فرصتها"
|
||||
icon={CheckCircle2}
|
||||
tone="var(--positive)"
|
||||
soft="var(--positive-soft)"
|
||||
entries={item.implications.opportunities}
|
||||
/>
|
||||
|
||||
<ImplicationList
|
||||
title="ریسکها و تهدیدها"
|
||||
icon={AlertOctagon}
|
||||
tone="var(--negative)"
|
||||
soft="var(--negative-soft)"
|
||||
entries={[...item.implications.risks, ...item.implications.threats]}
|
||||
/>
|
||||
|
||||
<ImplicationList
|
||||
title="قابلیتهای موردنیاز"
|
||||
icon={ShieldCheck}
|
||||
tone="var(--primary)"
|
||||
soft="var(--muted)"
|
||||
entries={item.implications.requiredCapabilities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategic Implications Grid (SWOT Bento Style) */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-slate-900 font-bold text-xs">تحلیل پیامدهای راهبردی (SWOT/Implications):</h4>
|
||||
|
||||
{/* Opportunities */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 p-3.5 rounded-2xl text-xs">
|
||||
<span className="text-emerald-800 font-bold flex items-center gap-1.5 mb-1.5">
|
||||
<CheckCircle2 size={15} />
|
||||
فرصتهای پیشرو برای دانشگاه اصفهان:
|
||||
</span>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 text-[11.5px]">
|
||||
{item.implications.opportunities.map((opp, idx) => (
|
||||
<li key={idx}>{opp}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Risks & Threats */}
|
||||
<div className="bg-rose-50 border border-rose-200 p-3.5 rounded-2xl text-xs">
|
||||
<span className="text-rose-800 font-bold flex items-center gap-1.5 mb-1.5">
|
||||
<AlertOctagon size={15} />
|
||||
ریسکها و تهدیدهای پیشرو:
|
||||
</span>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 text-[11.5px]">
|
||||
{item.implications.risks.map((risk, idx) => (
|
||||
<li key={idx}>{risk}</li>
|
||||
))}
|
||||
{item.implications.threats.map((threat, idx) => (
|
||||
<li key={`th-${idx}`}>{threat}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Required Capabilities */}
|
||||
<div className="bg-slate-50 border border-slate-200 p-3.5 rounded-2xl text-xs">
|
||||
<span className="text-slate-900 font-bold flex items-center gap-1.5 mb-1.5">
|
||||
<ShieldCheck size={15} className="text-teal-600" />
|
||||
قابلیتهای سازمانی و کالبدی موردنیاز:
|
||||
</span>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 text-[11.5px]">
|
||||
{item.implications.requiredCapabilities.map((cap, idx) => (
|
||||
<li key={idx}>{cap}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. EVIDENCE & SOURCES */}
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="text-slate-900 font-bold text-sm flex items-center gap-2">
|
||||
<FileText size={16} className="text-purple-600" />
|
||||
<span>شواهد تجربی، مقالات و رخدادهای مؤید (Evidence & Sources)</span>
|
||||
</h3>
|
||||
<span className="text-slate-400 text-xs font-semibold">{toPersianDigits(item.evidence.length)} منبع مستند</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{item.evidence.length > 0 && (
|
||||
<Card data-card className="mb-4">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">شواهد و منابع</CardTitle>
|
||||
<p className="tnum text-[11.5px] text-muted-foreground">
|
||||
{toPersianDigits(item.evidence.length)} منبع
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 pt-4 md:grid-cols-2">
|
||||
{item.evidence.map((ev, idx) => (
|
||||
<div key={idx} className="bg-slate-50 border border-slate-200 p-4 rounded-2xl flex flex-col gap-2 text-xs">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 font-bold">
|
||||
<span className="text-teal-700">{ev.source}</span>
|
||||
<span>{ev.date}</span>
|
||||
<div key={idx} className="rounded-lg border bg-muted/40 p-3.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate font-medium text-primary">{ev.source}</span>
|
||||
<span className="tnum shrink-0">{ev.date}</span>
|
||||
</div>
|
||||
<h5 className="text-slate-900 font-extrabold text-xs">{ev.title}</h5>
|
||||
<p className="text-slate-600 leading-relaxed text-[11.5px] bg-white p-3 rounded-xl border border-slate-200/80">
|
||||
«{ev.snippet}»
|
||||
<h4 className="text-[12.5px] font-semibold leading-relaxed">{ev.title}</h4>
|
||||
<p className="mt-1.5 border-s-2 ps-2.5 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
{ev.snippet}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 5. STRATEGIC TRACEABILITY */}
|
||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col gap-5 border-t-4 border-t-teal-500">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="text-slate-900 font-black text-sm flex items-center gap-2">
|
||||
<Layers size={17} className="text-teal-600" />
|
||||
<span>زنجیره ارتباطات راهبردی و ردپای اثر (Strategic Traceability)</span>
|
||||
</h3>
|
||||
<span className="text-slate-400 text-xs">اتصالات پایگاه داده هوشمندی</span>
|
||||
</div>
|
||||
{/* Traceability */}
|
||||
<Card data-card>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<CardTitle className="text-[14px]">ارتباطات</CardTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
اتصال این مورد به ویژگیهای ۱۴۱۵، مشاغل و مهارتها.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 text-xs">
|
||||
{/* Linked 1415 Dimensions */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-teal-700 font-bold flex items-center gap-1.5">
|
||||
<Target size={14} />
|
||||
ابعاد مرتبط در دانشگاه اصفهان ۱۴۱۵:
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{relatedDimensions.map((dim) => (
|
||||
<div
|
||||
<CardContent className="grid grid-cols-1 gap-5 pt-4 md:grid-cols-3">
|
||||
<LinkColumn title="ویژگیهای ۱۴۱۵" icon={Target}>
|
||||
{relatedDimensions.length ? (
|
||||
relatedDimensions.map((dim) => (
|
||||
<button
|
||||
key={dim.id}
|
||||
onClick={() => onSelectDimension(dim)}
|
||||
className="bg-slate-50 hover:bg-slate-100 border border-slate-200 p-3 rounded-2xl cursor-pointer transition-all flex items-center justify-between shadow-sm"
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-right text-[12px] transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="text-slate-900 font-bold">{dim.title}</span>
|
||||
<span className="text-rose-600 font-bold text-[11px]">شکاف: {toPersianDigits(dim.gap)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="truncate">{dim.title}</span>
|
||||
<span className="tnum shrink-0 text-[11px] text-negative">
|
||||
شکاف {toPersianDigits(dim.gap)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</LinkColumn>
|
||||
|
||||
{/* Linked Jobs */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-amber-700 font-bold flex items-center gap-1.5">
|
||||
<Briefcase size={14} />
|
||||
مشاغل تحت تأثیر:
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{relatedJobs.map((job) => (
|
||||
<LinkColumn title="مشاغل" icon={Briefcase}>
|
||||
{relatedJobs.length ? (
|
||||
relatedJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="bg-slate-50 border border-slate-200 p-3 rounded-2xl flex items-center justify-between shadow-sm"
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-[12px]"
|
||||
>
|
||||
<span className="text-slate-800 font-bold">{job.title}</span>
|
||||
<span className={`badge ${job.type === 'growing' ? 'badge-teal' : 'badge-rose'}`}>
|
||||
{job.type === 'growing' ? 'صعودی' : 'در تحول'}
|
||||
<span className="truncate">{job.title}</span>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||
style={{
|
||||
background:
|
||||
job.type === 'growing'
|
||||
? 'var(--positive-soft)'
|
||||
: 'var(--negative-soft)',
|
||||
color:
|
||||
job.type === 'growing' ? 'var(--positive)' : 'var(--negative)'
|
||||
}}
|
||||
>
|
||||
{job.type === 'growing' ? 'رو به رشد' : 'در تغییر'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{relatedJobs.length === 0 && (
|
||||
<span className="text-slate-400 text-[11px] italic">مشاغل عمومی آموزش عالی</span>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkColumn>
|
||||
|
||||
{/* Linked Skills */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-purple-700 font-bold flex items-center gap-1.5">
|
||||
<Brain size={14} />
|
||||
شایستگیهای موردنیاز:
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{relatedSkills.map((sk) => (
|
||||
<LinkColumn title="مهارتها" icon={Brain}>
|
||||
{relatedSkills.length ? (
|
||||
relatedSkills.map((sk) => (
|
||||
<div
|
||||
key={sk.id}
|
||||
className="bg-slate-50 border border-slate-200 p-3 rounded-2xl flex items-center justify-between shadow-sm"
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-[12px]"
|
||||
>
|
||||
<span className="text-slate-800 font-bold">{sk.title}</span>
|
||||
<span className="text-slate-500 text-[11px]">اهمیت: {toPersianDigits(sk.importanceScore)}/۵</span>
|
||||
<span className="truncate">{sk.title}</span>
|
||||
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||
{toPersianDigits(sk.importanceScore)}/۵
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{relatedSkills.length === 0 && (
|
||||
<span className="text-slate-400 text-[11px] italic">مهارتهای تفکر سیستمی و دیجیتال</span>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LinkColumn>
|
||||
</CardContent>
|
||||
|
||||
{/* Other Related Trends / Tech */}
|
||||
{relatedItems.length > 0 && (
|
||||
<div className="pt-4 border-t border-slate-100 flex flex-col gap-2">
|
||||
<span className="text-slate-500 text-xs font-bold">سایر موارد همبسته در رادار:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CardContent className="border-t pt-4">
|
||||
<div className="mb-2 text-[12px] font-semibold text-muted-foreground">
|
||||
موارد همبسته در رادار
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{relatedItems.map((rItem) => (
|
||||
<button
|
||||
key={rItem.id}
|
||||
onClick={() => onSelectItem(rItem)}
|
||||
className="bg-slate-100 hover:bg-slate-200/80 border border-slate-200 text-slate-800 px-3.5 py-1.5 rounded-full text-xs transition-colors flex items-center gap-1.5 shadow-sm font-semibold"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11.5px] transition-colors hover:bg-muted"
|
||||
>
|
||||
<span>{rItem.title}</span>
|
||||
<ArrowRight size={12} className="text-teal-600" />
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{ background: CATEGORY_META[rItem.category].color }}
|
||||
/>
|
||||
{rItem.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MetaRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<div className="flex items-center justify-between gap-2 py-2">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ImplicationList: React.FC<{
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
tone: string;
|
||||
soft: string;
|
||||
entries: string[];
|
||||
}> = ({ title, icon: Icon, tone, soft, entries }) => {
|
||||
if (!entries.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border p-3.5" style={{ background: soft }}>
|
||||
<div
|
||||
className="mb-2 flex items-center gap-1.5 text-[12px] font-semibold"
|
||||
style={{ color: tone }}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{title}
|
||||
</div>
|
||||
<ul className="flex list-disc flex-col gap-1 ps-4 text-[11.5px] leading-relaxed">
|
||||
{entries.map((entry, i) => (
|
||||
<li key={i}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkColumn: React.FC<{
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, icon: Icon, children }) => (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[12px] font-semibold text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Empty: React.FC = () => (
|
||||
<span className="text-[11.5px] text-muted-foreground">موردی ثبت نشده است.</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,294 +2,258 @@ import React, { useState, useMemo } from 'react';
|
|||
import { JobEntity, SkillEntity, Horizon } from '../types/futures';
|
||||
import { JobCard, SkillCard } from '../components/cards/JobSkillCard';
|
||||
import { LaborCharts } from '../components/visual/LaborCharts';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import { GlobalOutlook } from '../components/visual/GlobalOutlook';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { toPersianDigits, getHorizonLabelFa } from '../utils/persianNumbers';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Briefcase,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
Filter,
|
||||
Search,
|
||||
School,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
interface LaborMarketPageProps {
|
||||
jobs: JobEntity[];
|
||||
skills: SkillEntity[];
|
||||
}
|
||||
|
||||
/** The four buckets named in the brief, in the brief's own order. */
|
||||
type BucketKey = 'growing_jobs' | 'transforming_jobs' | 'growing_skills' | 'declining_skills';
|
||||
|
||||
const BUCKETS: { key: BucketKey; emoji: string; label: string; positive: boolean }[] = [
|
||||
{ key: 'growing_jobs', emoji: '🔥', label: 'مشاغل رو به رشد', positive: true },
|
||||
{ key: 'transforming_jobs', emoji: '⚠️', label: 'مشاغل در معرض تغییر', positive: false },
|
||||
{ key: 'growing_skills', emoji: '🚀', label: 'مهارتهای رو به رشد', positive: true },
|
||||
{ key: 'declining_skills', emoji: '📉', label: 'مهارتهای در حال کاهش', positive: false }
|
||||
];
|
||||
|
||||
const HORIZON_OPTIONS: (Horizon | 'all')[] = ['all', 'now', 'near', 'mid', 'long'];
|
||||
|
||||
export const LaborMarketPage: React.FC<LaborMarketPageProps> = ({ jobs, skills }) => {
|
||||
const [activeQuadrant, setActiveQuadrant] = useState<
|
||||
'all' | 'growing_jobs' | 'transforming_jobs' | 'growing_skills' | 'declining_skills'
|
||||
>('all');
|
||||
const [selectedDiscipline, setSelectedDiscipline] = useState<string>('all');
|
||||
const [selectedHorizon, setSelectedHorizon] = useState<Horizon | 'all'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [scope, setScope] = useState<'global' | 'local'>('global');
|
||||
const [bucket, setBucket] = useState<BucketKey | 'all'>('all');
|
||||
const [discipline, setDiscipline] = useState('all');
|
||||
const [horizon, setHorizon] = useState<Horizon | 'all'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
// Extract unique disciplines
|
||||
const disciplines = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
jobs.forEach((j) => {
|
||||
if (j.discipline) set.add(j.discipline);
|
||||
});
|
||||
return Array.from(set);
|
||||
}, [jobs]);
|
||||
const disciplines = useMemo(
|
||||
() => Array.from(new Set(jobs.map((j) => j.discipline).filter(Boolean))),
|
||||
[jobs]
|
||||
);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
return jobs.filter((j) => {
|
||||
if (selectedHorizon !== 'all' && j.horizon !== selectedHorizon) return false;
|
||||
if (selectedDiscipline !== 'all' && j.discipline !== selectedDiscipline) return false;
|
||||
if (searchQuery.trim() !== '') {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const inTitle = j.title.toLowerCase().includes(q);
|
||||
const inDesc = j.description.toLowerCase().includes(q);
|
||||
const inDisc = j.discipline.toLowerCase().includes(q);
|
||||
if (!inTitle && !inDesc && !inDisc) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [jobs, selectedHorizon, selectedDiscipline, searchQuery]);
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
return skills.filter((s) => {
|
||||
if (searchQuery.trim() !== '') {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const inTitle = s.title.toLowerCase().includes(q);
|
||||
const inDesc = s.description.toLowerCase().includes(q);
|
||||
if (!inTitle && !inDesc) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [skills, searchQuery]);
|
||||
const filteredJobs = useMemo(
|
||||
() =>
|
||||
jobs.filter((j) => {
|
||||
if (horizon !== 'all' && j.horizon !== horizon) return false;
|
||||
if (discipline !== 'all' && j.discipline !== discipline) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
j.title.toLowerCase().includes(q) ||
|
||||
j.description.toLowerCase().includes(q) ||
|
||||
j.discipline.toLowerCase().includes(q)
|
||||
);
|
||||
}),
|
||||
[jobs, horizon, discipline, q]
|
||||
);
|
||||
|
||||
const growingJobs = filteredJobs.filter((j) => j.type === 'growing');
|
||||
const transformingJobs = filteredJobs.filter((j) => j.type === 'transforming');
|
||||
const growingSkills = filteredSkills.filter((s) => s.type === 'growing');
|
||||
const decliningSkills = filteredSkills.filter((s) => s.type === 'declining');
|
||||
const filteredSkills = useMemo(
|
||||
() =>
|
||||
skills.filter((s) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
s.title.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
|
||||
);
|
||||
}),
|
||||
[skills, q]
|
||||
);
|
||||
|
||||
const contents = {
|
||||
growing_jobs: filteredJobs.filter((j) => j.type === 'growing'),
|
||||
transforming_jobs: filteredJobs.filter((j) => j.type === 'transforming'),
|
||||
growing_skills: filteredSkills.filter((s) => s.type === 'growing'),
|
||||
declining_skills: filteredSkills.filter((s) => s.type === 'declining')
|
||||
};
|
||||
|
||||
const hasFilters = horizon !== 'all' || discipline !== 'all' || q !== '';
|
||||
const resetFilters = () => {
|
||||
setHorizon('all');
|
||||
setDiscipline('all');
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const visibleBuckets = BUCKETS.filter((b) => bucket === 'all' || bucket === b.key);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-amber">تحلیل اشتغالپذیری و آمایش رشتهها</span>
|
||||
<span className="text-slate-400 text-xs">دیدهبانی مهارتهای نوظهور</span>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl text-white">
|
||||
بازار کار آینده و تحول مشاغل (Future Labor Market)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
||||
رصد ۴ ربع کلیدی تحول تقاضای شغلی: مشاغل رو به رشد، مشاغل در معرض دگرگونی، مهارتهای صعودی و مهارتهای منسوخ شونده
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title="بازار کار آینده"
|
||||
description="مشاغل و مهارتهایی که تقاضای بازار کار برای آنها در حال رشد یا کاهش است."
|
||||
actions={
|
||||
<Tabs value={scope} onValueChange={(v) => setScope(v as typeof scope)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="global">چشمانداز جهانی</TabsTrigger>
|
||||
<TabsTrigger value="local">دانشگاه اصفهان</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-[#0c1935] border border-teal-500/30 text-teal-300 px-3 py-1.5 rounded-xl font-bold">
|
||||
{toPersianDigits(jobs.length)} عنوان شغل رصد شده
|
||||
</span>
|
||||
<span className="text-xs bg-[#0c1935] border border-purple-500/30 text-purple-300 px-3 py-1.5 rounded-xl font-bold">
|
||||
{toPersianDigits(skills.length)} مهارت کلیدی
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{scope === 'global' && <GlobalOutlook />}
|
||||
|
||||
{/* Analytical Charts Section */}
|
||||
{scope === 'local' && (
|
||||
<>
|
||||
<LaborCharts jobs={jobs} skills={skills} />
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="intel-card p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Quadrant Pills */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setActiveQuadrant('all')}
|
||||
className={`px-3 py-1.5 rounded-xl font-semibold transition-all ${
|
||||
activeQuadrant === 'all'
|
||||
? 'bg-teal-500 text-slate-950 font-bold'
|
||||
: 'bg-[#091122] text-slate-300 border border-slate-800 hover:bg-[#111f3d]'
|
||||
}`}
|
||||
>
|
||||
همه ربعها
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveQuadrant('growing_jobs')}
|
||||
className={`px-3 py-1.5 rounded-xl font-semibold transition-all flex items-center gap-1.5 ${
|
||||
activeQuadrant === 'growing_jobs'
|
||||
? 'bg-teal-400 text-slate-950 font-bold'
|
||||
: 'bg-[#091122] text-teal-400 border border-teal-950 hover:bg-[#111f3d]'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp size={13} />
|
||||
مشاغل رو به رشد ({toPersianDigits(growingJobs.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveQuadrant('transforming_jobs')}
|
||||
className={`px-3 py-1.5 rounded-xl font-semibold transition-all flex items-center gap-1.5 ${
|
||||
activeQuadrant === 'transforming_jobs'
|
||||
? 'bg-rose-500 text-slate-950 font-bold'
|
||||
: 'bg-[#091122] text-rose-400 border border-rose-950 hover:bg-[#111f3d]'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
مشاغل در معرض تغییر ({toPersianDigits(transformingJobs.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveQuadrant('growing_skills')}
|
||||
className={`px-3 py-1.5 rounded-xl font-semibold transition-all flex items-center gap-1.5 ${
|
||||
activeQuadrant === 'growing_skills'
|
||||
? 'bg-purple-500 text-slate-950 font-bold'
|
||||
: 'bg-[#091122] text-purple-400 border border-purple-950 hover:bg-[#111f3d]'
|
||||
}`}
|
||||
>
|
||||
<Brain size={13} />
|
||||
مهارتهای رو به رشد ({toPersianDigits(growingSkills.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveQuadrant('declining_skills')}
|
||||
className={`px-3 py-1.5 rounded-xl font-semibold transition-all flex items-center gap-1.5 ${
|
||||
activeQuadrant === 'declining_skills'
|
||||
? 'bg-amber-500 text-slate-950 font-bold'
|
||||
: 'bg-[#091122] text-amber-400 border border-amber-950 hover:bg-[#111f3d]'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
مهارتهای در حال کاهش ({toPersianDigits(decliningSkills.length)})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="جستجو در مشاغل و مهارتها..."
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-xl py-1.5 pr-8 pl-3 text-xs text-white outline-none focus:border-teal-500 placeholder:text-slate-500"
|
||||
{/* Bucket selector + filters */}
|
||||
<Card data-card className="my-4">
|
||||
<CardContent className="flex flex-col gap-3 p-3.5">
|
||||
<div className="scroll-x">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BucketChip
|
||||
active={bucket === 'all'}
|
||||
onClick={() => setBucket('all')}
|
||||
label="همه"
|
||||
/>
|
||||
<Search size={14} className="absolute right-2.5 top-2.5 text-slate-400" />
|
||||
{BUCKETS.map((b) => (
|
||||
<BucketChip
|
||||
key={b.key}
|
||||
active={bucket === b.key}
|
||||
onClick={() => setBucket(b.key)}
|
||||
label={`${b.emoji} ${b.label}`}
|
||||
count={contents[b.key].length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discipline / Faculty Filter */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-800/80 text-xs">
|
||||
<span className="text-slate-400 font-semibold flex items-center gap-1">
|
||||
<School size={13} />
|
||||
فیلتر بر اساس دانشکده / رشته دانشگاه اصفهان:
|
||||
</span>
|
||||
<select
|
||||
value={selectedDiscipline}
|
||||
onChange={(e) => setSelectedDiscipline(e.target.value)}
|
||||
className="bg-[#091122] border border-slate-800 text-slate-200 text-xs rounded-lg p-1.5 outline-none focus:border-teal-500"
|
||||
>
|
||||
<option value="all">همه دانشکدهها و گروههای آموزشی</option>
|
||||
{disciplines.map((d, idx) => (
|
||||
<option key={idx} value={d}>
|
||||
<div className="flex flex-col gap-2.5 border-t pt-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute inset-y-0 end-3 my-auto size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="جستجو در مشاغل و مهارتها…"
|
||||
className="h-9 pe-9 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={discipline} onValueChange={setDiscipline}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه دانشکدهها</SelectItem>
|
||||
{disciplines.map((d) => (
|
||||
<SelectItem key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 4 Quadrants Display */}
|
||||
|
||||
{/* Quadrant 1: Growing Jobs */}
|
||||
{(activeQuadrant === 'all' || activeQuadrant === 'growing_jobs') && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between border-r-4 border-r-teal-400 pr-3">
|
||||
<div>
|
||||
<h3 className="text-white font-extrabold text-base flex items-center gap-2">
|
||||
<TrendingUp size={18} className="text-teal-400" />
|
||||
<span>مشاغل رو به رشد (Growing Occupations)</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs mt-0.5">
|
||||
مشاغلی که تقاضای بازار کار محلی، ملی و بینالمللی برای آنها با نرخ شتابان در حال افزایش است
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-teal">{toPersianDigits(growingJobs.length)} شغل</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{growingJobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} />
|
||||
<Select value={horizon} onValueChange={(v) => setHorizon(v as Horizon | 'all')}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HORIZON_OPTIONS.map((h) => (
|
||||
<SelectItem key={h} value={h}>
|
||||
{h === 'all' ? 'همه افقها' : getHorizonLabelFa(h)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<X />
|
||||
پاککردن
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quadrant 2: Transforming Jobs */}
|
||||
{(activeQuadrant === 'all' || activeQuadrant === 'transforming_jobs') && (
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<div className="flex items-center justify-between border-r-4 border-r-rose-400 pr-3">
|
||||
<div>
|
||||
<h3 className="text-white font-extrabold text-base flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-rose-400" />
|
||||
<span>مشاغل در معرض تغییر و بازآفرینی (Transforming Occupations)</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs mt-0.5">
|
||||
مشاغل سنتی با ریسک اتوماسیون بالا که برای بقا نیازمند بازطراحی مهارتی و همآفرینی با فناوری هستند
|
||||
<div className="flex flex-col gap-8">
|
||||
{visibleBuckets.map((b) => {
|
||||
const list = contents[b.key];
|
||||
const isJobs = b.key === 'growing_jobs' || b.key === 'transforming_jobs';
|
||||
|
||||
return (
|
||||
<section key={b.key}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 border-b pb-2">
|
||||
<h2 className="flex items-center gap-2 text-[15px] font-bold">
|
||||
<span aria-hidden>{b.emoji}</span>
|
||||
{b.label}
|
||||
</h2>
|
||||
<span
|
||||
className="tnum rounded px-1.5 py-0.5 text-[11px] font-semibold"
|
||||
style={{
|
||||
background: b.positive ? 'var(--positive-soft)' : 'var(--negative-soft)',
|
||||
color: b.positive ? 'var(--positive)' : 'var(--negative)'
|
||||
}}
|
||||
>
|
||||
{toPersianDigits(list.length)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<p className="py-8 text-center text-[13px] text-muted-foreground">
|
||||
موردی با این فیلترها یافت نشد.
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-rose">{toPersianDigits(transformingJobs.length)} شغل</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{transformingJobs.map((job) => (
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
isJobs
|
||||
? 'grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3'
|
||||
: 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4'
|
||||
}
|
||||
>
|
||||
{isJobs
|
||||
? (list as JobEntity[]).map((job) => (
|
||||
<JobCard key={job.id} job={job} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quadrant 3: Growing Skills */}
|
||||
{(activeQuadrant === 'all' || activeQuadrant === 'growing_skills') && (
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<div className="flex items-center justify-between border-r-4 border-r-purple-400 pr-3">
|
||||
<div>
|
||||
<h3 className="text-white font-extrabold text-base flex items-center gap-2">
|
||||
<Brain size={18} className="text-purple-400" />
|
||||
<span>مهارتهای رو به رشد (Rising Skills)</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs mt-0.5">
|
||||
شایستگیهای کلیدی شناختی، دیجیتال، بینرشتهای و انسانی که ضامن اشتغالپذیری فارغالتحصیلان است
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-purple">{toPersianDigits(growingSkills.length)} مهارت</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{growingSkills.map((skill) => (
|
||||
))
|
||||
: (list as SkillEntity[]).map((skill) => (
|
||||
<SkillCard key={skill.id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quadrant 4: Declining Skills */}
|
||||
{(activeQuadrant === 'all' || activeQuadrant === 'declining_skills') && (
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<div className="flex items-center justify-between border-r-4 border-r-amber-400 pr-3">
|
||||
<div>
|
||||
<h3 className="text-white font-extrabold text-base flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-amber-400" />
|
||||
<span>مهارتهای در حال کاهش و منسوخ شونده (Declining Skills)</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs mt-0.5">
|
||||
مهارتهای مبتنی بر محفوظات، ورود دستی دادهها و روتینهای محاسباتی که ارزش بازار کار را از دست میدهند
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-amber">{toPersianDigits(decliningSkills.length)} مهارت</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{decliningSkills.map((skill) => (
|
||||
<SkillCard key={skill.id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BucketChip: React.FC<{
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count?: number;
|
||||
}> = ({ active, onClick, label, count }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
active
|
||||
? 'border-transparent bg-foreground text-background'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && (
|
||||
<span className="tnum opacity-60">{toPersianDigits(count)}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import React, { useState } from 'react';
|
||||
import { authService, PRESET_USERS } from '../services/authService';
|
||||
import { PersonaType } from '../types/futures';
|
||||
import { authService } from '../services/authService';
|
||||
import { ForgotPasswordModal } from '../components/modals/ForgotPasswordModal';
|
||||
import { Compass, Shield, KeyRound, Smartphone, CheckCircle, AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import { HORIZON_START, HORIZON_END } from '../utils/horizon';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Compass, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
|
||||
export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||
const [activeTab, setActiveTab] = useState<'password' | 'otp' | 'persona'>('password');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [mobileNumber, setMobileNumber] = useState('');
|
||||
|
|
@ -18,355 +26,201 @@ export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
|||
const [simulatedCode, setSimulatedCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [showForgotModal, setShowForgotModal] = useState(false);
|
||||
const [showForgot, setShowForgot] = useState(false);
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
const run = async (fn: () => Promise<void>, fallbackError: string) => {
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : fallbackError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
run(async () => {
|
||||
await authService.loginWithCredentials(username, password);
|
||||
setLoading(false);
|
||||
onLoginSuccess();
|
||||
} catch (err: any) {
|
||||
setLoading(false);
|
||||
setErrorMsg(err.message || 'خطا در ورود');
|
||||
}
|
||||
}, 'خطا در ورود');
|
||||
};
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
const handleSendOtp = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!mobileNumber.trim()) return;
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
run(async () => {
|
||||
const res = await authService.sendOtp(mobileNumber);
|
||||
setOtpSent(true);
|
||||
setSimulatedCode(res.simulatedOtp);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
setLoading(false);
|
||||
setErrorMsg('خطا در ارسال پیامک کد یکبار مصرف');
|
||||
}
|
||||
setOtpSent(true);
|
||||
}, 'خطا در ارسال کد');
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
const handleVerifyOtp = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otpCode.trim()) return;
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
run(async () => {
|
||||
await authService.verifyOtp(otpCode);
|
||||
setLoading(false);
|
||||
onLoginSuccess();
|
||||
} catch (err: any) {
|
||||
setLoading(false);
|
||||
setErrorMsg(err.message || 'کد وارد شده نامعتبر است');
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickPersonaLogin = (role: PersonaType) => {
|
||||
authService.switchPersona(role);
|
||||
onLoginSuccess();
|
||||
}, 'کد وارد شده نامعتبر است');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center p-4 bg-radial-gradient">
|
||||
<div className="w-full max-w-md intel-card border-[#1f3258] bg-[#0c162b]/95 p-6 lg:p-8 shadow-2xl backdrop-blur-xl animate-fadeIn">
|
||||
{/* Institutional Emblem & Title */}
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#0c2452] to-[#12397c] border border-teal-400/50 flex items-center justify-center mb-3 shadow-xl shadow-teal-500/20">
|
||||
<Compass size={32} className="text-teal-400" />
|
||||
</div>
|
||||
<h2 className="text-white font-black text-xl lg:text-2xl tracking-tight">
|
||||
رادار آینده دانشگاه اصفهان
|
||||
</h2>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
سامانه پایش هوشمندی راهبردی و آیندهپژوهی آموزش عالی
|
||||
<div className="flex min-h-svh w-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Brand */}
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
<span className="mb-3 flex size-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||
<Compass className="size-6" strokeWidth={2} />
|
||||
</span>
|
||||
<h1 className="text-lg font-bold">رادار آینده دانشگاه اصفهان</h1>
|
||||
<p className="tnum mt-1 text-[12px] text-muted-foreground">
|
||||
افق {toPersianDigits(HORIZON_START)} تا {toPersianDigits(HORIZON_END)}
|
||||
</p>
|
||||
<div className="mt-2 inline-flex items-center gap-1 bg-teal-500/10 text-teal-300 border border-teal-500/30 px-2.5 py-0.5 rounded-full text-[11px] font-semibold">
|
||||
<Shield size={12} />
|
||||
درگاه احراز هویت یکپارچه دانشگاهی
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="grid grid-cols-3 gap-1 bg-[#081020] p-1 rounded-xl mb-5 text-xs font-semibold">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('password');
|
||||
setErrorMsg('');
|
||||
}}
|
||||
className={`py-1.5 rounded-lg transition-all ${
|
||||
activeTab === 'password'
|
||||
? 'bg-teal-500 text-slate-950 font-bold shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
کلمه عبور
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('otp');
|
||||
setErrorMsg('');
|
||||
}}
|
||||
className={`py-1.5 rounded-lg transition-all ${
|
||||
activeTab === 'otp'
|
||||
? 'bg-teal-500 text-slate-950 font-bold shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
<Card data-card>
|
||||
<CardContent className="p-5">
|
||||
<Tabs
|
||||
defaultValue="password"
|
||||
onValueChange={() => setErrorMsg('')}
|
||||
className="gap-4"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
رمز عبور
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="otp" className="flex-1">
|
||||
کد یکبار مصرف
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('persona');
|
||||
setErrorMsg('');
|
||||
}}
|
||||
className={`py-1.5 rounded-lg transition-all ${
|
||||
activeTab === 'persona'
|
||||
? 'bg-teal-500 text-slate-950 font-bold shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ورود سریع نقشی
|
||||
</button>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mb-4 p-3 bg-rose-950/40 border border-rose-500/50 rounded-xl text-rose-300 text-xs flex items-center gap-2">
|
||||
<AlertCircle size={15} className="shrink-0" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertDescription>{errorMsg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tab 1: Username / Password Form */}
|
||||
{activeTab === 'password' && (
|
||||
<form onSubmit={handlePasswordLogin} className="flex flex-col gap-4 text-xs">
|
||||
<div className="flex flex-col gap-1.5 text-right">
|
||||
<label className="text-slate-300 font-semibold">شناسه کاربری دانشگاهی:</label>
|
||||
<input
|
||||
type="text"
|
||||
<TabsContent value="password">
|
||||
<form onSubmit={handlePasswordLogin} className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username">نام کاربری</Label>
|
||||
<Input
|
||||
id="username"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="president یا researcher یا نام کاربری..."
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-xl p-3 text-white outline-none focus:border-teal-500 text-xs text-right"
|
||||
placeholder="president"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
(راهنما: برای ورود با هر نام کاربری میتوانید تست نمایید)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-right">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 font-semibold">رمز عبور:</label>
|
||||
<Label htmlFor="password">رمز عبور</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotModal(true)}
|
||||
className="text-[11px] text-teal-400 hover:underline"
|
||||
onClick={() => setShowForgot(true)}
|
||||
className="text-[11.5px] text-primary hover:underline"
|
||||
>
|
||||
فراموشی رمز عبور؟
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-xl p-3 text-white outline-none focus:border-teal-500 text-xs"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full py-3 mt-2 font-bold text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<span>در حال اعتبارسنجی...</span>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound size={15} />
|
||||
<span>ورود امن به سامانه آیندهپژوهی</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Button type="submit" disabled={loading} className="mt-1 w-full">
|
||||
{loading && <Loader2 className="animate-spin" />}
|
||||
ورود
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: OTP Login Flow */}
|
||||
{activeTab === 'otp' && (
|
||||
<div className="flex flex-col gap-4 text-xs text-right">
|
||||
<TabsContent value="otp">
|
||||
{!otpSent ? (
|
||||
<form onSubmit={handleSendOtp} className="flex flex-col gap-4">
|
||||
<p className="text-slate-300 leading-relaxed text-[11.5px]">
|
||||
کد ۵ رقمی یکبار مصرف به شماره تلفن همراه ثبتشده شما در اتوماسیون دانشگاه ارسال خواهد شد.
|
||||
<form onSubmit={handleSendOtp} className="flex flex-col gap-3.5">
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
کد پنجرقمی به شماره ثبتشده شما پیامک میشود.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-slate-300 font-semibold">شماره تلفن همراه:</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
<Label htmlFor="mobile">شماره تلفن همراه</Label>
|
||||
<Input
|
||||
id="mobile"
|
||||
type="tel"
|
||||
required
|
||||
dir="ltr"
|
||||
value={mobileNumber}
|
||||
onChange={(e) => setMobileNumber(e.target.value)}
|
||||
placeholder="0913xxxxxxx"
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-xl p-3 text-white outline-none focus:border-teal-500 text-xs text-left font-mono"
|
||||
placeholder="09130000000"
|
||||
className="text-left"
|
||||
/>
|
||||
<Smartphone size={16} className="absolute left-3 top-3 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full py-3 mt-2 font-bold text-xs"
|
||||
>
|
||||
{loading ? 'در حال ارسال...' : 'ارسال کد تأیید پیامکی'}
|
||||
</button>
|
||||
<Button type="submit" disabled={loading} className="mt-1 w-full">
|
||||
{loading && <Loader2 className="animate-spin" />}
|
||||
ارسال کد
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerifyOtp} className="flex flex-col gap-4 animate-fadeIn">
|
||||
<div className="p-3 bg-teal-950/30 border border-teal-500/40 rounded-xl text-teal-300 text-[11.5px] flex items-center gap-2">
|
||||
<CheckCircle size={16} className="shrink-0 text-teal-400" />
|
||||
<div>
|
||||
کد یکبار مصرف شبیهسازی شده آزمایشی:{' '}
|
||||
<strong className="font-mono text-white text-sm bg-teal-900/60 px-2 py-0.5 rounded">
|
||||
{simulatedCode}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleVerifyOtp} className="flex flex-col gap-3.5">
|
||||
<Alert>
|
||||
<AlertDescription className="tnum">
|
||||
کد آزمایشی:{' '}
|
||||
<b className="font-semibold text-foreground">{simulatedCode}</b>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-slate-300 font-semibold">کد تأیید پیامکشده:</label>
|
||||
<input
|
||||
type="text"
|
||||
<Label htmlFor="otp">کد تأیید</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
required
|
||||
maxLength={5}
|
||||
autoFocus
|
||||
dir="ltr"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
placeholder="کد ۵ رقمی را وارد کنید"
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-xl p-3 text-white outline-none focus:border-teal-500 text-sm text-center font-mono tracking-widest"
|
||||
className="text-center tracking-[0.4em]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full py-3 font-bold text-xs"
|
||||
>
|
||||
{loading ? 'در حال بررسی...' : 'تأیید کد و ورود به سامانه'}
|
||||
</button>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading && <Loader2 className="animate-spin" />}
|
||||
تأیید و ورود
|
||||
</Button>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOtpSent(false)}
|
||||
className="text-slate-400 hover:text-white text-center text-[11px] mt-1"
|
||||
>
|
||||
ویرایش شماره تلفن همراه
|
||||
</button>
|
||||
ویرایش شماره
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: Quick Persona Selection (For University Decision-Makers Testing) */}
|
||||
{activeTab === 'persona' && (
|
||||
<div className="flex flex-col gap-2.5 animate-fadeIn">
|
||||
<p className="text-slate-400 text-[11px] mb-1 text-right">
|
||||
جهت ارزیابی فوری پلتفرم، یکی از نقشهای سازمانی زیر را انتخاب فرمایید:
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="mt-5 text-center text-[11px] text-muted-foreground">
|
||||
مرکز رصد فناوری و آیندهپژوهی دانشگاه اصفهان
|
||||
</p>
|
||||
|
||||
{/* Persona 1: President */}
|
||||
<button
|
||||
onClick={() => handleQuickPersonaLogin('president')}
|
||||
className="bg-[#0b1428] hover:bg-[#122242] border border-teal-500/40 p-3 rounded-xl text-right flex items-center justify-between transition-all group"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-white text-xs group-hover:text-teal-300">
|
||||
{PRESET_USERS.president.fullName}
|
||||
</div>
|
||||
<div className="text-[11px] text-teal-400 mt-0.5">
|
||||
{PRESET_USERS.president.roleTitleFa}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">
|
||||
دیدگاه: پیامدهای کلان و راهبردی برای دانشگاه
|
||||
</div>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-teal-400 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
|
||||
{/* Persona 2: Researcher */}
|
||||
<button
|
||||
onClick={() => handleQuickPersonaLogin('researcher')}
|
||||
className="bg-[#0b1428] hover:bg-[#122242] border border-sky-500/40 p-3 rounded-xl text-right flex items-center justify-between transition-all group"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-white text-xs group-hover:text-sky-300">
|
||||
{PRESET_USERS.researcher.fullName}
|
||||
</div>
|
||||
<div className="text-[11px] text-sky-400 mt-0.5">
|
||||
{PRESET_USERS.researcher.roleTitleFa}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">
|
||||
دیدگاه: رصد سیگنالهای ضعیف، شواهد و پیشرانها
|
||||
</div>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-sky-400 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
|
||||
{/* Persona 3: Strategic Planner */}
|
||||
<button
|
||||
onClick={() => handleQuickPersonaLogin('planner')}
|
||||
className="bg-[#0b1428] hover:bg-[#122242] border border-amber-500/40 p-3 rounded-xl text-right flex items-center justify-between transition-all group"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-white text-xs group-hover:text-amber-300">
|
||||
{PRESET_USERS.planner.fullName}
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-400 mt-0.5">
|
||||
{PRESET_USERS.planner.roleTitleFa}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">
|
||||
دیدگاه: تحلیل شکاف وضع موجود و چشمانداز ۱۴۱۵
|
||||
</div>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-amber-400 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
|
||||
{/* Persona 4: System Admin */}
|
||||
<button
|
||||
onClick={() => handleQuickPersonaLogin('admin')}
|
||||
className="bg-[#0b1428] hover:bg-[#122242] border border-purple-500/40 p-3 rounded-xl text-right flex items-center justify-between transition-all group"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-white text-xs group-hover:text-purple-300">
|
||||
{PRESET_USERS.admin.fullName}
|
||||
</div>
|
||||
<div className="text-[11px] text-purple-400 mt-0.5">
|
||||
{PRESET_USERS.admin.roleTitleFa}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">
|
||||
مدیریت دادهها، ویرایش سیگنالها و کالیبراسیون
|
||||
</div>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-purple-400 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-800/80 text-center text-[10.5px] text-slate-500">
|
||||
مرکز رصد فناوری و آیندهپژوهی دانشگاه اصفهان — دسترسی ویژه مدیران و پژوهشگران
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForgotPasswordModal
|
||||
isOpen={showForgotModal}
|
||||
onClose={() => setShowForgotModal(false)}
|
||||
/>
|
||||
<ForgotPasswordModal isOpen={showForgot} onClose={() => setShowForgot(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,22 +2,30 @@ import React, { useState, useMemo } from 'react';
|
|||
import { IntelligenceItem, Category, Horizon } from '../types/futures';
|
||||
import { FuturesRadar } from '../components/visual/FuturesRadar';
|
||||
import { IntelligenceCard } from '../components/cards/IntelligenceCard';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { toPersianDigits, getHorizonLabelFa } from '../utils/persianNumbers';
|
||||
import { CATEGORY_META, CATEGORY_ORDER } from '../utils/categories';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
toPersianDigits,
|
||||
getCategoryLabelFa,
|
||||
getHorizonLabelFa,
|
||||
getCategoryColor
|
||||
} from '../utils/persianNumbers';
|
||||
import {
|
||||
Radar,
|
||||
Grid,
|
||||
Table,
|
||||
Search,
|
||||
Clock,
|
||||
Zap,
|
||||
Filter,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Radar as RadarIcon, LayoutGrid, Rows3, Search, Plus, X } from 'lucide-react';
|
||||
|
||||
interface RadarPageProps {
|
||||
items: IntelligenceItem[];
|
||||
|
|
@ -26,312 +34,272 @@ interface RadarPageProps {
|
|||
initialCategory?: Category | 'all';
|
||||
}
|
||||
|
||||
const HORIZON_OPTIONS: (Horizon | 'all')[] = ['all', 'now', 'near', 'mid', 'long'];
|
||||
|
||||
export const RadarPage: React.FC<RadarPageProps> = ({
|
||||
items,
|
||||
onSelectItem,
|
||||
onOpenCreateModal,
|
||||
initialCategory = 'all'
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>(initialCategory);
|
||||
const [selectedHorizon, setSelectedHorizon] = useState<Horizon | 'all'>('all');
|
||||
const [minImpact, setMinImpact] = useState<number>(1);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [viewMode, setViewMode] = useState<'radar' | 'grid' | 'table'>('radar');
|
||||
const [category, setCategory] = useState<Category | 'all'>(initialCategory);
|
||||
const [horizon, setHorizon] = useState<Horizon | 'all'>('all');
|
||||
const [minImpact, setMinImpact] = useState(1);
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState<'radar' | 'grid' | 'table'>('radar');
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((item) => {
|
||||
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
||||
if (selectedHorizon !== 'all' && item.horizon !== selectedHorizon) return false;
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((item) => {
|
||||
if (category !== 'all' && item.category !== category) return false;
|
||||
if (horizon !== 'all' && item.horizon !== horizon) return false;
|
||||
if (item.impactScore < minImpact) return false;
|
||||
if (searchQuery.trim() !== '') {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const inTitle = item.title.toLowerCase().includes(q);
|
||||
const inSummary = item.executiveSummary.toLowerCase().includes(q);
|
||||
const inWhy = item.whyItMatters.toLowerCase().includes(q);
|
||||
if (!inTitle && !inSummary && !inWhy) return false;
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase().trim();
|
||||
return (
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.executiveSummary.toLowerCase().includes(q) ||
|
||||
item.whyItMatters.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [items, selectedCategory, selectedHorizon, minImpact, searchQuery]);
|
||||
}),
|
||||
[items, category, horizon, minImpact, query]
|
||||
);
|
||||
|
||||
const hasFilters =
|
||||
category !== 'all' || horizon !== 'all' || minImpact > 1 || query.trim() !== '';
|
||||
|
||||
const resetFilters = () => {
|
||||
setCategory('all');
|
||||
setHorizon('all');
|
||||
setMinImpact(1);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-teal">سامانه رصد مستمر</span>
|
||||
<span className="text-slate-400 text-xs font-medium">دیدهبانی آینده آموزش عالی</span>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl lg:text-3xl text-slate-900">
|
||||
رادار آینده دانشگاه (Futures Radar)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-500 text-xs mt-1">
|
||||
دیدهبانی جامع و پایش چندبُعدی روندها، فناوریها، سیاستها و سیگنالهای ضعیف تغییر
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title="رادار آینده"
|
||||
description="روندها، فناوریها، سیاستها و سیگنالهای آینده مؤثر بر آموزش عالی."
|
||||
actions={
|
||||
<>
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as typeof view)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="radar" className="gap-1.5">
|
||||
<RadarIcon className="size-3.5" />
|
||||
<span className="hidden sm:inline">رادار</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="grid" className="gap-1.5">
|
||||
<LayoutGrid className="size-3.5" />
|
||||
<span className="hidden sm:inline">کارت</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="table" className="gap-1.5">
|
||||
<Rows3 className="size-3.5" />
|
||||
<span className="hidden sm:inline">جدول</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View Mode Toggle (Pill Switcher) */}
|
||||
<div className="flex items-center bg-slate-100 border border-slate-200 p-1 rounded-full text-xs shadow-sm">
|
||||
<button
|
||||
onClick={() => setViewMode('radar')}
|
||||
className={`py-1.5 px-3 rounded-full flex items-center gap-1.5 transition-all ${
|
||||
viewMode === 'radar'
|
||||
? 'bg-slate-900 text-white font-bold shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
title="نمای رادار دایرهای"
|
||||
>
|
||||
<Radar size={15} />
|
||||
<span className="hidden sm:inline">رادار دایرهای</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`py-1.5 px-3 rounded-full flex items-center gap-1.5 transition-all ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-slate-900 text-white font-bold shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
title="نمای شبکه کارتها"
|
||||
>
|
||||
<Grid size={15} />
|
||||
<span className="hidden sm:inline">شبکه کارتها</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={`py-1.5 px-3 rounded-full flex items-center gap-1.5 transition-all ${
|
||||
viewMode === 'table'
|
||||
? 'bg-slate-900 text-white font-bold shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
title="نمای جدول تحلیلی"
|
||||
>
|
||||
<Table size={15} />
|
||||
<span className="hidden sm:inline">جدول تحلیلی</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenCreateModal}
|
||||
className="btn btn-primary text-xs py-2 px-4 flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>ثبت سیگنال جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytical Filter Controls Bar (Bento White Card) */}
|
||||
<div className="intel-card p-5 bg-white border border-slate-200 rounded-3xl shadow-sm flex flex-col gap-4">
|
||||
{/* Category Pills */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-slate-500 font-bold flex items-center gap-1 ml-1">
|
||||
<Filter size={13} />
|
||||
دستهبندی موضوعی:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
className={`px-3 py-1.5 rounded-full font-bold transition-all ${
|
||||
selectedCategory === 'all'
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200/80'
|
||||
}`}
|
||||
>
|
||||
همه ({toPersianDigits(items.length)})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory('trend')}
|
||||
className={`px-3 py-1.5 rounded-full font-bold transition-all ${
|
||||
selectedCategory === 'trend'
|
||||
? 'bg-sky-600 text-white shadow-sm'
|
||||
: 'bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100'
|
||||
}`}
|
||||
>
|
||||
روندها (Trends)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory('technology')}
|
||||
className={`px-3 py-1.5 rounded-full font-bold transition-all ${
|
||||
selectedCategory === 'technology'
|
||||
? 'bg-teal-600 text-white shadow-sm'
|
||||
: 'bg-teal-50 text-teal-700 border border-teal-200 hover:bg-teal-100'
|
||||
}`}
|
||||
>
|
||||
فناوریها (Tech)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory('policy')}
|
||||
className={`px-3 py-1.5 rounded-full font-bold transition-all ${
|
||||
selectedCategory === 'policy'
|
||||
? 'bg-amber-600 text-white shadow-sm'
|
||||
: 'bg-amber-50 text-amber-800 border border-amber-200 hover:bg-amber-100'
|
||||
}`}
|
||||
>
|
||||
سیاستها (Policies)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory('weak_signal')}
|
||||
className={`px-3 py-1.5 rounded-full font-bold transition-all ${
|
||||
selectedCategory === 'weak_signal'
|
||||
? 'bg-rose-600 text-white shadow-sm'
|
||||
: 'bg-rose-50 text-rose-700 border border-rose-200 hover:bg-rose-100'
|
||||
}`}
|
||||
>
|
||||
سیگنالهای آینده (Signals)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Search Pill Input */}
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="فیلتر با واژگان کلیدی..."
|
||||
className="w-full bg-slate-100 border border-slate-200 rounded-full py-1.5 pr-8 pl-3 text-xs text-slate-900 outline-none focus:border-teal-500 placeholder:text-slate-400 shadow-sm"
|
||||
<Button size="sm" onClick={onOpenCreateModal}>
|
||||
<Plus />
|
||||
<span className="hidden sm:inline">ثبت مورد</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Search size={14} className="absolute right-3 top-2.5 text-slate-400" />
|
||||
|
||||
{/* Filters */}
|
||||
<Card data-card className="mb-4">
|
||||
<CardContent className="flex flex-col gap-3 p-3.5">
|
||||
<div className="scroll-x">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FilterChip
|
||||
active={category === 'all'}
|
||||
onClick={() => setCategory('all')}
|
||||
label={`همه (${toPersianDigits(items.length)})`}
|
||||
/>
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
return (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
active={category === cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
label={meta.label}
|
||||
color={meta.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizon & Impact Sliders */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-3 border-t border-slate-100 text-xs">
|
||||
{/* Horizon Selector */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-slate-500 font-bold flex items-center gap-1">
|
||||
<Clock size={13} />
|
||||
افق زمانی تحقق:
|
||||
</span>
|
||||
{(['all', 'now', 'near', 'mid', 'long'] as const).map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setSelectedHorizon(h)}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition-all ${
|
||||
selectedHorizon === h
|
||||
? 'bg-slate-800 text-white font-bold'
|
||||
: 'bg-slate-100 text-slate-600 hover:text-slate-900 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 border-t pt-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute inset-y-0 end-3 my-auto size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="جستجو در عنوان و خلاصه…"
|
||||
className="h-9 pe-9 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={horizon} onValueChange={(v) => setHorizon(v as Horizon | 'all')}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HORIZON_OPTIONS.map((h) => (
|
||||
<SelectItem key={h} value={h}>
|
||||
{h === 'all' ? 'همه افقها' : getHorizonLabelFa(h)}
|
||||
</button>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Impact Score Threshold */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-bold flex items-center gap-1">
|
||||
<Zap size={13} className="text-teal-600" />
|
||||
حداقل شدت اثر:
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={minImpact}
|
||||
onChange={(e) => setMinImpact(Number(e.target.value))}
|
||||
className="w-24 accent-teal-600 cursor-pointer"
|
||||
/>
|
||||
<span className="font-bold text-slate-800 font-mono bg-slate-100 px-2.5 py-0.5 rounded-full border border-slate-200">
|
||||
{toPersianDigits(minImpact)} از ۵
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(minImpact)}
|
||||
onValueChange={(v) => setMinImpact(Number(v))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n === 1 ? 'همه شدتها' : `شدت اثر ${toPersianDigits(n)}+`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Active Results Counter */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 px-1 font-medium">
|
||||
<span>
|
||||
نمایش <strong className="text-slate-900 font-bold">{toPersianDigits(filteredItems.length)}</strong> مورد از مجموع{' '}
|
||||
<strong className="text-slate-900 font-bold">{toPersianDigits(items.length)}</strong> موجودیت پایگاه رصد
|
||||
</span>
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<X />
|
||||
پاککردن
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* View Mode 1: Interactive Circular Radar */}
|
||||
{viewMode === 'radar' && (
|
||||
<div className="intel-card p-8 bg-white border border-slate-200 rounded-3xl shadow-sm flex flex-col items-center justify-center animate-fadeIn">
|
||||
<p className="tnum mb-3 px-1 text-[12px] text-muted-foreground">
|
||||
{toPersianDigits(filtered.length)} مورد از {toPersianDigits(items.length)}
|
||||
</p>
|
||||
|
||||
{view === 'radar' && (
|
||||
<Card data-card className="animate-in fade-in duration-200">
|
||||
<CardContent className="flex items-center justify-center p-3 sm:p-6">
|
||||
<FuturesRadar
|
||||
items={filteredItems}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedHorizon={selectedHorizon}
|
||||
items={filtered}
|
||||
selectedCategory={category}
|
||||
selectedHorizon={horizon}
|
||||
onSelectItem={onSelectItem}
|
||||
height={680}
|
||||
height={560}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* View Mode 2: Card Grid */}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 animate-fadeIn">
|
||||
{filteredItems.map((item) => (
|
||||
<IntelligenceCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => onSelectItem(item)}
|
||||
/>
|
||||
{view === 'grid' && (
|
||||
<div className="grid animate-in grid-cols-1 gap-3 fade-in duration-200 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{filtered.map((item) => (
|
||||
<IntelligenceCard key={item.id} item={item} onClick={() => onSelectItem(item)} />
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="col-span-full py-16 text-center text-slate-400 text-sm">
|
||||
هیچ موردی منطبق با فیلترهای انتخابی یافت نشد.
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && <EmptyState onReset={resetFilters} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Mode 3: Matrix Table */}
|
||||
{viewMode === 'table' && (
|
||||
<div className="intel-card overflow-x-auto p-0 bg-white border border-slate-200 rounded-3xl shadow-sm animate-fadeIn">
|
||||
<table className="w-full text-right text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-slate-500 border-b border-slate-200">
|
||||
<th className="p-4 font-bold">عنوان پدیده / سیگنال</th>
|
||||
<th className="p-4 font-bold">دستهبندی</th>
|
||||
<th className="p-4 font-bold">افق زمانی</th>
|
||||
<th className="p-4 font-bold">شدت اثر</th>
|
||||
<th className="p-4 font-bold">عدم قطعیت</th>
|
||||
<th className="p-4 font-bold">دامنه</th>
|
||||
<th className="p-4 font-bold">وضعیت</th>
|
||||
<th className="p-4 font-bold">اقدام</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredItems.map((item) => (
|
||||
<tr
|
||||
{view === 'table' && (
|
||||
<Card data-card className="animate-in overflow-hidden p-0 fade-in duration-200">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-right">عنوان</TableHead>
|
||||
<TableHead className="text-right">دسته</TableHead>
|
||||
<TableHead className="text-right">افق</TableHead>
|
||||
<TableHead className="text-right">شدت اثر</TableHead>
|
||||
<TableHead className="text-right">عدم قطعیت</TableHead>
|
||||
<TableHead className="text-right">دامنه</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((item) => {
|
||||
const meta = CATEGORY_META[item.category];
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
onClick={() => onSelectItem(item)}
|
||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<td className="p-4 font-extrabold text-slate-900 max-w-xs">{item.title}</td>
|
||||
<td className="p-4">
|
||||
<TableCell className="max-w-xs font-semibold">
|
||||
{item.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className="px-2.5 py-0.5 rounded-full text-[10.5px] font-bold"
|
||||
style={{
|
||||
backgroundColor: `${getCategoryColor(item.category)}15`,
|
||||
color: getCategoryColor(item.category)
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11.5px] font-medium"
|
||||
style={{ color: meta.color }}
|
||||
>
|
||||
{getCategoryLabelFa(item.category)}
|
||||
<span className="cat-dot" style={{ background: meta.color }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-slate-600">{getHorizonLabelFa(item.horizon)}</td>
|
||||
<td className="p-4 font-bold text-teal-600 font-mono">{toPersianDigits(item.impactScore)}/۵</td>
|
||||
<td className="p-4 text-slate-500 font-mono">{toPersianDigits(item.uncertaintyScore)}/۵</td>
|
||||
<td className="p-4 text-slate-600">{item.geographicScope}</td>
|
||||
<td className="p-4">
|
||||
<span className="badge badge-gray">{item.status}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<button className="text-teal-600 font-bold hover:underline">
|
||||
مشاهده پرونده ↗
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{getHorizonLabelFa(item.horizon)}
|
||||
</TableCell>
|
||||
<TableCell className="tnum font-semibold">
|
||||
{toPersianDigits(item.impactScore)}/۵
|
||||
</TableCell>
|
||||
<TableCell className="tnum text-muted-foreground">
|
||||
{toPersianDigits(item.uncertaintyScore)}/۵
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.geographicScope}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{filtered.length === 0 && <EmptyState onReset={resetFilters} />}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterChip: React.FC<{
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
color?: string;
|
||||
}> = ({ active, onClick, label, color }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
active
|
||||
? 'border-transparent bg-foreground text-background'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{color && (
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{ background: active ? 'currentColor' : color }}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const EmptyState: React.FC<{ onReset: () => void }> = ({ onReset }) => (
|
||||
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">موردی با این فیلترها یافت نشد.</p>
|
||||
<Button variant="outline" size="sm" onClick={onReset}>
|
||||
پاککردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import React from 'react';
|
||||
import { IntelligenceItem, University1415Dimension, JobEntity } from '../types/futures';
|
||||
import { toPersianDigits, getCategoryLabelFa, getHorizonLabelFa } from '../utils/persianNumbers';
|
||||
import { Printer, Download, FileText, CheckCircle2, AlertTriangle, Target, Compass } from 'lucide-react';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { toPersianDigits, getHorizonLabelFa } from '../utils/persianNumbers';
|
||||
import { CATEGORY_META, CATEGORY_ORDER } from '../utils/categories';
|
||||
import { HORIZON_START, HORIZON_END } from '../utils/horizon';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Printer } from 'lucide-react';
|
||||
|
||||
interface ReportsPageProps {
|
||||
items: IntelligenceItem[];
|
||||
|
|
@ -10,193 +24,160 @@ interface ReportsPageProps {
|
|||
}
|
||||
|
||||
export const ReportsPage: React.FC<ReportsPageProps> = ({ items, dimensions, jobs }) => {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const criticalItems = items.filter((i) => i.status === 'critical' || i.impactScore === 5);
|
||||
const sortedGaps = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||
const avgReadiness = Math.round(
|
||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
||||
const criticalItems = items.filter(
|
||||
(i) => i.status === 'critical' || i.impactScore === 5
|
||||
);
|
||||
const byGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||
|
||||
const avgReadiness = dimensions.length
|
||||
? Math.round(
|
||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
const growingJobs = jobs.filter((j) => j.type === 'growing').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-5xl mx-auto">
|
||||
{/* Action Header (Hidden in Print) */}
|
||||
<div className="no-print flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-teal">مرکز هوشمندی راهبردی</span>
|
||||
<span className="text-slate-400 text-xs">گزارش مدیریتی و نهادی</span>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl text-white">
|
||||
گزارشهای راهبردی و بولتنهای آیندهپژوهی
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
||||
نسخه آماده چاپ و خروجی رسمی ویژه اعضای هیئت رئیسه و شورای دانشگاه اصفهان
|
||||
</p>
|
||||
<div className="mx-auto max-w-5xl animate-in fade-in duration-300">
|
||||
<div className="no-print">
|
||||
<PageHeader
|
||||
title="گزارشها"
|
||||
description="خلاصه وضعیت رصد و شکافها، آماده چاپ."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => window.print()}>
|
||||
<Printer />
|
||||
چاپ
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="btn btn-primary text-xs py-2 px-4 flex items-center gap-1.5 shadow-lg"
|
||||
>
|
||||
<Printer size={15} />
|
||||
<span>چاپ گزارش راهبردی (PDF)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printable Institutional Report Document */}
|
||||
<div className="intel-card p-8 lg:p-12 flex flex-col gap-8 bg-[#0c162b] border-[#1c2c4d] shadow-2xl">
|
||||
{/* Document Header */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 pb-6 border-b-2 border-teal-500/40">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#091122] border border-teal-400 flex items-center justify-center text-teal-400 font-bold">
|
||||
<Compass size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white font-black text-xl lg:text-2xl">
|
||||
دانشگاه اصفهان — رادار آیندهپژوهی و هوشمندی راهبردی
|
||||
<Card data-card className="p-6 sm:p-8">
|
||||
{/* Document header */}
|
||||
<header className="mb-6 border-b pb-5">
|
||||
<h2 className="text-lg font-bold">
|
||||
رادار آینده دانشگاه اصفهان — گزارش وضعیت
|
||||
</h2>
|
||||
<div className="text-slate-400 text-xs mt-1">
|
||||
بولتن تحلیلی شماره ۱۲ | افق راهبردی دانشگاه در سال ۱۴۱۵
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left text-xs text-slate-400 flex flex-col">
|
||||
<span className="font-bold text-white">طبقهبندی: محرمانه راهبردی</span>
|
||||
<span>تاریخ انتشار: پاییز ۱۴۰۳</span>
|
||||
<span className="font-mono text-teal-400">UI-FORESIGHT-1415</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Summary Narrative */}
|
||||
<div className="flex flex-col gap-3 text-xs leading-relaxed text-slate-200 bg-[#091122] p-5 rounded-xl border border-slate-800">
|
||||
<h3 className="text-teal-300 font-extrabold text-sm flex items-center gap-2">
|
||||
<FileText size={16} />
|
||||
<span>چکیده اجرایی ویژه ریاست محترم و هیئت رئیسه دانشگاه اصفهان</span>
|
||||
</h3>
|
||||
<p>
|
||||
این گزارش تحلیلی حاصل پایش مستمر{' '}
|
||||
<strong className="text-white">{toPersianDigits(items.length)}</strong> پیشران، فناوری، سیاست و سیگنال ضعیف آینده توسط مرکز هوشمندی دانشگاه اصفهان است. ارزیابی جامع ۱۰ کلانویژگی افق ۱۴۱۵ نشان میدهد که شاخص کل آمادگی دانشگاه در حال حاضر{' '}
|
||||
<strong className="text-teal-400 font-mono font-bold">
|
||||
{toPersianDigits(avgReadiness)}٪
|
||||
</strong>{' '}
|
||||
بوده و فاصله معناداری با وضعیت مطلوب دارد.
|
||||
</p>
|
||||
<p>
|
||||
بزرگترین شکافهای عملکردی در بعد{' '}
|
||||
<strong className="text-rose-400 font-bold">«هوشمصنوعیبنیان» (شکاف ۵.۳ نمره)</strong> و{' '}
|
||||
<strong className="text-amber-400 font-bold">«انعطافپذیر و چابک» (شکاف ۵.۴ نمره)</strong> متمرکز است که به دلیل حفظ الگوهای سنتی امتحانات و دیوانسالاری اداری ایجاد شده است. در نقطه مقابل، بعد «انسانمحور» و «بینرشتهای» با ۶۰٪ آمادگی به عنوان مزیت رقابتی دانشگاه شناخته میشوند.
|
||||
<p className="tnum mt-1 text-[12px] text-muted-foreground">
|
||||
افق راهبردی {toPersianDigits(HORIZON_START)} تا {toPersianDigits(HORIZON_END)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Figures, all derived from the dataset. */}
|
||||
<section className="mb-7">
|
||||
<SectionTitle>در یک نگاه</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Figure label="موارد رصدشده" value={toPersianDigits(items.length)} />
|
||||
<Figure label="موارد بحرانی" value={toPersianDigits(criticalItems.length)} />
|
||||
<Figure label="آمادگی ۱۴۱۵" value={`${toPersianDigits(avgReadiness)}٪`} />
|
||||
<Figure label="مشاغل رو به رشد" value={toPersianDigits(growingJobs)} />
|
||||
</div>
|
||||
|
||||
{/* High Priority Critical Items Table */}
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<h3 className="text-rose-400 font-extrabold text-sm flex items-center gap-2">
|
||||
<AlertTriangle size={16} />
|
||||
<span>جدول پیشرانهای با فوریت بحرانی و نیازمند واکنش فوری</span>
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
const count = items.filter((i) => i.category === cat).length;
|
||||
return (
|
||||
<div key={cat} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
|
||||
<span className="cat-dot" style={{ background: meta.color }} />
|
||||
{meta.label}
|
||||
</div>
|
||||
<div className="kpi-number mt-1 text-lg font-bold">
|
||||
{toPersianDigits(count)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right text-xs border border-slate-800 rounded-lg overflow-hidden">
|
||||
<thead className="bg-[#091122] text-slate-400">
|
||||
<tr>
|
||||
<th className="p-3 border-b border-slate-800">عنوان پدیده / سیگنال</th>
|
||||
<th className="p-3 border-b border-slate-800">رده</th>
|
||||
<th className="p-3 border-b border-slate-800">افق زمانی</th>
|
||||
<th className="p-3 border-b border-slate-800">ضریب اثر</th>
|
||||
<th className="p-3 border-b border-slate-800">مهمترین پیامد برای دانشگاه</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/80">
|
||||
{criticalItems.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="p-3 font-bold text-white">{c.title}</td>
|
||||
<td className="p-3 text-slate-300">{getCategoryLabelFa(c.category)}</td>
|
||||
<td className="p-3 text-slate-400">{getHorizonLabelFa(c.horizon)}</td>
|
||||
<td className="p-3 text-rose-400 font-bold font-mono">{toPersianDigits(c.impactScore)}/۵</td>
|
||||
<td className="p-3 text-slate-300 max-w-sm">{c.whyItMatters}</td>
|
||||
</tr>
|
||||
{/* Critical items */}
|
||||
{criticalItems.length > 0 && (
|
||||
<section className="mb-7">
|
||||
<SectionTitle>مواردی که نیازمند تصمیماند</SectionTitle>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-right">عنوان</TableHead>
|
||||
<TableHead className="text-right">دسته</TableHead>
|
||||
<TableHead className="text-right">افق</TableHead>
|
||||
<TableHead className="text-right">اثر</TableHead>
|
||||
<TableHead className="text-right">پیامد</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{criticalItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-semibold">{item.title}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{CATEGORY_META[item.category].label}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{getHorizonLabelFa(item.horizon)}
|
||||
</TableCell>
|
||||
<TableCell className="tnum font-semibold">
|
||||
{toPersianDigits(item.impactScore)}/۵
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm text-muted-foreground">
|
||||
{item.whyItMatters}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 1415 Readiness & Gap Matrix */}
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<h3 className="text-teal-400 font-extrabold text-sm flex items-center gap-2">
|
||||
<Target size={16} />
|
||||
<span>ماتریس شکافهای راهبردی دانشگاه اصفهان در افق ۱۴۱۵</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{sortedGaps.map((dim) => (
|
||||
<div
|
||||
key={dim.id}
|
||||
className="bg-[#091122] p-3 rounded-xl border border-slate-800 flex flex-col gap-1.5"
|
||||
{/* Gap matrix */}
|
||||
<section>
|
||||
<SectionTitle>شکاف ویژگیهای {toPersianDigits(HORIZON_END)}</SectionTitle>
|
||||
<div className="grid grid-cols-1 gap-2.5 md:grid-cols-2">
|
||||
{byGap.map((dim) => (
|
||||
<div key={dim.id} className="rounded-lg border p-3.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12.5px] font-semibold">{dim.title}</span>
|
||||
<span
|
||||
className="tnum shrink-0 rounded px-1.5 py-0.5 text-[11px] font-semibold"
|
||||
style={{
|
||||
background: 'var(--negative-soft)',
|
||||
color: 'var(--negative)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-white">{dim.title}</span>
|
||||
<span className="text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded text-[11px]">
|
||||
شکاف: {toPersianDigits(dim.gap)} نمره
|
||||
شکاف {toPersianDigits(dim.gap)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-slate-400 text-[11px]">
|
||||
<span>وضع موجود: {toPersianDigits(dim.currentScore)}/۱۰</span>
|
||||
<span>هدف ۱۴۱۵: {toPersianDigits(dim.targetScore)}/۱۰</span>
|
||||
<span className="text-teal-300 font-semibold">{toPersianDigits(dim.readinessPercentage)}٪ آمادگی</span>
|
||||
</div>
|
||||
<div className="text-slate-300 text-[11px] mt-1 border-t border-slate-800 pt-1">
|
||||
<strong>اقدام کلیدی:</strong> {dim.strategicRecommendations[0]}
|
||||
|
||||
<div className="tnum flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>موجود {toPersianDigits(dim.currentScore)}/۱۰</span>
|
||||
<span>هدف {toPersianDigits(dim.targetScore)}/۱۰</span>
|
||||
<span>{toPersianDigits(dim.readinessPercentage)}٪ آمادگی</span>
|
||||
</div>
|
||||
|
||||
{dim.strategicRecommendations[0] && (
|
||||
<p className="mt-2 border-t pt-2 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">اقدام پیشنهادی: </span>
|
||||
{dim.strategicRecommendations[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actionable Directives for Academic Leadership */}
|
||||
<div className="flex flex-col gap-3 text-xs bg-gradient-to-br from-[#0b172e] to-[#091d24] p-5 rounded-xl border border-teal-500/30">
|
||||
<h3 className="text-teal-300 font-black text-sm flex items-center gap-2">
|
||||
<CheckCircle2 size={16} />
|
||||
<span>پنج فرمان راهبردی هیئت رئیسه برای سال تحصیلی پیشرو</span>
|
||||
</h3>
|
||||
|
||||
<ol className="list-decimal list-inside text-slate-200 space-y-2 text-[11.5px] leading-relaxed">
|
||||
<li>
|
||||
<strong>تأسیس مرکز هوش مصنوعی شناختی و تحول آموزش:</strong> بازطراحی شیوههای سنجش بدون اتکا به آزمون کتبی سنتی در ۳۰ گروه آموزشی.
|
||||
</li>
|
||||
<li>
|
||||
<strong>ابلاغ طرح مأموریتهای استانی زایندهرود و فرونشست:</strong> اختصاص حداقل ۵۰٪ پژوهانههای دانشگاه به راهحلهای علمی بحران آب و خاک اصفهان.
|
||||
</li>
|
||||
<li>
|
||||
<strong>راهاندازی دورههای صنعتی Co-op با شرکتهای برتر استان:</strong> تضمین بورسیه و حقوق ماهیانه برای دانشجویان کارشناسی در صنایع فولاد و هایتک.
|
||||
</li>
|
||||
<li>
|
||||
<strong>توسعه پلتفرم دیپلماسی علمی منطقهای:</strong> ارتقای خوابگاهها و خدمات زبان فارسی برای هدفگذاری جذب ۳۰۰۰ دانشجوی خارجی.
|
||||
</li>
|
||||
<li>
|
||||
<strong>مقرراتزدایی آموزشی و استقرار دورههای ریزمدرک:</strong> تفویض اختیار تغییر ۴۰٪ سرفصلها به شوراهای دانشکدهها.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Signature Box */}
|
||||
<div className="pt-6 border-t border-slate-800 flex items-center justify-between text-xs text-slate-400">
|
||||
<div>
|
||||
<span>تأییدیه مرکز دیدهبانی و هوشمندی راهبردی</span>
|
||||
<div className="font-bold text-white mt-1">دکتر مریم میرمحمدی / مدیر پروژه رادار آینده</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span>ملاحظه و ابلاغ ریاست دانشگاه اصفهان</span>
|
||||
<div className="font-bold text-teal-400 mt-1">دکتر حسین هرسیج / رئیس دانشگاه</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<h3 className="mb-3 text-[14px] font-bold">{children}</h3>
|
||||
);
|
||||
|
||||
const Figure: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-[11.5px] text-muted-foreground">{label}</div>
|
||||
<div className="kpi-number mt-1 text-xl font-bold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { dataService } from '../services/dataService';
|
||||
import { Settings, Download, Upload, RotateCcw, Shield, CheckCircle2, Moon, Sun } from 'lucide-react';
|
||||
|
||||
interface SettingsPageProps {
|
||||
onRefreshData: () => void;
|
||||
}
|
||||
|
||||
export const SettingsPage: React.FC<SettingsPageProps> = ({ onRefreshData }) => {
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = dataService.exportJson();
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `ui_futures_radar_backup_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.click();
|
||||
showSuccess('فایل پشتیبان JSON با موفقیت ذخیره شد');
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (!jsonInput.trim()) return;
|
||||
const success = dataService.importJson(jsonInput);
|
||||
if (success) {
|
||||
onRefreshData();
|
||||
showSuccess('دادهها با موفقیت بازیابی شدند');
|
||||
setJsonInput('');
|
||||
} else {
|
||||
alert('ساختار فایل JSON نامعتبر است');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('آیا از بازنشانی دادهها به تنظیمات پیشفرض سازمانی دانشگاه اصفهان اطمینان دارید؟')) {
|
||||
dataService.resetToDefault();
|
||||
onRefreshData();
|
||||
showSuccess('تنظیمات و دادههای سامانه به وضعیت اولیه بازگردانی شد');
|
||||
}
|
||||
};
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setTimeout(() => setSuccessMsg(''), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="pb-2 border-b border-slate-800">
|
||||
<h1 className="page-title text-2xl text-white">
|
||||
تنظیمات سامانه و مدیریت پشتیبان (Platform Settings)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
||||
پیکربندی محیط، پشتیبانگیری از دادههای آیندهپژوهی، بازیابی اطلاعات و مدیریت نشست
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{successMsg && (
|
||||
<div className="p-3 bg-teal-950/40 border border-teal-500/50 rounded-xl text-teal-300 text-xs flex items-center gap-2 animate-fadeIn">
|
||||
<CheckCircle2 size={16} className="text-teal-400" />
|
||||
<span>{successMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Institutional System Info */}
|
||||
<div className="intel-card flex flex-col gap-3">
|
||||
<h3 className="text-white font-bold text-sm border-b border-slate-800 pb-2">
|
||||
مشخصات استقرار سامانه سازمانی
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
||||
<span className="text-slate-400 text-[11px] block">نام سامانه:</span>
|
||||
<span className="font-bold text-white">رادار آینده دانشگاه اصفهان (UFR)</span>
|
||||
</div>
|
||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
||||
<span className="text-slate-400 text-[11px] block">افق چشمانداز:</span>
|
||||
<span className="font-bold text-teal-400 font-mono">سال ۱۴۱۵ (۱۰ ساله)</span>
|
||||
</div>
|
||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
||||
<span className="text-slate-400 text-[11px] block">مالکیت فکری و سازمانی:</span>
|
||||
<span className="font-bold text-white">معاونت پژوهش و فناوری دانشگاه اصفهان</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup & Export / Import */}
|
||||
<div className="intel-card flex flex-col gap-4">
|
||||
<h3 className="text-white font-bold text-sm border-b border-slate-800 pb-2 flex items-center gap-2">
|
||||
<Download size={16} className="text-teal-400" />
|
||||
<span>پشتیبانگیری و بارگذاری دادهها (JSON Export / Import)</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-slate-300 text-xs leading-relaxed">
|
||||
شما میتوانید از کلیه موجودیتهای رادار، نمرات کالیبرهشده ابعاد ۱۴۱۵ و تحلیلها یک نسخه کامل متنی با ساختار JSON دریافت کرده و در صورت نیاز بر روی سرور دیگری بازیابی فرمایید.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="btn btn-primary text-xs py-2 px-4 flex items-center gap-2"
|
||||
>
|
||||
<Download size={15} />
|
||||
<span>دریافت خروجی کامل JSON (Export)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JSON Import Box */}
|
||||
<div className="mt-2 pt-3 border-t border-slate-800/80 flex flex-col gap-2">
|
||||
<label className="text-slate-300 text-xs font-semibold">بارگذاری و بازیابی از فایل JSON:</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder="محتوای فایل JSON خروجی را اینجا الصاق کنید..."
|
||||
className="w-full bg-[#081020] border border-slate-800 rounded-lg p-2.5 text-white font-mono text-xs outline-none focus:border-teal-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!jsonInput.trim()}
|
||||
className="btn btn-secondary text-xs py-2 px-4 self-end flex items-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
<Upload size={14} />
|
||||
<span>بارگذاری و اعمال دادهها</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone: Reset to Baseline */}
|
||||
<div className="intel-card border-rose-500/30 flex flex-col gap-3">
|
||||
<h3 className="text-rose-400 font-bold text-sm flex items-center gap-2">
|
||||
<RotateCcw size={16} />
|
||||
<span>بازنشانی به تنظیمات کارخانه (Factory Reset)</span>
|
||||
</h3>
|
||||
<p className="text-slate-300 text-xs leading-relaxed">
|
||||
با انتخاب این گزینه، تمامی تغییرات دستی و موارد اضافه شده پاک شده و پایگاه هوشمندی با بیش از ۱۰۰ سیگنال، روند و فناوری معیار دانشگاه اصفهان دوباره مقداردهی خواهد شد.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="btn btn-secondary text-xs py-2 px-4 text-rose-300 hover:bg-rose-950/40 border-rose-500/40 self-start"
|
||||
>
|
||||
بازنشانی کامل به دادههای معیار سازمانی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
import React from 'react';
|
||||
import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../types/futures';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IntelligenceItem,
|
||||
University1415Dimension,
|
||||
JobEntity,
|
||||
SkillEntity
|
||||
} from '../types/futures';
|
||||
import { StrategicGraph } from '../components/visual/StrategicGraph';
|
||||
import { GitFork, Layers, Compass, ArrowLeft } from 'lucide-react';
|
||||
import { ImpactNetwork } from '../components/visual/ImpactNetwork';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Share2, Route } from 'lucide-react';
|
||||
|
||||
interface StrategicNetworkPageProps {
|
||||
items: IntelligenceItem[];
|
||||
|
|
@ -20,52 +30,56 @@ export const StrategicNetworkPage: React.FC<StrategicNetworkPageProps> = ({
|
|||
onSelectItem,
|
||||
onSelectDimension
|
||||
}) => {
|
||||
const [view, setView] = useState<'network' | 'chain'>('network');
|
||||
|
||||
const handleSelectEntity = (type: string, id: string) => {
|
||||
if (type === 'item') {
|
||||
const it = items.find((i) => i.id === id);
|
||||
if (it) onSelectItem(it);
|
||||
const found = items.find((i) => i.id === id);
|
||||
if (found) onSelectItem(found);
|
||||
} else if (type === 'dimension') {
|
||||
const dim = dimensions.find((d) => d.id === id);
|
||||
if (dim) onSelectDimension(dim);
|
||||
const found = dimensions.find((d) => d.id === id);
|
||||
if (found) onSelectDimension(found);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-teal">تحلیل سیستمی و سناریوسازی</span>
|
||||
<span className="text-slate-400 text-xs">نگاشت شبکه علّی اثرات</span>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl text-white">
|
||||
شبکه روابط و زنجیره ارزش تحول راهبردی (Strategic Impact Network)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
||||
ردگیری مسیر علّی و همبستگی پدیدهها: پیشرانهای بیرونی ← اختلال در آموزش عالی ← مهارتها ← مشاغل ← ابعاد ۱۴۱۵ دانشگاه اصفهان
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title="شبکه روابط"
|
||||
description="مسیر اثر هر پیشران: از فناوری یا سیگنال بیرونی تا مهارت، شغل و شکاف قابلیت دانشگاه."
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as typeof view)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="network" className="gap-1.5">
|
||||
<Share2 className="size-3.5" />
|
||||
<span className="hidden sm:inline">شبکه</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chain" className="gap-1.5">
|
||||
<Route className="size-3.5" />
|
||||
<span className="hidden sm:inline">زنجیره</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Conceptual Network Principle */}
|
||||
<div className="intel-card border-teal-500/30 p-4 bg-[#0a1428] flex flex-col md:flex-row items-center justify-between gap-4 text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-500/20 text-teal-400 flex items-center justify-center shrink-0">
|
||||
<Compass size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-white block text-sm">
|
||||
اصل اساسی: رادار آینده دانشگاه یک گردآورنده اخبار ساده نیست!
|
||||
</span>
|
||||
<p className="text-slate-300 leading-relaxed mt-0.5">
|
||||
ارزش افزوده این سامانه در اتصال نقاط کور است: هر فناوری (مانند هوش مصنوعی زاینده) مستقیماً به ضرورت تحول ارزشیابی، دگرگونی مهارتها، ظهور مشاغل نو و شکاف آمادگی بعد AI-Native دانشگاه اصفهان متصل است.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{view === 'network' && (
|
||||
<Card data-card className="animate-in fade-in duration-300">
|
||||
<CardContent className="p-3 sm:p-6">
|
||||
<ImpactNetwork
|
||||
items={items}
|
||||
dimensions={dimensions}
|
||||
jobs={jobs}
|
||||
skills={skills}
|
||||
onOpenItem={onSelectItem}
|
||||
onOpenDimension={onSelectDimension}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Strategic Graph Component */}
|
||||
{view === 'chain' && (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<StrategicGraph
|
||||
items={items}
|
||||
dimensions={dimensions}
|
||||
|
|
@ -74,5 +88,7 @@ export const StrategicNetworkPage: React.FC<StrategicNetworkPageProps> = ({
|
|||
onSelectEntity={handleSelectEntity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { University1415Dimension, IntelligenceItem } from '../types/futures';
|
||||
import { Dimension1415Card } from '../components/cards/Dimension1415Card';
|
||||
import { DimensionBloom } from '../components/visual/DimensionBloom';
|
||||
import { DimensionGapTrack } from '../components/visual/DimensionGapTrack';
|
||||
import { PageHeader } from '../components/common/PageHeader';
|
||||
import { dataService } from '../services/dataService';
|
||||
import { toPersianDigits } from '../utils/persianNumbers';
|
||||
import { CATEGORY_META } from '../utils/categories';
|
||||
import { HORIZON_END, HORIZON_YEAR_INDEX, HORIZON_SPAN } from '../utils/horizon';
|
||||
import { useCountUp } from '@/hooks/use-count-up';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Target,
|
||||
ArrowLeft,
|
||||
X,
|
||||
Lightbulb,
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
BookOpen,
|
||||
Sliders,
|
||||
Save
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
SlidersHorizontal,
|
||||
Save,
|
||||
Flower,
|
||||
BarChartHorizontal,
|
||||
LayoutGrid
|
||||
} from 'lucide-react';
|
||||
|
||||
interface University1415PageProps {
|
||||
|
|
@ -23,285 +37,340 @@ interface University1415PageProps {
|
|||
onRefreshDimensions: () => void;
|
||||
}
|
||||
|
||||
type View = 'bloom' | 'gap' | 'cards';
|
||||
|
||||
/** Animated headline figure. */
|
||||
const Figure: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
decimals?: number;
|
||||
tone?: string;
|
||||
sub?: string;
|
||||
}> = ({ label, value, suffix = '', decimals = 0, tone, sub }) => {
|
||||
const animated = useCountUp(value, 900, decimals);
|
||||
return (
|
||||
<Card data-card className="gap-0 p-3.5">
|
||||
<span className="text-[11.5px] text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className="kpi-number mt-1.5 text-2xl font-bold"
|
||||
style={tone ? { color: tone } : undefined}
|
||||
>
|
||||
{toPersianDigits(decimals ? animated.toFixed(decimals) : Math.round(animated))}
|
||||
{suffix}
|
||||
</span>
|
||||
{sub && <span className="mt-0.5 text-[10.5px] text-muted-foreground">{sub}</span>}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const University1415Page: React.FC<University1415PageProps> = ({
|
||||
dimensions,
|
||||
items,
|
||||
onSelectItem,
|
||||
onRefreshDimensions
|
||||
}) => {
|
||||
const [selectedDimension, setSelectedDimension] = useState<University1415Dimension | null>(null);
|
||||
const [isEditingScores, setIsEditingScores] = useState(false);
|
||||
const [editCurrent, setEditCurrent] = useState<number>(0);
|
||||
const [editTarget, setEditTarget] = useState<number>(0);
|
||||
const [view, setView] = useState<View>('bloom');
|
||||
const [selected, setSelected] = useState<University1415Dimension | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editCurrent, setEditCurrent] = useState(0);
|
||||
const [editTarget, setEditTarget] = useState(0);
|
||||
|
||||
const handleOpenDetail = (dim: University1415Dimension) => {
|
||||
setSelectedDimension(dim);
|
||||
const openDetail = (dim: University1415Dimension) => {
|
||||
setSelected(dim);
|
||||
setEditCurrent(dim.currentScore);
|
||||
setEditTarget(dim.targetScore);
|
||||
setIsEditingScores(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleSaveScores = () => {
|
||||
if (!selectedDimension) return;
|
||||
dataService.updateDimensionScores(selectedDimension.id, editCurrent, editTarget);
|
||||
const saveScores = () => {
|
||||
if (!selected) return;
|
||||
dataService.updateDimensionScores(selected.id, editCurrent, editTarget);
|
||||
onRefreshDimensions();
|
||||
const updated = dataService.getDimensionById(selectedDimension.id);
|
||||
if (updated) setSelectedDimension(updated);
|
||||
setIsEditingScores(false);
|
||||
const updated = dataService.getDimensionById(selected.id);
|
||||
if (updated) setSelected(updated);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
// Find linked items for selected dimension
|
||||
const linkedTrends = selectedDimension
|
||||
? items.filter((i) => selectedDimension.relatedTrendIds?.includes(i.id))
|
||||
: [];
|
||||
const linkedTech = selectedDimension
|
||||
? items.filter((i) => selectedDimension.relatedTechIds?.includes(i.id))
|
||||
: [];
|
||||
const linkedPolicies = selectedDimension
|
||||
? items.filter((i) => selectedDimension.relatedPolicyIds?.includes(i.id))
|
||||
const stats = useMemo(() => {
|
||||
if (!dimensions.length) {
|
||||
return { avg: 0, widest: null as University1415Dimension | null, totalGap: 0, strongest: null as University1415Dimension | null };
|
||||
}
|
||||
const byGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||
const byScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
|
||||
return {
|
||||
avg: Math.round(
|
||||
dimensions.reduce((a, d) => a + d.readinessPercentage, 0) / dimensions.length
|
||||
),
|
||||
widest: byGap[0],
|
||||
strongest: byScore[0],
|
||||
totalGap: Number(dimensions.reduce((a, d) => a + d.gap, 0).toFixed(1))
|
||||
};
|
||||
}, [dimensions]);
|
||||
|
||||
const linked = selected
|
||||
? [
|
||||
...items
|
||||
.filter((i) => selected.relatedTrendIds?.includes(i.id))
|
||||
.map((i) => ({ item: i, cat: 'trend' as const })),
|
||||
...items
|
||||
.filter((i) => selected.relatedTechIds?.includes(i.id))
|
||||
.map((i) => ({ item: i, cat: 'technology' as const })),
|
||||
...items
|
||||
.filter((i) => selected.relatedPolicyIds?.includes(i.id))
|
||||
.map((i) => ({ item: i, cat: 'policy' as const }))
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge badge-teal">سند راهبردی چشمانداز دانشگاه آینده</span>
|
||||
<span className="text-slate-400 text-xs">افق دهساله ۱۴۱۵</span>
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title={`دانشگاه اصفهان در سال ${toPersianDigits(HORIZON_END)}`}
|
||||
description="ده ویژگی دانشگاه مطلوب، نمره وضع موجود و فاصلهای که تا هدف مانده است."
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as View)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="bloom" className="gap-1.5">
|
||||
<Flower className="size-3.5" />
|
||||
<span className="hidden sm:inline">بلوغ</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gap" className="gap-1.5">
|
||||
<BarChartHorizontal className="size-3.5" />
|
||||
<span className="hidden sm:inline">شکاف</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cards" className="gap-1.5">
|
||||
<LayoutGrid className="size-3.5" />
|
||||
<span className="hidden sm:inline">کارتها</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Headline figures */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Figure
|
||||
label="میانگین آمادگی"
|
||||
value={stats.avg}
|
||||
suffix="٪"
|
||||
tone="var(--primary)"
|
||||
sub={`سال ${toPersianDigits(HORIZON_YEAR_INDEX)} از ${toPersianDigits(HORIZON_SPAN)}`}
|
||||
/>
|
||||
<Figure
|
||||
label="مجموع شکاف"
|
||||
value={stats.totalGap}
|
||||
decimals={1}
|
||||
tone="var(--negative)"
|
||||
sub="نمره، در ۱۰ ویژگی"
|
||||
/>
|
||||
<Figure
|
||||
label="بزرگترین شکاف"
|
||||
value={stats.widest?.gap ?? 0}
|
||||
decimals={1}
|
||||
tone="var(--negative)"
|
||||
sub={stats.widest?.title}
|
||||
/>
|
||||
<Figure
|
||||
label="قویترین بعد"
|
||||
value={stats.strongest?.currentScore ?? 0}
|
||||
decimals={1}
|
||||
tone="var(--positive)"
|
||||
sub={stats.strongest?.title}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="page-title text-2xl lg:text-3xl text-white">
|
||||
دانشگاه اصفهان در سال ۱۴۱۵ (University 1415)
|
||||
</h1>
|
||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
||||
ده کلانویژگی، قابلیتهای بنیادین، وضعیت موجود، نمره مطلوب و اقدامات راهبردی تحول دانشگاه
|
||||
|
||||
{/* Main view */}
|
||||
<Card data-card className="mt-4">
|
||||
<CardContent className="p-3 sm:p-6">
|
||||
{view === 'bloom' && (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<DimensionBloom dimensions={dimensions} onSelect={openDetail} />
|
||||
<p className="mt-2 text-center text-[11.5px] text-muted-foreground">
|
||||
طول هر گلبرگ نمره وضع موجود است و بخش راهراه، فاصله تا هدف. رنگ از
|
||||
قرمز به سبز، نشاندهنده درصد آمادگی است.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-[#0c1833] border border-teal-500/30 rounded-xl px-4 py-2 text-right">
|
||||
<span className="text-[11px] text-slate-400 block">میانگین آمادگی سازمانی:</span>
|
||||
<span className="text-lg font-black text-teal-400 font-mono">
|
||||
{toPersianDigits(
|
||||
Math.round(
|
||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
||||
)
|
||||
)}
|
||||
٪
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 10 Dimension Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{dimensions.map((dim) => (
|
||||
<Dimension1415Card
|
||||
{view === 'gap' && (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<DimensionGapTrack dimensions={dimensions} onSelect={openDetail} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'cards' && (
|
||||
<div className="grid animate-in grid-cols-1 gap-3 fade-in duration-300 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{dimensions.map((dim, i) => (
|
||||
<div
|
||||
key={dim.id}
|
||||
dimension={dim}
|
||||
onClick={() => handleOpenDetail(dim)}
|
||||
/>
|
||||
className="animate-in fade-in slide-in-from-bottom-2"
|
||||
style={{ animationDelay: `${i * 45}ms`, animationFillMode: 'both' }}
|
||||
>
|
||||
<Dimension1415Card dimension={dim} onClick={() => openDetail(dim)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detailed Drilldown Modal for Selected Dimension */}
|
||||
{selectedDimension && (
|
||||
<div className="modal-overlay" onClick={() => setSelectedDimension(null)}>
|
||||
<div
|
||||
className="modal-content max-w-3xl p-6 flex flex-col gap-5 text-right animate-fadeIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ borderTop: `4px solid ${selectedDimension.pillarColor}` }}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between pb-3 border-b border-slate-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-slate-400">
|
||||
{selectedDimension.titleEn}
|
||||
</span>
|
||||
{/* Detail */}
|
||||
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
|
||||
{selected && (
|
||||
<>
|
||||
<DialogHeader className="gap-1">
|
||||
<span
|
||||
className="text-[11px] font-bold px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${selectedDimension.pillarColor}20`,
|
||||
color: selectedDimension.pillarColor
|
||||
}}
|
||||
>
|
||||
آمادگی: {toPersianDigits(selectedDimension.readinessPercentage)}٪
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-white font-black text-xl">
|
||||
{selectedDimension.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedDimension(null)}
|
||||
className="p-1 text-slate-400 hover:text-white rounded-lg"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
className="h-0.5 w-10 rounded-full"
|
||||
style={{ background: selected.pillarColor }}
|
||||
/>
|
||||
<DialogTitle className="text-lg">{selected.title}</DialogTitle>
|
||||
<p className="text-[11.5px] text-muted-foreground">{selected.titleEn}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded-lg border bg-muted/40 p-3.5">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 className="text-[13px] font-semibold">نمرات و شکاف</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(!editing)}>
|
||||
<SlidersHorizontal />
|
||||
{editing ? 'لغو' : 'ویرایش'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Vision & Definition */}
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="bg-[#091122] p-4 rounded-xl border border-slate-800">
|
||||
<span className="text-teal-400 font-bold block mb-1">چشمانداز و تعریف (Definition & Vision):</span>
|
||||
<p className="text-white font-semibold leading-relaxed mb-2">
|
||||
{selectedDimension.vision}
|
||||
</p>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
{selectedDimension.definition}
|
||||
</p>
|
||||
{!editing ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<ScoreBox
|
||||
label="وضع موجود"
|
||||
value={toPersianDigits(selected.currentScore)}
|
||||
/>
|
||||
<ScoreBox
|
||||
label={`هدف ${toPersianDigits(HORIZON_END)}`}
|
||||
value={toPersianDigits(selected.targetScore)}
|
||||
tone="var(--primary)"
|
||||
/>
|
||||
<ScoreBox
|
||||
label="شکاف"
|
||||
value={toPersianDigits(selected.gap)}
|
||||
tone="var(--negative)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Score Assessment & Gap Calibration */}
|
||||
<div className="bg-[#0c162d] p-4 rounded-xl border border-slate-800 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white font-bold">ارزیابی نمرات و شکاف راهبردی (Gap Analysis):</span>
|
||||
<button
|
||||
onClick={() => setIsEditingScores(!isEditingScores)}
|
||||
className="text-teal-400 hover:underline text-[11px] font-semibold"
|
||||
>
|
||||
{isEditingScores ? 'لغو کالیبراسیون' : 'کالیبراسیون نمرات سازمانی'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isEditingScores ? (
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
||||
<span className="text-slate-400 text-[11px] block">وضع موجود:</span>
|
||||
<span className="text-amber-400 font-black text-base">
|
||||
{toPersianDigits(selectedDimension.currentScore)} از ۱۰
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Progress value={selected.readinessPercentage} className="h-1.5" />
|
||||
<span className="tnum shrink-0 text-[11.5px] text-muted-foreground">
|
||||
{toPersianDigits(selected.readinessPercentage)}٪
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
||||
<span className="text-slate-400 text-[11px] block">هدف افق ۱۴۱۵:</span>
|
||||
<span className="text-teal-400 font-black text-base">
|
||||
{toPersianDigits(selectedDimension.targetScore)} از ۱۰
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-[#091122] p-3 rounded-lg border border-rose-500/30">
|
||||
<span className="text-slate-400 text-[11px] block">شکاف راهبردی:</span>
|
||||
<span className="text-rose-400 font-black text-base">
|
||||
{toPersianDigits(selectedDimension.gap)} نمره
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-[#091122] p-4 rounded-lg border border-teal-500/40 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-slate-300 block mb-1">وضع موجود (۱ تا ۱۰):</label>
|
||||
<input
|
||||
<Label className="mb-1.5 text-[12px]">وضع موجود (۱ تا ۱۰)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={1}
|
||||
max={10}
|
||||
value={editCurrent}
|
||||
onChange={(e) => setEditCurrent(Number(e.target.value))}
|
||||
className="w-full bg-[#081020] border border-slate-700 rounded p-2 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-slate-300 block mb-1">هدف ۱۴۱۵ (۱ تا ۱۰):</label>
|
||||
<input
|
||||
<Label className="mb-1.5 text-[12px]">هدف (۱ تا ۱۰)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={1}
|
||||
max={10}
|
||||
value={editTarget}
|
||||
onChange={(e) => setEditTarget(Number(e.target.value))}
|
||||
className="w-full bg-[#081020] border border-slate-700 rounded p-2 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveScores}
|
||||
className="btn btn-primary text-xs py-2 self-end flex items-center gap-1"
|
||||
>
|
||||
<Save size={13} />
|
||||
<span>ثبت و بازنگری شکاف</span>
|
||||
</button>
|
||||
<Button onClick={saveScores}>
|
||||
<Save />
|
||||
ثبت
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Supporting Evidence in University of Isfahan */}
|
||||
<div className="bg-[#091122] p-4 rounded-xl border border-slate-800">
|
||||
<span className="text-purple-400 font-bold block mb-2 flex items-center gap-1.5">
|
||||
<CheckCircle2 size={15} />
|
||||
شواهد و اقدامات در حال اجرا در دانشگاه اصفهان:
|
||||
</span>
|
||||
<ul className="list-disc list-inside text-slate-300 space-y-1.5 leading-relaxed text-[11.5px]">
|
||||
{selectedDimension.supportingEvidence.map((ev, idx) => (
|
||||
<li key={idx}>{ev}</li>
|
||||
<Section title="چشمانداز">
|
||||
<p className="text-[12.5px] leading-relaxed">{selected.vision}</p>
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{selected.definition}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="اقدامات در حال اجرا">
|
||||
<ul className="flex list-disc flex-col gap-1.5 ps-4 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{selected.supportingEvidence.map((ev, i) => (
|
||||
<li key={i}>{ev}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Strategic Recommendations */}
|
||||
<div className="bg-[#091122] p-4 rounded-xl border border-teal-500/30 bg-teal-950/10">
|
||||
<span className="text-teal-400 font-bold block mb-2 flex items-center gap-1.5">
|
||||
<Lightbulb size={15} />
|
||||
اقدامات راهبردی اولویتدار برای بستن شکاف (Actionable Roadmap):
|
||||
</span>
|
||||
<ol className="list-decimal list-inside text-slate-200 space-y-1.5 leading-relaxed text-[11.5px]">
|
||||
{selectedDimension.strategicRecommendations.map((rec, idx) => (
|
||||
<li key={idx}>{rec}</li>
|
||||
<Section title="اقدامات پیشنهادی برای بستن شکاف">
|
||||
<ol className="flex list-decimal flex-col gap-1.5 ps-4 text-[12.5px] leading-relaxed">
|
||||
{selected.strategicRecommendations.map((rec, i) => (
|
||||
<li key={i}>{rec}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Traceability: Linked Trends, Tech, Policies */}
|
||||
<div className="pt-2 border-t border-slate-800 flex flex-col gap-3">
|
||||
<span className="text-slate-400 font-bold text-xs">پیشرانها و فناوریهای مرتبط در رادار:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{linkedTrends.map((t) => (
|
||||
{linked.length > 0 && (
|
||||
<Section title="موارد مرتبط در رادار">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{linked.map(({ item, cat }) => (
|
||||
<button
|
||||
key={t.id}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setSelectedDimension(null);
|
||||
onSelectItem(t);
|
||||
setSelected(null);
|
||||
onSelectItem(item);
|
||||
}}
|
||||
className="bg-[#0b162e] hover:bg-[#122244] border border-sky-500/30 text-sky-300 px-3 py-1.5 rounded-lg text-xs flex items-center gap-1"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11.5px] transition-colors hover:bg-muted"
|
||||
>
|
||||
<TrendingUp size={12} />
|
||||
<span>{t.title}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{linkedTech.map((tc) => (
|
||||
<button
|
||||
key={tc.id}
|
||||
onClick={() => {
|
||||
setSelectedDimension(null);
|
||||
onSelectItem(tc);
|
||||
}}
|
||||
className="bg-[#0b162e] hover:bg-[#122244] border border-teal-500/30 text-teal-300 px-3 py-1.5 rounded-lg text-xs flex items-center gap-1"
|
||||
>
|
||||
<Cpu size={12} />
|
||||
<span>{tc.title}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{linkedPolicies.map((pol) => (
|
||||
<button
|
||||
key={pol.id}
|
||||
onClick={() => {
|
||||
setSelectedDimension(null);
|
||||
onSelectItem(pol);
|
||||
}}
|
||||
className="bg-[#0b162e] hover:bg-[#122244] border border-amber-500/30 text-amber-300 px-3 py-1.5 rounded-lg text-xs flex items-center gap-1"
|
||||
>
|
||||
<BookOpen size={12} />
|
||||
<span>{pol.title}</span>
|
||||
<span
|
||||
className="cat-dot"
|
||||
style={{ background: CATEGORY_META[cat].color }}
|
||||
/>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScoreBox: React.FC<{ label: string; value: string; tone?: string }> = ({
|
||||
label,
|
||||
value,
|
||||
tone
|
||||
}) => (
|
||||
<div className="rounded-md border bg-background p-2.5">
|
||||
<div className="text-[10.5px] text-muted-foreground">{label}</div>
|
||||
<div
|
||||
className="kpi-number mt-0.5 text-base font-bold"
|
||||
style={tone ? { color: tone } : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||
title,
|
||||
children
|
||||
}) => (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-[12px] font-semibold text-muted-foreground">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
|||
president: {
|
||||
id: 'usr-1',
|
||||
username: 'president',
|
||||
fullName: 'دکتر حسین هرسیج',
|
||||
fullName: 'ریاست دانشگاه',
|
||||
role: 'president',
|
||||
roleTitleFa: 'رئیس دانشگاه اصفهان',
|
||||
department: 'ریاست و هیئت رئیسه',
|
||||
|
|
@ -16,7 +16,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
|||
researcher: {
|
||||
id: 'usr-2',
|
||||
username: 'researcher',
|
||||
fullName: 'دکتر مریم میرمحمدی',
|
||||
fullName: 'پژوهشگر آیندهپژوهی',
|
||||
role: 'researcher',
|
||||
roleTitleFa: 'پژوهشگر ارشد رصد آینده و سیگنالشناسی',
|
||||
department: 'پژوهشکده مطالعات آینده و علوم شناختی',
|
||||
|
|
@ -26,7 +26,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
|||
planner: {
|
||||
id: 'usr-3',
|
||||
username: 'planner',
|
||||
fullName: 'دکتر علیرضا فروغی',
|
||||
fullName: 'برنامهریز راهبردی',
|
||||
role: 'planner',
|
||||
roleTitleFa: 'مدیر کل برنامهریزی راهبردی و تحول',
|
||||
department: 'معاونت طرح، برنامه و تحول آموزشی',
|
||||
|
|
@ -36,7 +36,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
|||
admin: {
|
||||
id: 'usr-4',
|
||||
username: 'admin',
|
||||
fullName: 'مهندس سعید فیروزبخش',
|
||||
fullName: 'راهبر سامانه',
|
||||
role: 'admin',
|
||||
roleTitleFa: 'راهبر ارشد سامانه رادار هوشمندی',
|
||||
department: 'مرکز فناوری اطلاعات و آمار دانشگاه',
|
||||
|
|
@ -108,11 +108,11 @@ class AuthService {
|
|||
const u = username.toLowerCase().trim();
|
||||
let matched: UserSession | undefined;
|
||||
|
||||
if (u === 'president' || u.includes('هرسیج') || u === 'p') {
|
||||
if (u === 'president' || u === 'p') {
|
||||
matched = PRESET_USERS.president;
|
||||
} else if (u === 'researcher' || u.includes('میرمحمدی') || u === 'r') {
|
||||
} else if (u === 'researcher' || u === 'r') {
|
||||
matched = PRESET_USERS.researcher;
|
||||
} else if (u === 'planner' || u.includes('فروغی') || u === 's') {
|
||||
} else if (u === 'planner' || u === 's') {
|
||||
matched = PRESET_USERS.planner;
|
||||
} else if (u === 'admin' || u === 'a') {
|
||||
matched = PRESET_USERS.admin;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export type Horizon = 'now' | 'near' | 'mid' | 'long';
|
||||
// 'now': افق اکنون (۱۴۰۳-۱۴۰۵) / 0-2 سال
|
||||
// 'near': افق نزدیک (۱۴۰۵-۱۴۰۸) / 2-5 سال
|
||||
// 'mid': افق میانمدت (۱۴۰۸-۱۴۱۱) / 5-8 سال
|
||||
// 'long': افق بلندمدت (۱۴۱۱-۱۴۱۵) / 8-12 سال
|
||||
// 'now': افق اکنون (۱۴۰۵-۱۴۰۷) / 0-2 سال
|
||||
// 'near': افق نزدیک (۱۴۰۷-۱۴۱۰) / 2-5 سال
|
||||
// 'mid': افق میانمدت (۱۴۱۰-۱۴۱۳) / 5-8 سال
|
||||
// 'long': افق بلندمدت (۱۴۱۳-۱۴۱۵) / 8-10 سال
|
||||
|
||||
export type Category = 'trend' | 'technology' | 'policy' | 'weak_signal';
|
||||
export type Confidence = 'low' | 'medium' | 'high';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type { Category } from '../types/futures';
|
||||
|
||||
/**
|
||||
* The four observation streams, worded as they are in the project brief.
|
||||
* Every label, colour and description in the UI comes from here so the
|
||||
* screens can't drift apart from each other.
|
||||
*/
|
||||
export type CategoryMeta = {
|
||||
label: string;
|
||||
/** The brief's own one-line definition of the stream. */
|
||||
description: string;
|
||||
/** Solid colour — used for dots, strokes and chart marks. */
|
||||
color: string;
|
||||
/** Tinted surface for badges and soft backgrounds. */
|
||||
soft: string;
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER: Category[] = [
|
||||
'trend',
|
||||
'technology',
|
||||
'policy',
|
||||
'weak_signal'
|
||||
];
|
||||
|
||||
export const CATEGORY_META: Record<Category, CategoryMeta> = {
|
||||
trend: {
|
||||
label: 'روندها',
|
||||
description: 'روندهای مهمی که در نهایت روی آموزش عالی تأثیر میگذارند.',
|
||||
color: 'var(--cat-trend)',
|
||||
soft: 'var(--cat-trend-soft)'
|
||||
},
|
||||
technology: {
|
||||
label: 'فناوریها',
|
||||
description: 'فناوریهایی که دانشگاه را تحت تأثیر قرار میدهند.',
|
||||
color: 'var(--cat-tech)',
|
||||
soft: 'var(--cat-tech-soft)'
|
||||
},
|
||||
policy: {
|
||||
label: 'سیاستها',
|
||||
description:
|
||||
'تغییرات سیاستی و مقرراتی در دانشگاهها و مراکز آموزش عالی دنیا.',
|
||||
color: 'var(--cat-policy)',
|
||||
soft: 'var(--cat-policy-soft)'
|
||||
},
|
||||
weak_signal: {
|
||||
label: 'سیگنالهای آینده',
|
||||
description: 'اتفاقات کوچک اما مهم و نوظهور برای آموزش عالی کشور.',
|
||||
color: 'var(--cat-signal)',
|
||||
soft: 'var(--cat-signal-soft)'
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Horizon } from '../types/futures';
|
||||
|
||||
/**
|
||||
* The single source of truth for the foresight window.
|
||||
*
|
||||
* The programme is scoped to the ten-year run from ۱۴۰۵ to ۱۴۱۵ (the
|
||||
* «دانشگاه اصفهان ۱۴۱۵» vision horizon). Every band label, radar ring and
|
||||
* form option is derived from the constants below so the window can never
|
||||
* drift out of sync between screens again.
|
||||
*/
|
||||
export const HORIZON_START = 1405;
|
||||
export const HORIZON_END = 1415;
|
||||
export const HORIZON_SPAN = HORIZON_END - HORIZON_START;
|
||||
|
||||
/** Band boundaries in Persian calendar years, cumulative across the window. */
|
||||
export const HORIZON_BANDS: Record<Horizon, { from: number; to: number }> = {
|
||||
now: { from: 1405, to: 1407 }, // 0–2 years
|
||||
near: { from: 1407, to: 1410 }, // 2–5 years
|
||||
mid: { from: 1410, to: 1413 }, // 5–8 years
|
||||
long: { from: 1413, to: 1415 } // 8–10 years
|
||||
};
|
||||
|
||||
/** Which year of the ten-year window we are currently in (1-based). */
|
||||
export const HORIZON_CURRENT_YEAR = 1405;
|
||||
export const HORIZON_YEAR_INDEX = HORIZON_CURRENT_YEAR - HORIZON_START + 1;
|
||||
|
||||
const toFa = (n: number): string =>
|
||||
String(n).replace(/[0-9]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]);
|
||||
|
||||
/** e.g. «۱۴۰۵–۱۴۰۷» */
|
||||
export const horizonRangeFa = (h: Horizon): string => {
|
||||
const band = HORIZON_BANDS[h];
|
||||
return `${toFa(band.from)}–${toFa(band.to)}`;
|
||||
};
|
||||
|
||||
/** The full window, e.g. «۱۴۰۵–۱۴۱۵» */
|
||||
export const horizonWindowFa = (): string =>
|
||||
`${toFa(HORIZON_START)}–${toFa(HORIZON_END)}`;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Horizon, Category, ForceType, PersonaType } from '../types/futures';
|
||||
import { Horizon, Category, ForceType } from '../types/futures';
|
||||
import { horizonRangeFa } from './horizon';
|
||||
|
||||
export const toPersianDigits = (num: number | string | null | undefined): string => {
|
||||
if (num === null || num === undefined) return '';
|
||||
|
|
@ -7,19 +8,16 @@ export const toPersianDigits = (num: number | string | null | undefined): string
|
|||
return str.replace(/[0-9]/g, (w) => persianDigits[parseInt(w, 10)]);
|
||||
};
|
||||
|
||||
const HORIZON_NAME_FA: Record<Horizon, string> = {
|
||||
now: 'اکنون',
|
||||
near: 'افق نزدیک',
|
||||
mid: 'میانمدت',
|
||||
long: 'بلندمدت'
|
||||
};
|
||||
|
||||
export const getHorizonLabelFa = (horizon: Horizon): string => {
|
||||
switch (horizon) {
|
||||
case 'now':
|
||||
return 'اکنون (۱۴۰۳-۱۴۰۵)';
|
||||
case 'near':
|
||||
return 'افق نزدیک (۱۴۰۵-۱۴۰۸)';
|
||||
case 'mid':
|
||||
return 'میانمدت (۱۴۰۸-۱۴۱۱)';
|
||||
case 'long':
|
||||
return 'بلندمدت (۱۴۱۱-۱۴۱۵)';
|
||||
default:
|
||||
return horizon;
|
||||
}
|
||||
const name = HORIZON_NAME_FA[horizon];
|
||||
return name ? `${name} (${horizonRangeFa(horizon)})` : horizon;
|
||||
};
|
||||
|
||||
export const getHorizonShortFa = (horizon: Horizon): string => {
|
||||
|
|
@ -79,28 +77,3 @@ export const getForceLabelFa = (type: ForceType): string => {
|
|||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPersonaLabelFa = (p: PersonaType): { title: string; subtitle: string } => {
|
||||
switch (p) {
|
||||
case 'president':
|
||||
return {
|
||||
title: 'ریاست و هیئت رئیسه دانشگاه',
|
||||
subtitle: 'دیدگاه راهبردی کلان و تصمیمگیری سازمانی'
|
||||
};
|
||||
case 'researcher':
|
||||
return {
|
||||
title: 'پژوهشگر آیندهپژوهی و تحلیلگر علم',
|
||||
subtitle: 'کاوش سیگنالهای ضعیف و شواهد تجربی'
|
||||
};
|
||||
case 'planner':
|
||||
return {
|
||||
title: 'برنامهریز راهبردی و تحول آموزشی',
|
||||
subtitle: 'سنجش شکاف قابلیتها و نقشه راه ۱۴۱۵'
|
||||
};
|
||||
case 'admin':
|
||||
return {
|
||||
title: 'مدیر ارشد سامانه هوشمندی',
|
||||
subtitle: 'مدیریت دادهها و کالیبراسیون شاخصها'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,37 +43,177 @@ export const getCategoryQuadrant = (category: Category): { startAngle: number; e
|
|||
}
|
||||
};
|
||||
|
||||
export const calculateItemRadarCoords = (
|
||||
id: string,
|
||||
category: Category,
|
||||
horizon: Horizon,
|
||||
indexInGroup: number,
|
||||
totalInGroup: number,
|
||||
center: number,
|
||||
maxRadius: number
|
||||
): RadarPoint => {
|
||||
const baseRatio = getHorizonRadiusRatio(horizon);
|
||||
// deterministic pseudo-random offset based on string id
|
||||
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
const radiusJitter = ((hash % 17) - 8) * 0.009;
|
||||
const radius = maxRadius * Math.max(0.15, Math.min(0.98, baseRatio + radiusJitter));
|
||||
|
||||
const { startAngle, endAngle } = getCategoryQuadrant(category);
|
||||
const span = endAngle - startAngle;
|
||||
|
||||
// Distribute items evenly in angular span with small deterministic spread
|
||||
const step = totalInGroup > 1 ? span / (totalInGroup + 1) : span / 2;
|
||||
const rawAngle = startAngle + step * (indexInGroup + 1);
|
||||
const angleJitter = (((hash * 7) % 19) - 9) * 0.012;
|
||||
const angle = rawAngle + angleJitter;
|
||||
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
|
||||
return { x, y, r: radius, angle, radius };
|
||||
/**
|
||||
* Radial band each horizon occupies, as a fraction of the plot radius.
|
||||
* Items are packed *inside* their band rather than pinned to a single ring,
|
||||
* which is what made same-horizon dots stack on top of each other.
|
||||
*/
|
||||
export const getHorizonBand = (horizon: Horizon): { inner: number; outer: number } => {
|
||||
switch (horizon) {
|
||||
case 'now':
|
||||
return { inner: 0.17, outer: 0.36 };
|
||||
case 'near':
|
||||
return { inner: 0.4, outer: 0.58 };
|
||||
case 'mid':
|
||||
return { inner: 0.62, outer: 0.79 };
|
||||
case 'long':
|
||||
return { inner: 0.83, outer: 0.98 };
|
||||
default:
|
||||
return { inner: 0.4, outer: 0.6 };
|
||||
}
|
||||
};
|
||||
|
||||
// Math for 10-axis Spider Chart
|
||||
export interface RadarPlacement<T> {
|
||||
item: T;
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay every item out so no two dots overlap.
|
||||
*
|
||||
* Items are bucketed by (category, horizon). Each bucket owns one quadrant
|
||||
* and one radial band; within it, dots are packed into as many concentric
|
||||
* rows as the arc length needs, then spread evenly along each row.
|
||||
*/
|
||||
export function layoutRadarItems<T>(
|
||||
items: T[],
|
||||
getCategory: (item: T) => Category,
|
||||
getHorizon: (item: T) => Horizon,
|
||||
getDotRadius: (item: T) => number,
|
||||
center: number,
|
||||
maxRadius: number
|
||||
): RadarPlacement<T>[] {
|
||||
const PADDING = 3;
|
||||
|
||||
type Node = {
|
||||
item: T;
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
minAngle: number;
|
||||
maxAngle: number;
|
||||
minRadius: number;
|
||||
maxRadius: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const key = `${getCategory(item)}|${getHorizon(item)}`;
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(item);
|
||||
else buckets.set(key, [item]);
|
||||
}
|
||||
|
||||
const nodes: Node[] = [];
|
||||
|
||||
// --- 1. Seed: spread each bucket over its quadrant and radial band -------
|
||||
for (const [key, bucket] of buckets) {
|
||||
const [category, horizon] = key.split('|') as [Category, Horizon];
|
||||
const { startAngle, endAngle } = getCategoryQuadrant(category);
|
||||
const band = getHorizonBand(horizon);
|
||||
|
||||
const innerR = maxRadius * band.inner;
|
||||
const outerR = maxRadius * band.outer;
|
||||
const span = endAngle - startAngle;
|
||||
|
||||
const maxDot = Math.max(...bucket.map(getDotRadius));
|
||||
const gap = maxDot * 2 + PADDING;
|
||||
|
||||
// Rows are limited by radial depth as well as by arc length.
|
||||
const byArc = Math.max(1, Math.floor((span * innerR) / gap));
|
||||
const byDepth = Math.max(1, Math.floor((outerR - innerR) / gap) + 1);
|
||||
const rows = Math.min(byDepth, Math.max(1, Math.ceil(bucket.length / byArc)));
|
||||
const perRow = Math.ceil(bucket.length / rows);
|
||||
const rowStep = rows > 1 ? (outerR - innerR) / (rows - 1) : 0;
|
||||
|
||||
bucket.forEach((item, i) => {
|
||||
const row = Math.floor(i / perRow);
|
||||
const indexInRow = i % perRow;
|
||||
const inThisRow = Math.min(perRow, bucket.length - row * perRow);
|
||||
|
||||
const radius = rows > 1 ? innerR + row * rowStep : (innerR + outerR) / 2;
|
||||
const step = span / (inThisRow + 1);
|
||||
const angle = startAngle + step * (indexInRow + 1);
|
||||
|
||||
const r = getDotRadius(item);
|
||||
nodes.push({
|
||||
item,
|
||||
r,
|
||||
x: center + radius * Math.cos(angle),
|
||||
y: center + radius * Math.sin(angle),
|
||||
minAngle: startAngle,
|
||||
maxAngle: endAngle,
|
||||
minRadius: innerR,
|
||||
maxRadius: outerR
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. Relax: push overlapping dots apart, then pull them back into
|
||||
// their own quadrant and band. Deterministic, so renders are stable.
|
||||
const clamp = (n: Node) => {
|
||||
const dx = n.x - center;
|
||||
const dy = n.y - center;
|
||||
let angle = Math.atan2(dy, dx);
|
||||
let radius = Math.hypot(dx, dy);
|
||||
|
||||
// atan2 returns (-PI, PI]; quadrant ranges can start below -PI/2, so
|
||||
// compare in the same revolution as the range.
|
||||
if (angle < n.minAngle - Math.PI) angle += 2 * Math.PI;
|
||||
if (angle > n.maxAngle + Math.PI) angle -= 2 * Math.PI;
|
||||
|
||||
angle = Math.min(n.maxAngle, Math.max(n.minAngle, angle));
|
||||
radius = Math.min(n.maxRadius, Math.max(n.minRadius, radius));
|
||||
|
||||
n.x = center + radius * Math.cos(angle);
|
||||
n.y = center + radius * Math.sin(angle);
|
||||
};
|
||||
|
||||
for (let pass = 0; pass < 160; pass++) {
|
||||
let moved = false;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const needed = a.r + b.r + PADDING;
|
||||
const dist = Math.hypot(dx, dy) || 0.001;
|
||||
|
||||
if (dist >= needed) continue;
|
||||
|
||||
const push = (needed - dist) / 2;
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
|
||||
a.x -= ux * push;
|
||||
a.y -= uy * push;
|
||||
b.x += ux * push;
|
||||
b.y += uy * push;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!moved) break;
|
||||
for (const n of nodes) clamp(n);
|
||||
}
|
||||
|
||||
return nodes.map((n) => {
|
||||
const angle = Math.atan2(n.y - center, n.x - center);
|
||||
return {
|
||||
item: n.item,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
angle,
|
||||
radius: Math.hypot(n.x - center, n.y - center)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const getSpiderCoordinates = (
|
||||
values: number[], // 10 scores from 1 to 10
|
||||
maxValue: number, // 10
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path alias — shadcn components import from "@/..." */
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue