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)
|
```bash
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
npm install
|
||||||
|
npm run dev
|
||||||
## 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 }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 | Tailwind CSS v4 (`@tailwindcss/vite`) |
|
||||||
|
| کتابخانه اجزا | shadcn/ui روی Radix |
|
||||||
```css
|
| جهت | RTL (`dir="rtl"` روی `<html>`) |
|
||||||
*, ::before, ::after {
|
| قلم | Vazirmatn (وزنهای ۴۰۰، ۵۰۰، ۶۰۰، ۷۰۰) |
|
||||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif !important;
|
| حالت نمایش | روشن و تیره، با کلاس `.dark` روی `<html>` |
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ۱.۲. مقیاس و سلسلهمراتب تیترها (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) | متناسب با دسته | برچسبهای وضعیت و شمارندهها |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ۲. پالت رنگ و کنتراست (Color Palette)
|
## ۲. رنگ
|
||||||
|
|
||||||
### ۲.۱. بوم و پسزمینهها (Canvas & Surfaces)
|
همه رنگها متغیر CSS در فضای رنگی `oklch` هستند و در `:root` (روشن) و
|
||||||
- **بوم اصلی (Canvas Background)**: `#f4f6fb` (خاکستری سرد و ملایم با مش گرادینت سبک)
|
`.dark` (تیره) تعریف میشوند. هیچ رنگ ثابتی نباید مستقیماً در کامپوننتها
|
||||||
- **کارتها (Card Surface)**: `#ffffff` (سفید یکدست با سایه نرم `0 8px 24px -4px rgba(15,23,42,0.04)`)
|
نوشته شود.
|
||||||
- **کارتهای فرعی (Subtle Surface)**: `#f8fafc`
|
|
||||||
- **خطوط حاشیه (Borders)**: `#e2e8f0` (خطوط تفکیککننده محکم اما چشمنواز)
|
|
||||||
|
|
||||||
### ۲.۲. رنگهای موضوعی پاستلی (Pastel Theme Tints)
|
### ۲.۱. نقشها
|
||||||
برای طبقهبندی هوشمندی آینده و ایجاد زیبایی در ساختار جعبههای بنتو:
|
|
||||||
- **روندها (Trends)**: پسزمینه `#eaf3fe` | حاشیه `#c8e0fd` | متن `#0284c7`
|
`--background` · `--foreground` · `--card` · `--popover` · `--muted` ·
|
||||||
- **فناوریها (Tech)**: پسزمینه `#e6f6f1` | حاشیه `#c4ecdf` | متن `#059669`
|
`--accent` · `--primary` · `--secondary` · `--destructive` · `--border` ·
|
||||||
- **سیاستها (Policies)**: پسزمینه `#fef7e6` | حاشیه `#fde8b8` | متن `#d97706`
|
`--input` · `--ring` و مجموعه `--sidebar-*`.
|
||||||
- **سیگنالهای آینده (Signals)**: پسزمینه `#fdecf2` | حاشیه `#f9cbd9` | متن `#e11d48`
|
|
||||||
- **برنامهریزی ۱۴۱۵ / مثلث آینده**: پسزمینه `#f3effe` | حاشیه `#ded2fd` | متن `#7c3aed`
|
### ۲.۲. رنگهای معنایی
|
||||||
|
|
||||||
|
رنگ فقط برای **معنا** به کار میرود، نه تزئین. چهار جریان رصد، هر کدام یک
|
||||||
|
رنگ ثابت دارند که در `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`، آیکون روشن، سایه ملایم.
|
| ----------------- | ------------- | --- |
|
||||||
- **شمارندهها و برچسبها**:
|
| عنوان صفحه | `1.375rem` | ۷۰۰ |
|
||||||
- نشانهای عددی باید در سمت چپ آیتم قرار گیرند (`align-self: flex-start` در ساختار راستبهچپ).
|
| عنوان کارت | `0.9375rem` | ۷۰۰ |
|
||||||
- متن شمارنده کاملاً با قلم وزیرمتن فارسی و بدون فونت مونو یا فاصله بیمورد درج شود.
|
| متن بدنه | `0.8125rem` | ۴۰۰ |
|
||||||
- **حذف متون دوزبانه زاید**:
|
| برچسب و فراداده | `0.75rem` | ۵۰۰ |
|
||||||
- تمامی پرانتزهای انگلیسی غیرضروری مانند `(Overview)`، `(Trends)`، `(Tech)`، `(Policies)`، `(Signals)`، `(Spider Gap)`، `(PULL/PUSH)` و `(CRUD)` در نوار کناری **ممنوع** بوده و با واژگان حرفهای و فاخر فارسی جایگزین میشوند.
|
| ریزنویس | `0.6875rem` | ۶۰۰ |
|
||||||
|
|
||||||
|
- ارتفاع خط بدنه `1.75` است؛ فارسی به فضای بیشتری بین سطرها نیاز دارد.
|
||||||
|
- ارقام با کلاس `.tnum` نمایش داده میشوند تا در ستونها همتراز بمانند.
|
||||||
|
- اعداد همیشه فارسیاند (`toPersianDigits`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ۴. استانداردهای هندسی نمودارها و مدلهای بصری
|
## ۴. فاصله، گردی و ارتفاع
|
||||||
|
|
||||||
### ۴.۱. مثلث هوشمندی آینده (Futures Triangle Geometry)
|
- گردی از `--radius` (`0.7rem`) مشتق میشود: `radius-sm` تا `radius-2xl`.
|
||||||
- **تناسب ابعادی استاندارد**:
|
- سایه فقط برای جدا کردن لایههای شناور (کارت شناور، منو، دیالوگ). کارتهای
|
||||||
- از ترسیم مثلثهای عریض، پخ و تخت (نسبت عرض به ارتفاع ۲ به ۱) اکیداً پرهیز شود.
|
درون صفحه با خط مویین `border` از هم جدا میشوند، نه با سایه.
|
||||||
- نسبت ارتفاع به قاعده مثلث باید بین **۰.۸۵ تا ۰.۹۰** باشد (شبیه به مثلث متساویالاضلاع کشیده و متوازن).
|
- فاصلهها از مقیاس Tailwind؛ فاصله استاندارد میان کارتها `gap-3` یا `gap-4`.
|
||||||
- اضلاع مثلث باید با خطوط رنگی ضخیم (حداقل ۴ پیکسل) و گرادینتهای جهتدار متصل شوند.
|
|
||||||
- گره مرکزی (هوشمندی دانشگاه اصفهان) دقیقاً در گرانیگاه هندسی $(x_c, y_c)$ قرار گیرد.
|
|
||||||
|
|
||||||
### ۴.۲. رادار دایرهای آیندهپژوهی (Futures Radar)
|
|
||||||
- دایره مرکزی سفید مرواریدی، پرههای رنگی ملایم برای ۴ ربع، نقاط سیگنال با حاشیه سفید تمیز و عدد شدت اثر درون نقطه.
|
|
||||||
|
|
||||||
### ۴.۳. نمودار عنکبوتی ۱۰ بعدی (Spider Gap Chart)
|
|
||||||
- چندضلعی هدف دانشگاه اصفهان ۱۴۱۵ به رنگ سبز زمردی (`#00b894`) و چندضلعی وضع موجود به رنگ کهربایی (`#f59e0b`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ۵. راهنمای زبان و لحن سازمانی (Content & Tone Standards)
|
## ۵. حرکت
|
||||||
|
|
||||||
1. **حذف واژگان تکراری و عبارات توخالی (Zero AI-Slop)**:
|
- انتقالها کوتاه (۱۵۰ تا ۲۰۰ میلیثانیه) و فقط روی ویژگیهای مشخص.
|
||||||
- حذف برچسبهای آکادمیک گلدرشت مانند `نظریه سوهیل عنایتالله (Sohail Inayatullah)` از هدر صفحات و استفاده از برچسبهای راهبردی خلاصه (`چارچوب آیندهپژوهی`).
|
- **`transition-all` استفاده نشود**؛ چون رنگها متغیر CSS هستند، تغییر حالت
|
||||||
- حذف توضیحات شعاری و کشدار؛ محتوا باید مستقیماً پیامد دانشگاهی، عدد شاخص یا اقدام فوری را گزارش دهد.
|
روشن/تیره باعث انیمیشن ناخواسته رنگ میشود.
|
||||||
2. **نگارش فارسی معیار و روان**:
|
- هنگام تعویض حالت نمایش، صفت `data-theme-switching` روی `<html>` قرار
|
||||||
- به کارگیری اصطلاحات دقیق آیندهپژوهی و مدیریت دانشگاهی (مانند: *سیگنالهای ضعیف، افق زمانی، شکاف بلوغ، شایستگیهای نوظهور، آمایش آموزشی*).
|
میگیرد و همه انتقالها را موقتاً غیرفعال میکند.
|
||||||
|
- `prefers-reduced-motion` رعایت میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. نگارش متن
|
||||||
|
|
||||||
|
- متن رابط کاربری **توصیفی** است، نه تبلیغاتی. برچسب کوتاه و دقیق.
|
||||||
|
- عنوانها بدون پسوند انگلیسی داخل پرانتز.
|
||||||
|
- هیچ عدد، تاریخ یا نتیجهگیریای در متن ثابت کدنویسی نمیشود؛ همه از داده
|
||||||
|
محاسبه میشوند.
|
||||||
|
- افق زمانی همیشه از `src/utils/horizon.ts` خوانده میشود
|
||||||
|
(۱۴۰۵ تا ۱۴۱۵)، نه بهصورت دستی.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. دسترسپذیری
|
||||||
|
|
||||||
|
- تمرکز صفحهکلید با حلقه `--ring` دیده میشود.
|
||||||
|
- هر کنترل بدون متن، `aria-label` دارد.
|
||||||
|
- ناوبری روی موبایل به کشوی کناری (Sheet) تبدیل میشود و با صفحهکلید
|
||||||
|
قابل استفاده است.
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@
|
||||||
<meta charset="UTF-8" />
|
<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>" />
|
<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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="رادار آینده دانشگاه اصفهان — سامانه هوشمندی راهبردی، دیدهبانی سیگنالها، روندها، فناوریها و تحلیل شکاف تا افق ۱۴۱۵" />
|
<meta name="description" content="رصد روندها، فناوریها، سیاستها و سیگنالهای آینده آموزش عالی، و سنجش فاصله دانشگاه اصفهان تا افق ۱۴۰۵ تا ۱۴۱۵." />
|
||||||
<title>رادار آینده دانشگاه اصفهان | University Futures Radar</title>
|
<title>رادار آینده دانشگاه اصفهان | University Futures Radar</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
</head>
|
||||||
<body class="bg-navy">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -10,16 +10,24 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.34.0",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.2.8",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"oxlint": "^1.79.0",
|
"oxlint": "^1.79.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
312
src/App.tsx
312
src/App.tsx
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
IntelligenceItem,
|
IntelligenceItem,
|
||||||
University1415Dimension,
|
University1415Dimension,
|
||||||
|
|
@ -11,15 +11,14 @@ import {
|
||||||
import { dataService } from './services/dataService';
|
import { dataService } from './services/dataService';
|
||||||
import { authService } from './services/authService';
|
import { authService } from './services/authService';
|
||||||
|
|
||||||
// Layout
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import { Header } from './components/layout/Header';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { Sidebar } from './components/layout/Sidebar';
|
import { AppSidebar } from './components/layout/AppSidebar';
|
||||||
|
import { AppHeader } from './components/layout/AppHeader';
|
||||||
|
|
||||||
// Modals
|
|
||||||
import { GlobalSearchModal } from './components/modals/GlobalSearchModal';
|
import { GlobalSearchModal } from './components/modals/GlobalSearchModal';
|
||||||
import { EntityEditorModal } from './components/modals/EntityEditorModal';
|
import { EntityEditorModal } from './components/modals/EntityEditorModal';
|
||||||
|
|
||||||
// Pages
|
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { RadarPage } from './pages/RadarPage';
|
import { RadarPage } from './pages/RadarPage';
|
||||||
|
|
@ -30,27 +29,35 @@ import { GapAnalysisPage } from './pages/GapAnalysisPage';
|
||||||
import { FuturesTrianglePage } from './pages/FuturesTrianglePage';
|
import { FuturesTrianglePage } from './pages/FuturesTrianglePage';
|
||||||
import { StrategicNetworkPage } from './pages/StrategicNetworkPage';
|
import { StrategicNetworkPage } from './pages/StrategicNetworkPage';
|
||||||
import { ReportsPage } from './pages/ReportsPage';
|
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() {
|
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 [currentRoute, setCurrentRoute] = useState<string>('/');
|
||||||
const [selectedItem, setSelectedItem] = useState<IntelligenceItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<IntelligenceItem | null>(null);
|
||||||
const [radarCategoryFilter, setRadarCategoryFilter] = useState<Category | 'all'>('all');
|
const [radarCategoryFilter, setRadarCategoryFilter] = useState<Category | 'all'>('all');
|
||||||
|
|
||||||
// Modals
|
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
// Live Data State
|
|
||||||
const [items, setItems] = useState<IntelligenceItem[]>([]);
|
const [items, setItems] = useState<IntelligenceItem[]>([]);
|
||||||
const [dimensions, setDimensions] = useState<University1415Dimension[]>([]);
|
const [dimensions, setDimensions] = useState<University1415Dimension[]>([]);
|
||||||
const [jobs, setJobs] = useState<JobEntity[]>([]);
|
const [jobs, setJobs] = useState<JobEntity[]>([]);
|
||||||
const [skills, setSkills] = useState<SkillEntity[]>([]);
|
const [skills, setSkills] = useState<SkillEntity[]>([]);
|
||||||
const [forces, setForces] = useState<FuturesForce[]>([]);
|
const [forces, setForces] = useState<FuturesForce[]>([]);
|
||||||
|
|
||||||
// Refresh all state from dataService
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
setItems(dataService.getAllItems());
|
setItems(dataService.getAllItems());
|
||||||
setDimensions(dataService.getDimensions());
|
setDimensions(dataService.getDimensions());
|
||||||
|
|
@ -63,19 +70,16 @@ export function App() {
|
||||||
refreshData();
|
refreshData();
|
||||||
const unsub = authService.subscribe((sess) => {
|
const unsub = authService.subscribe((sess) => {
|
||||||
setCurrentSession(sess);
|
setCurrentSession(sess);
|
||||||
if (!sess) {
|
if (!sess) setCurrentRoute('/login');
|
||||||
setCurrentRoute('/login');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return () => unsub();
|
return () => unsub();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Global Ctrl+K listener
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSearchOpen(true);
|
setIsSearchOpen((open) => !open);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
@ -85,10 +89,8 @@ export function App() {
|
||||||
const handleNavigate = (route: string) => {
|
const handleNavigate = (route: string) => {
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
|
|
||||||
// Handle query params like /radar?category=trend
|
|
||||||
if (route.startsWith('/radar?category=')) {
|
if (route.startsWith('/radar?category=')) {
|
||||||
const cat = route.split('=')[1] as Category;
|
setRadarCategoryFilter(route.split('=')[1] as Category);
|
||||||
setRadarCategoryFilter(cat);
|
|
||||||
setCurrentRoute('/radar');
|
setCurrentRoute('/radar');
|
||||||
} else {
|
} else {
|
||||||
setRadarCategoryFilter('all');
|
setRadarCategoryFilter('all');
|
||||||
|
|
@ -102,172 +104,158 @@ export function App() {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectDimension = (dim: University1415Dimension) => {
|
const handleSelectDimension = () => {
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setCurrentRoute('/university-1415');
|
setCurrentRoute('/university-1415');
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveNewItem = (newItem: IntelligenceItem) => {
|
const counts = useMemo(
|
||||||
dataService.createItem(newItem);
|
() => ({
|
||||||
refreshData();
|
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 on login route or not authenticated
|
|
||||||
if (!currentSession || currentRoute === '/login') {
|
if (!currentSession || currentRoute === '/login') {
|
||||||
return (
|
return <Login onLoginSuccess={() => setCurrentRoute('/')} />;
|
||||||
<Login
|
|
||||||
onLoginSuccess={() => {
|
|
||||||
setCurrentRoute('/');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate section counts
|
const headerTitle = selectedItem
|
||||||
const counts = {
|
? selectedItem.title
|
||||||
signals: items.filter((i) => i.category === 'weak_signal').length,
|
: ROUTE_TITLES[currentRoute] ?? 'رادار آینده';
|
||||||
trends: items.filter((i) => i.category === 'trend').length,
|
|
||||||
tech: items.filter((i) => i.category === 'technology').length,
|
|
||||||
policies: items.filter((i) => i.category === 'policy').length
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<TooltipProvider delayDuration={300}>
|
||||||
{/* Institutional Sidebar */}
|
<SidebarProvider>
|
||||||
<Sidebar
|
<AppSidebar
|
||||||
currentRoute={selectedItem ? '' : currentRoute}
|
currentRoute={selectedItem ? '' : currentRoute}
|
||||||
onNavigate={handleNavigate}
|
|
||||||
counts={counts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<div className="main-content">
|
|
||||||
<Header
|
|
||||||
currentSession={currentSession}
|
|
||||||
onOpenSearch={() => setIsSearchOpen(true)}
|
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
counts={counts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="page-wrapper">
|
<SidebarInset className="min-w-0">
|
||||||
{/* Detail Page View (Highest Priority) */}
|
<AppHeader
|
||||||
{selectedItem ? (
|
currentSession={currentSession}
|
||||||
<ItemDetailPage
|
title={headerTitle}
|
||||||
item={selectedItem}
|
items={items}
|
||||||
allItems={items}
|
onOpenSearch={() => setIsSearchOpen(true)}
|
||||||
dimensions={dimensions}
|
onSelectItem={handleSelectItem}
|
||||||
jobs={jobs}
|
onNavigate={handleNavigate}
|
||||||
skills={skills}
|
/>
|
||||||
onBack={() => setSelectedItem(null)}
|
|
||||||
onSelectItem={handleSelectItem}
|
|
||||||
onSelectDimension={handleSelectDimension}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{currentRoute === '/' && (
|
|
||||||
<Dashboard
|
|
||||||
currentSession={currentSession}
|
|
||||||
items={items}
|
|
||||||
dimensions={dimensions}
|
|
||||||
onSelectItem={handleSelectItem}
|
|
||||||
onSelectDimension={handleSelectDimension}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentRoute === '/radar' && (
|
<main className="page-shell">
|
||||||
<RadarPage
|
{selectedItem ? (
|
||||||
items={items}
|
<ItemDetailPage
|
||||||
onSelectItem={handleSelectItem}
|
item={selectedItem}
|
||||||
onOpenCreateModal={() => setIsCreateModalOpen(true)}
|
allItems={items}
|
||||||
initialCategory={radarCategoryFilter}
|
dimensions={dimensions}
|
||||||
/>
|
jobs={jobs}
|
||||||
)}
|
skills={skills}
|
||||||
|
onBack={() => setSelectedItem(null)}
|
||||||
|
onSelectItem={handleSelectItem}
|
||||||
|
onSelectDimension={handleSelectDimension}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{currentRoute === '/' && (
|
||||||
|
<Dashboard
|
||||||
|
currentSession={currentSession}
|
||||||
|
items={items}
|
||||||
|
dimensions={dimensions}
|
||||||
|
onSelectItem={handleSelectItem}
|
||||||
|
onSelectDimension={handleSelectDimension}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentRoute === '/labor-market' && (
|
{currentRoute === '/radar' && (
|
||||||
<LaborMarketPage jobs={jobs} skills={skills} />
|
<RadarPage
|
||||||
)}
|
items={items}
|
||||||
|
onSelectItem={handleSelectItem}
|
||||||
|
onOpenCreateModal={() => setIsCreateModalOpen(true)}
|
||||||
|
initialCategory={radarCategoryFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentRoute === '/university-1415' && (
|
{currentRoute === '/labor-market' && (
|
||||||
<University1415Page
|
<LaborMarketPage jobs={jobs} skills={skills} />
|
||||||
dimensions={dimensions}
|
)}
|
||||||
items={items}
|
|
||||||
onSelectItem={handleSelectItem}
|
|
||||||
onRefreshDimensions={refreshData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentRoute === '/gap-analysis' && (
|
{currentRoute === '/university-1415' && (
|
||||||
<GapAnalysisPage
|
<University1415Page
|
||||||
dimensions={dimensions}
|
dimensions={dimensions}
|
||||||
onSelectDimension={handleSelectDimension}
|
items={items}
|
||||||
/>
|
onSelectItem={handleSelectItem}
|
||||||
)}
|
onRefreshDimensions={refreshData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentRoute === '/futures-triangle' && (
|
{currentRoute === '/gap-analysis' && (
|
||||||
<FuturesTrianglePage forces={forces} />
|
<GapAnalysisPage
|
||||||
)}
|
dimensions={dimensions}
|
||||||
|
onSelectDimension={handleSelectDimension}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentRoute === '/network' && (
|
{currentRoute === '/futures-triangle' && (
|
||||||
<StrategicNetworkPage
|
<FuturesTrianglePage forces={forces} />
|
||||||
items={items}
|
)}
|
||||||
dimensions={dimensions}
|
|
||||||
jobs={jobs}
|
|
||||||
skills={skills}
|
|
||||||
onSelectItem={handleSelectItem}
|
|
||||||
onSelectDimension={handleSelectDimension}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentRoute === '/reports' && (
|
{currentRoute === '/network' && (
|
||||||
<ReportsPage
|
<StrategicNetworkPage
|
||||||
items={items}
|
items={items}
|
||||||
dimensions={dimensions}
|
dimensions={dimensions}
|
||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
/>
|
skills={skills}
|
||||||
)}
|
onSelectItem={handleSelectItem}
|
||||||
|
onSelectDimension={handleSelectDimension}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentRoute === '/admin' && (
|
{currentRoute === '/reports' && (
|
||||||
<AdminPage
|
<ReportsPage items={items} dimensions={dimensions} jobs={jobs} />
|
||||||
items={items}
|
)}
|
||||||
dimensions={dimensions}
|
</>
|
||||||
jobs={jobs}
|
)}
|
||||||
skills={skills}
|
</main>
|
||||||
onRefreshData={refreshData}
|
</SidebarInset>
|
||||||
onSelectItem={handleSelectItem}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentRoute === '/settings' && (
|
<GlobalSearchModal
|
||||||
<SettingsPage onRefreshData={refreshData} />
|
isOpen={isSearchOpen}
|
||||||
)}
|
onClose={() => setIsSearchOpen(false)}
|
||||||
</>
|
items={items}
|
||||||
)}
|
dimensions={dimensions}
|
||||||
</main>
|
jobs={jobs}
|
||||||
</div>
|
skills={skills}
|
||||||
|
onSelectItem={(item) => {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
handleSelectItem(item);
|
||||||
|
}}
|
||||||
|
onSelectDimension={() => {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
handleSelectDimension();
|
||||||
|
}}
|
||||||
|
onSelectJob={() => {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSelectedItem(null);
|
||||||
|
setCurrentRoute('/labor-market');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Global Modals */}
|
<EntityEditorModal
|
||||||
<GlobalSearchModal
|
isOpen={isCreateModalOpen}
|
||||||
isOpen={isSearchOpen}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
onClose={() => setIsSearchOpen(false)}
|
onSave={(newItem) => {
|
||||||
items={items}
|
dataService.createItem(newItem);
|
||||||
dimensions={dimensions}
|
refreshData();
|
||||||
jobs={jobs}
|
}}
|
||||||
skills={skills}
|
/>
|
||||||
onSelectItem={handleSelectItem}
|
</SidebarProvider>
|
||||||
onSelectDimension={handleSelectDimension}
|
</TooltipProvider>
|
||||||
onSelectJob={() => {
|
|
||||||
setSelectedItem(null);
|
|
||||||
setCurrentRoute('/labor-market');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EntityEditorModal
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSave={handleSaveNewItem}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 React from 'react';
|
||||||
import { University1415Dimension } from '../../types/futures';
|
import { University1415Dimension } from '../../types/futures';
|
||||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||||
import { Target, ArrowLeft, Lightbulb, ArrowUpRight } from 'lucide-react';
|
import { Card } from '@/components/ui/card';
|
||||||
|
|
||||||
interface Dimension1415CardProps {
|
interface Dimension1415CardProps {
|
||||||
dimension: University1415Dimension;
|
dimension: University1415Dimension;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dimension1415Card: React.FC<Dimension1415CardProps> = ({ dimension, onClick }) => {
|
export const Dimension1415Card: React.FC<Dimension1415CardProps> = ({
|
||||||
return (
|
dimension,
|
||||||
<div
|
onClick
|
||||||
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"
|
<Card
|
||||||
style={{ borderTop: `4px solid ${dimension.pillarColor}` }}
|
data-card
|
||||||
>
|
role="button"
|
||||||
<div>
|
tabIndex={0}
|
||||||
{/* Header Badges */}
|
onClick={onClick}
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
onKeyDown={(e) => {
|
||||||
<span className="text-[10.5px] text-slate-400 font-bold">
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
{dimension.titleEn}
|
e.preventDefault();
|
||||||
</span>
|
onClick();
|
||||||
<span
|
}
|
||||||
className="text-[11px] font-bold px-2.5 py-0.5 rounded-full"
|
}}
|
||||||
style={{
|
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"
|
||||||
backgroundColor: `${dimension.pillarColor}15`,
|
>
|
||||||
color: dimension.pillarColor
|
{/* Colour is the dimension's own identity, kept to a hairline. */}
|
||||||
}}
|
<span
|
||||||
>
|
aria-hidden
|
||||||
آمادگی: {toPersianDigits(dimension.readinessPercentage)}٪
|
className="block h-0.5 w-full"
|
||||||
</span>
|
style={{ background: dimension.pillarColor }}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Title */}
|
<div className="p-4">
|
||||||
<h4 className="text-slate-900 font-extrabold text-base mb-1.5 flex items-center justify-between">
|
<div className="mb-1.5 flex items-start justify-between gap-2">
|
||||||
<span>{dimension.title}</span>
|
<h3 className="text-[13.5px] font-bold leading-snug transition-colors group-hover:text-primary">
|
||||||
<div className="circle-action-btn text-slate-700 w-7 h-7 text-xs">
|
{dimension.title}
|
||||||
<ArrowUpRight size={14} />
|
</h3>
|
||||||
</div>
|
<span className="tnum shrink-0 text-[11px] font-semibold text-muted-foreground">
|
||||||
</h4>
|
{toPersianDigits(dimension.readinessPercentage)}٪
|
||||||
|
</span>
|
||||||
{/* Definition */}
|
|
||||||
<p className="text-slate-600 text-xs leading-relaxed line-clamp-2">
|
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
{/* Scores & Gap Meter */}
|
<p className="text-[10.5px] text-muted-foreground">{dimension.titleEn}</p>
|
||||||
<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">
|
<p className="mt-2 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||||
<span className="text-amber-600">
|
{dimension.definition}
|
||||||
موجود: {toPersianDigits(dimension.currentScore)}
|
</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
<span className="text-teal-600">
|
<span className="text-muted-foreground">
|
||||||
هدف: {toPersianDigits(dimension.targetScore)}
|
هدف <b className="font-semibold text-foreground">
|
||||||
|
{toPersianDigits(dimension.targetScore)}
|
||||||
|
</b>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-rose-600 bg-rose-50 px-2 py-0.5 rounded-full text-[11px]">
|
<span className="font-semibold text-negative">
|
||||||
شکاف: {toPersianDigits(dimension.gap)}
|
شکاف {toPersianDigits(dimension.gap)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Readiness Bar */}
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden flex">
|
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-500"
|
className="h-full rounded-full transition-[width] duration-500"
|
||||||
style={{
|
style={{
|
||||||
width: `${dimension.readinessPercentage}%`,
|
width: `${dimension.readinessPercentage}%`,
|
||||||
backgroundColor: dimension.pillarColor
|
background: dimension.pillarColor
|
||||||
}}
|
}}
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</Card>
|
||||||
};
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IntelligenceItem } from '../../types/futures';
|
import { IntelligenceItem } from '../../types/futures';
|
||||||
import {
|
import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
|
||||||
toPersianDigits,
|
import { CATEGORY_META } from '../../utils/categories';
|
||||||
getCategoryLabelFa,
|
import { Card } from '@/components/ui/card';
|
||||||
getCategoryColor,
|
import { cn } from '@/lib/utils';
|
||||||
getHorizonShortFa
|
|
||||||
} from '../../utils/persianNumbers';
|
|
||||||
import { Clock, Zap, ArrowUpRight } from 'lucide-react';
|
|
||||||
|
|
||||||
interface IntelligenceCardProps {
|
interface IntelligenceCardProps {
|
||||||
item: IntelligenceItem;
|
item: IntelligenceItem;
|
||||||
|
|
@ -14,59 +11,63 @@ interface IntelligenceCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IntelligenceCard: React.FC<IntelligenceCardProps> = ({ item, onClick }) => {
|
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;
|
const isCritical = item.status === 'critical' || item.impactScore === 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
|
data-card
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={onClick}
|
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 ${
|
onKeyDown={(e) => {
|
||||||
isCritical ? 'border-r-4 border-r-rose-500' : ''
|
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'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{/* Top Badges */}
|
<span
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
className="inline-flex items-center gap-1.5 text-[11px] font-semibold"
|
||||||
<span
|
style={{ color: meta.color }}
|
||||||
className="text-[10px] font-bold px-2.5 py-0.5 rounded-full"
|
>
|
||||||
style={{
|
<span className="cat-dot" style={{ background: meta.color }} />
|
||||||
backgroundColor: `${catColor}15`,
|
{meta.label}
|
||||||
color: catColor
|
</span>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getCategoryLabelFa(item.category)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-400 font-medium">
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
<Clock size={12} />
|
{getHorizonShortFa(item.horizon)}
|
||||||
<span>{getHorizonShortFa(item.horizon)}</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h4 className="text-slate-900 font-extrabold text-sm leading-snug line-clamp-2 hover:text-teal-600 transition-colors">
|
|
||||||
{item.title}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{/* Executive Summary */}
|
|
||||||
<p className="text-slate-600 text-xs leading-relaxed line-clamp-3 mt-2">
|
|
||||||
{item.executiveSummary}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Indicators */}
|
<h3 className="line-clamp-2 text-[13.5px] font-bold leading-relaxed text-foreground transition-colors group-hover:text-primary">
|
||||||
<div className="pt-3 border-t border-slate-100 flex items-center justify-between text-xs font-bold">
|
{item.title}
|
||||||
<div className="flex items-center gap-2">
|
</h3>
|
||||||
<span className="text-teal-700 flex items-center gap-1">
|
|
||||||
<Zap size={13} className="text-teal-500" />
|
|
||||||
اثر: {toPersianDigits(item.impactScore)}/۵
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="circle-action-btn w-7 h-7 text-slate-600">
|
<p className="mt-1.5 line-clamp-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||||
<ArrowUpRight size={14} />
|
{item.executiveSummary}
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{isCritical && (
|
||||||
|
<span className="rounded px-1.5 py-0.5 text-[10.5px] font-semibold text-negative bg-negative-soft">
|
||||||
|
بحرانی
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,118 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { JobEntity, SkillEntity } from '../../types/futures';
|
import { JobEntity, SkillEntity } from '../../types/futures';
|
||||||
import { toPersianDigits, getHorizonLabelFa } from '../../utils/persianNumbers';
|
import { toPersianDigits, getHorizonShortFa } from '../../utils/persianNumbers';
|
||||||
import { Briefcase, Brain, TrendingUp, AlertTriangle, ShieldCheck } from 'lucide-react';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { TrendingUp, TrendingDown, AlertTriangle, ArrowUpRight } from 'lucide-react';
|
||||||
|
|
||||||
interface JobCardProps {
|
/** Small tinted pill used for the growing / declining signal on both cards. */
|
||||||
job: JobEntity;
|
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';
|
const isGrowing = job.type === 'growing';
|
||||||
|
|
||||||
return (
|
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">
|
<Card data-card className="gap-0 p-4">
|
||||||
<div>
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
<TrendPill positive={isGrowing} icon={isGrowing ? TrendingUp : AlertTriangle}>
|
||||||
<span className={`badge ${isGrowing ? 'badge-teal' : 'badge-rose'}`}>
|
{isGrowing
|
||||||
{isGrowing ? (
|
? `رشد ${toPersianDigits(job.growthRatePercent)}٪`
|
||||||
<>
|
: `ریسک اتوماسیون ${toPersianDigits(job.automationRiskPercent)}٪`}
|
||||||
<TrendingUp size={12} />
|
</TrendPill>
|
||||||
شغل رو به رشد (+{toPersianDigits(job.growthRatePercent)}٪)
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
</>
|
{getHorizonShortFa(job.horizon)}
|
||||||
) : (
|
</span>
|
||||||
<>
|
</div>
|
||||||
<AlertTriangle size={12} />
|
|
||||||
شغل در معرض تحول ({toPersianDigits(job.automationRiskPercent)}٪ ریسک)
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
<h3 className="text-[13.5px] font-bold leading-relaxed">{job.title}</h3>
|
||||||
<div className="text-teal-700 text-xs font-bold mb-2">
|
|
||||||
دانشکده / گروه مرتبط: {job.discipline}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-slate-600 text-xs leading-relaxed mb-3">
|
<p className="mt-0.5 text-[11.5px] text-primary">{job.discipline}</p>
|
||||||
{job.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Required Skills Badges */}
|
<p className="mt-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
{job.description}
|
||||||
{job.keyRequiredSkills.map((sk, idx) => (
|
</p>
|
||||||
|
|
||||||
|
{job.keyRequiredSkills.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
|
{job.keyRequiredSkills.map((skill, i) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={i}
|
||||||
className="bg-slate-100 text-slate-700 border border-slate-200 text-[10.5px] px-2 py-0.5 rounded-full font-semibold"
|
className="rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
{sk}
|
{skill}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* University Action Box */}
|
<div className="mt-3 rounded-lg border bg-muted/40 p-2.5">
|
||||||
<div className="p-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs flex items-start gap-2">
|
<div className="mb-0.5 text-[10.5px] font-semibold text-muted-foreground">
|
||||||
<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>
|
||||||
|
<p className="text-[11.5px] leading-relaxed">{job.universityAction}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SkillCardProps {
|
export const SkillCard: React.FC<{ skill: SkillEntity }> = ({ skill }) => {
|
||||||
skill: SkillEntity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SkillCard: React.FC<SkillCardProps> = ({ skill }) => {
|
|
||||||
const isGrowing = skill.type === 'growing';
|
const isGrowing = skill.type === 'growing';
|
||||||
|
|
||||||
return (
|
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">
|
<Card data-card className="gap-0 p-4">
|
||||||
<div>
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
<TrendPill positive={isGrowing} icon={isGrowing ? ArrowUpRight : TrendingDown}>
|
||||||
<span className={`badge ${isGrowing ? 'badge-purple' : 'badge-amber'}`}>
|
{isGrowing ? 'رو به رشد' : 'در حال کاهش'}
|
||||||
{isGrowing ? 'شایستگی صعودی' : 'مهارت در حال کاهش'}
|
</TrendPill>
|
||||||
</span>
|
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||||
<span className="text-slate-500 text-xs font-bold">
|
اهمیت {toPersianDigits(skill.importanceScore)}/۵
|
||||||
اهمیت: {toPersianDigits(skill.importanceScore)}/۵
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 className="text-slate-900 font-extrabold text-sm mb-2">{skill.title}</h4>
|
<h3 className="text-[13.5px] font-bold leading-relaxed">{skill.title}</h3>
|
||||||
<p className="text-slate-600 text-xs leading-relaxed mb-3">
|
|
||||||
{skill.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Affected Fields */}
|
<p className="mt-1.5 text-[12px] leading-relaxed text-muted-foreground">
|
||||||
<div className="text-xs text-slate-500 mb-2">
|
{skill.description}
|
||||||
<span className="font-bold text-slate-700 block mb-1">رشتههای تحت تأثیر:</span>
|
</p>
|
||||||
|
|
||||||
|
{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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{skill.affectedAcademicFields.map((f, idx) => (
|
{skill.affectedAcademicFields.map((field, i) => (
|
||||||
<span key={idx} className="bg-slate-100 text-slate-700 text-[10.5px] px-2 py-0.5 rounded-full border border-slate-200">
|
<span
|
||||||
{f}
|
key={i}
|
||||||
|
className="rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{field}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="p-2.5 bg-slate-50 border border-slate-200 rounded-2xl text-[11px] text-slate-700">
|
<div className="mt-3 rounded-lg border bg-muted/40 p-2.5">
|
||||||
<span className="text-teal-700 font-bold block mb-0.5">وضعیت جایگزینی / ارتقا:</span>
|
<div className="mb-0.5 text-[10.5px] font-semibold text-muted-foreground">
|
||||||
<span className="leading-relaxed">{skill.replacementOrAugmentation}</span>
|
جایگزینی یا ارتقا
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] leading-relaxed">{skill.replacementOrAugmentation}</p>
|
||||||
</div>
|
</div>
|
||||||
</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 React, { useState } from 'react';
|
||||||
import { IntelligenceItem, Category, Horizon, ItemStatus } from '../../types/futures';
|
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 {
|
interface EntityEditorModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -9,6 +31,17 @@ interface EntityEditorModalProps {
|
||||||
initialItem?: IntelligenceItem | null;
|
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> = ({
|
export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -21,22 +54,22 @@ export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
||||||
const [category, setCategory] = useState<Category>(initialItem?.category || 'trend');
|
const [category, setCategory] = useState<Category>(initialItem?.category || 'trend');
|
||||||
const [horizon, setHorizon] = useState<Horizon>(initialItem?.horizon || 'near');
|
const [horizon, setHorizon] = useState<Horizon>(initialItem?.horizon || 'near');
|
||||||
const [status, setStatus] = useState<ItemStatus>(initialItem?.status || 'active');
|
const [status, setStatus] = useState<ItemStatus>(initialItem?.status || 'active');
|
||||||
const [impactScore, setImpactScore] = useState<number>(initialItem?.impactScore || 4);
|
const [impactScore, setImpactScore] = useState(initialItem?.impactScore || 4);
|
||||||
const [uncertaintyScore, setUncertaintyScore] = useState<number>(initialItem?.uncertaintyScore || 2);
|
const [uncertaintyScore, setUncertaintyScore] = useState(
|
||||||
const [geographicScope, setGeographicScope] = useState<'ایران' | 'منطقهای' | 'جهانی'>(
|
initialItem?.uncertaintyScore || 2
|
||||||
|
);
|
||||||
|
const [scope, setScope] = useState<(typeof SCOPES)[number]>(
|
||||||
initialItem?.geographicScope || 'ایران'
|
initialItem?.geographicScope || 'ایران'
|
||||||
);
|
);
|
||||||
const [summary, setSummary] = useState(initialItem?.executiveSummary || '');
|
const [summary, setSummary] = useState(initialItem?.executiveSummary || '');
|
||||||
const [whyItMatters, setWhyItMatters] = useState(initialItem?.whyItMatters || '');
|
const [whyItMatters, setWhyItMatters] = useState(initialItem?.whyItMatters || '');
|
||||||
const [relevance, setRelevance] = useState(initialItem?.relevanceToUniversity || '');
|
const [relevance, setRelevance] = useState(initialItem?.relevanceToUniversity || '');
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim() || !summary.trim()) return;
|
if (!title.trim() || !summary.trim()) return;
|
||||||
|
|
||||||
const item: IntelligenceItem = {
|
onSave({
|
||||||
id: initialItem?.id || `custom-${Date.now()}`,
|
id: initialItem?.id || `custom-${Date.now()}`,
|
||||||
title,
|
title,
|
||||||
category,
|
category,
|
||||||
|
|
@ -45,7 +78,7 @@ export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
||||||
impactScore,
|
impactScore,
|
||||||
uncertaintyScore,
|
uncertaintyScore,
|
||||||
confidence: 'high',
|
confidence: 'high',
|
||||||
geographicScope,
|
geographicScope: scope,
|
||||||
executiveSummary: summary,
|
executiveSummary: summary,
|
||||||
whyItMatters,
|
whyItMatters,
|
||||||
relevanceToUniversity: relevance,
|
relevanceToUniversity: relevance,
|
||||||
|
|
@ -58,201 +91,181 @@ export const EntityEditorModal: React.FC<EntityEditorModalProps> = ({
|
||||||
laborMarket: 4,
|
laborMarket: 4,
|
||||||
strategicImportance: impactScore
|
strategicImportance: impactScore
|
||||||
},
|
},
|
||||||
evidence: initialItem?.evidence || [
|
evidence: initialItem?.evidence || [],
|
||||||
{
|
|
||||||
title: 'شواهد ثبت شده توسط کاربر در سامانه هوشمندی',
|
|
||||||
source: 'دادهورزی داخلی دانشگاه اصفهان',
|
|
||||||
date: '۱۴۰۳/۰۶/۰۱',
|
|
||||||
snippet: summary
|
|
||||||
}
|
|
||||||
],
|
|
||||||
implications: initialItem?.implications || {
|
implications: initialItem?.implications || {
|
||||||
opportunities: ['فرصت نوآوری آموزشی و پاسخ به تقاضای بازار'],
|
opportunities: [],
|
||||||
risks: ['خطر عدم انطباق بهنگام برنامههای درسی'],
|
risks: [],
|
||||||
threats: ['عقبماندگی از رقبای منطقهای'],
|
threats: [],
|
||||||
requiredCapabilities: ['توسعه زیرساختها و بازآموزی اساتید'],
|
requiredCapabilities: [],
|
||||||
affectedDepartments: ['معاونت آموزشی', 'گروههای آموزشی مرتبط']
|
affectedDepartments: []
|
||||||
},
|
},
|
||||||
relatedItemIds: initialItem?.relatedItemIds || [],
|
relatedItemIds: initialItem?.relatedItemIds || [],
|
||||||
relatedJobIds: initialItem?.relatedJobIds || [],
|
relatedJobIds: initialItem?.relatedJobIds || [],
|
||||||
relatedSkillIds: initialItem?.relatedSkillIds || [],
|
relatedSkillIds: initialItem?.relatedSkillIds || [],
|
||||||
relatedDimensionIds: initialItem?.relatedDimensionIds || ['dim-1']
|
relatedDimensionIds: initialItem?.relatedDimensionIds || []
|
||||||
};
|
});
|
||||||
|
|
||||||
onSave(item);
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<div
|
<DialogContent className="max-h-[88vh] max-w-2xl overflow-y-auto">
|
||||||
className="modal-content p-6 flex flex-col gap-4 text-right animate-fadeIn max-w-2xl"
|
<form onSubmit={handleSubmit}>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogHeader>
|
||||||
>
|
<DialogTitle>{isEditing ? 'ویرایش مورد' : 'ثبت مورد جدید'}</DialogTitle>
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
<DialogDescription>
|
||||||
<h3 className="text-white font-black text-base">
|
روند، فناوری، سیاست یا سیگنال آینده را در رادار ثبت کنید.
|
||||||
{isEditing ? 'ویرایش موجودیت هوشمندی' : 'افزودن سیگنال / روند / فناوری جدید'}
|
</DialogDescription>
|
||||||
</h3>
|
</DialogHeader>
|
||||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg">
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 text-xs">
|
<div className="my-5 flex flex-col gap-4">
|
||||||
{/* Title */}
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-1">
|
<Label htmlFor="title">عنوان</Label>
|
||||||
<label className="text-slate-300 font-semibold">عنوان موجودیت:</label>
|
<Input
|
||||||
<input
|
id="title"
|
||||||
type="text"
|
required
|
||||||
required
|
value={title}
|
||||||
value={title}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</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
|
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<label className="text-slate-300 font-semibold">عدم قطعیت (۱ تا ۵):</label>
|
<div className="flex flex-col gap-1.5">
|
||||||
<input
|
<Label>دسته</Label>
|
||||||
type="number"
|
<Select value={category} onValueChange={(v) => setCategory(v as Category)}>
|
||||||
min={1}
|
<SelectTrigger className="w-full">
|
||||||
max={5}
|
<SelectValue />
|
||||||
value={uncertaintyScore}
|
</SelectTrigger>
|
||||||
onChange={(e) => setUncertaintyScore(Number(e.target.value))}
|
<SelectContent>
|
||||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2 text-white outline-none focus:border-teal-500 text-xs"
|
{CATEGORY_ORDER.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>
|
||||||
|
{CATEGORY_META[cat].label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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.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>
|
||||||
|
|
||||||
|
<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))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>دامنه</Label>
|
||||||
|
<Select
|
||||||
|
value={scope}
|
||||||
|
onValueChange={(v) => setScope(v as (typeof SCOPES)[number])}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-slate-300 font-semibold">دامنه جغرافیایی:</label>
|
<Label htmlFor="why">چرا اهمیت دارد؟</Label>
|
||||||
<select
|
<Textarea
|
||||||
value={geographicScope}
|
id="why"
|
||||||
onChange={(e) => setGeographicScope(e.target.value as any)}
|
rows={2}
|
||||||
className="bg-[#091122] border border-slate-800 rounded-lg p-2.5 text-white outline-none focus:border-teal-500 text-xs"
|
value={whyItMatters}
|
||||||
>
|
onChange={(e) => setWhyItMatters(e.target.value)}
|
||||||
<option value="ایران">ایران</option>
|
/>
|
||||||
<option value="منطقهای">منطقهای (خاورمیانه / استان اصفهان)</option>
|
</div>
|
||||||
<option value="جهانی">جهانی</option>
|
|
||||||
</select>
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="relevance">ارتباط با دانشگاه اصفهان</Label>
|
||||||
|
<Input
|
||||||
|
id="relevance"
|
||||||
|
value={relevance}
|
||||||
|
onChange={(e) => setRelevance(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
<DialogFooter>
|
||||||
<div className="flex flex-col gap-1">
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
<label className="text-slate-300 font-semibold">خلاصه مدیریتی (Executive Summary):</label>
|
|
||||||
<textarea
|
|
||||||
rows={2}
|
|
||||||
required
|
|
||||||
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
|
|
||||||
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"
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 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"
|
|
||||||
>
|
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit">
|
||||||
type="submit"
|
<Save />
|
||||||
className="btn btn-primary text-xs py-2 px-5 flex items-center gap-2"
|
{isEditing ? 'ذخیره' : 'ثبت'}
|
||||||
>
|
</Button>
|
||||||
<Save size={15} />
|
</DialogFooter>
|
||||||
<span>{isEditing ? 'ذخیره تغییرات' : 'ثبت در رادار هوشمندی'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,87 @@
|
||||||
import React, { useState } from 'react';
|
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 {
|
interface ForgotPasswordModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({ isOpen, onClose }) => {
|
export const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
const [identifier, setIdentifier] = useState('');
|
const [identifier, setIdentifier] = useState('');
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
// Reset only after the dialog has finished animating out.
|
||||||
e.preventDefault();
|
setTimeout(() => {
|
||||||
if (!identifier.trim()) return;
|
setSent(false);
|
||||||
setIsSubmitted(true);
|
setIdentifier('');
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
<div
|
<DialogContent className="max-w-md">
|
||||||
className="modal-content p-6 flex flex-col gap-4 text-right max-w-md animate-fadeIn"
|
{sent ? (
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||||
>
|
<CheckCircle2 className="size-9 text-primary" strokeWidth={1.8} />
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
<DialogTitle className="text-[15px]">لینک بازیابی ارسال شد</DialogTitle>
|
||||||
<h3 className="text-white font-black text-sm">بازیابی گذرواژه دانشگاهی</h3>
|
<DialogDescription className="max-w-xs">
|
||||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg">
|
لینک تعیین رمز عبور جدید به شناسه شما ارسال شد.
|
||||||
<X size={18} />
|
</DialogDescription>
|
||||||
</button>
|
<Button onClick={handleClose} className="mt-2">
|
||||||
</div>
|
بازگشت به ورود
|
||||||
|
</Button>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 text-xs">
|
<form
|
||||||
<p className="text-slate-300 leading-relaxed">
|
onSubmit={(e) => {
|
||||||
لطفاً پست الکترونیکی دانشگاهی (با پسوند ui.ac.ir@) یا شماره تلفن همراه متصل به کارتابل اداری خود را وارد نمایید.
|
e.preventDefault();
|
||||||
</p>
|
if (identifier.trim()) setSent(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>بازیابی رمز عبور</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
ایمیل دانشگاهی یا شماره تلفن همراه خود را وارد کنید.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="my-4 flex flex-col gap-1.5">
|
||||||
<label className="text-slate-400 font-semibold">پست الکترونیکی یا شماره همراه:</label>
|
<Label htmlFor="recovery">ایمیل یا شماره همراه</Label>
|
||||||
<div className="relative">
|
<Input
|
||||||
<input
|
id="recovery"
|
||||||
type="text"
|
required
|
||||||
required
|
value={identifier}
|
||||||
value={identifier}
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
onChange={(e) => setIdentifier(e.target.value)}
|
placeholder="name@ui.ac.ir"
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<Mail size={15} className="absolute left-2.5 top-3 text-slate-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
<DialogFooter>
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="btn btn-secondary text-xs py-2 px-4"
|
|
||||||
>
|
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit">ارسال لینک</Button>
|
||||||
type="submit"
|
</DialogFooter>
|
||||||
className="btn btn-primary text-xs py-2 px-5"
|
|
||||||
>
|
|
||||||
ارسال لینک بازیابی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../../types/futures';
|
|
||||||
import {
|
import {
|
||||||
toPersianDigits,
|
IntelligenceItem,
|
||||||
getCategoryLabelFa,
|
University1415Dimension,
|
||||||
getCategoryColor,
|
JobEntity,
|
||||||
getHorizonLabelFa
|
SkillEntity
|
||||||
} from '../../utils/persianNumbers';
|
} from '../../types/futures';
|
||||||
import { Search, X, Clock, Zap, ArrowLeft, Target, Briefcase, Brain } from 'lucide-react';
|
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 {
|
interface GlobalSearchModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -20,6 +32,31 @@ interface GlobalSearchModalProps {
|
||||||
onSelectJob: () => void;
|
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> = ({
|
export const GlobalSearchModal: React.FC<GlobalSearchModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -30,260 +67,97 @@ export const GlobalSearchModal: React.FC<GlobalSearchModalProps> = ({
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onSelectDimension,
|
onSelectDimension,
|
||||||
onSelectJob
|
onSelectJob
|
||||||
}) => {
|
}) => (
|
||||||
const [query, setQuery] = useState('');
|
<CommandDialog
|
||||||
const [activeFilter, setActiveFilter] = useState<'all' | 'items' | 'dimensions' | 'jobs' | 'skills'>('all');
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
title="جستجو"
|
||||||
|
description="جستجو در موارد رصد، ویژگیها، مشاغل و مهارتها"
|
||||||
|
className="max-w-xl"
|
||||||
|
>
|
||||||
|
<Command filter={substringFilter}>
|
||||||
|
<CommandInput placeholder="جستجو…" />
|
||||||
|
|
||||||
// Keyboard shortcut listener for Esc
|
<CommandList>
|
||||||
useEffect(() => {
|
<CommandEmpty>نتیجهای یافت نشد.</CommandEmpty>
|
||||||
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(() => {
|
<CommandGroup heading="موارد رصد">
|
||||||
const q = query.trim().toLowerCase();
|
{items.slice(0, 40).map((item) => {
|
||||||
if (!q) {
|
const meta = CATEGORY_META[item.category];
|
||||||
// Return top priority items if query is empty
|
return (
|
||||||
return {
|
<CommandItem
|
||||||
matchedItems: items.slice(0, 5),
|
key={item.id}
|
||||||
matchedDimensions: dimensions.slice(0, 3),
|
value={`${item.title} ${item.executiveSummary}`}
|
||||||
matchedJobs: jobs.slice(0, 3),
|
onSelect={() => onSelectItem(item)}
|
||||||
matchedSkills: skills.slice(0, 3)
|
className="gap-2.5"
|
||||||
};
|
>
|
||||||
}
|
<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>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
const matchedItems = items.filter(
|
<CommandSeparator />
|
||||||
(i) =>
|
|
||||||
i.title.toLowerCase().includes(q) ||
|
|
||||||
i.executiveSummary.toLowerCase().includes(q) ||
|
|
||||||
i.whyItMatters.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
|
|
||||||
const matchedDimensions = dimensions.filter(
|
<CommandGroup heading="ویژگیهای ۱۴۱۵">
|
||||||
(d) =>
|
{dimensions.slice(0, GROUP_LIMIT + 4).map((dim) => (
|
||||||
d.title.toLowerCase().includes(q) ||
|
<CommandItem
|
||||||
d.definition.toLowerCase().includes(q) ||
|
key={dim.id}
|
||||||
d.vision.toLowerCase().includes(q)
|
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>
|
||||||
|
|
||||||
const matchedJobs = jobs.filter(
|
<CommandSeparator />
|
||||||
(j) =>
|
|
||||||
j.title.toLowerCase().includes(q) ||
|
|
||||||
j.description.toLowerCase().includes(q) ||
|
|
||||||
j.discipline.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
|
|
||||||
const matchedSkills = skills.filter(
|
<CommandGroup heading="مشاغل">
|
||||||
(s) =>
|
{jobs.slice(0, 30).map((job) => (
|
||||||
s.title.toLowerCase().includes(q) ||
|
<CommandItem
|
||||||
s.description.toLowerCase().includes(q)
|
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>
|
||||||
|
|
||||||
return { matchedItems, matchedDimensions, matchedJobs, matchedSkills };
|
<CommandSeparator />
|
||||||
}, [query, items, dimensions, jobs, skills]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
<CommandGroup heading="مهارتها">
|
||||||
|
{skills.slice(0, 30).map((skill) => (
|
||||||
return (
|
<CommandItem
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
key={skill.id}
|
||||||
<div
|
value={`${skill.title} ${skill.description}`}
|
||||||
className="modal-content p-5 flex flex-col gap-4 text-right animate-fadeIn"
|
onSelect={onSelectJob}
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="gap-2.5"
|
||||||
>
|
>
|
||||||
{/* Search Input Bar */}
|
<Brain className="size-3.5 text-muted-foreground" />
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
<span className="min-w-0 flex-1 truncate">{skill.title}</span>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||||
<Search size={20} className="text-teal-400" />
|
{toPersianDigits(skill.importanceScore)}/۵
|
||||||
<input
|
</span>
|
||||||
type="text"
|
</CommandItem>
|
||||||
autoFocus
|
))}
|
||||||
value={query}
|
</CommandGroup>
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
</CommandList>
|
||||||
placeholder="جستجو در تمام موجودیتهای رادار، ابعاد ۱۴۱۵، مشاغل و مهارتها..."
|
</Command>
|
||||||
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-slate-500"
|
</CommandDialog>
|
||||||
/>
|
);
|
||||||
</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
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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 { IntelligenceItem, Category, Horizon } from '../../types/futures';
|
||||||
import {
|
import { layoutRadarItems, getHorizonBand } from '../../utils/radarMath';
|
||||||
calculateItemRadarCoords,
|
import { toPersianDigits, getHorizonLabelFa } from '../../utils/persianNumbers';
|
||||||
getHorizonRadiusRatio
|
import { CATEGORY_META, CATEGORY_ORDER } from '../../utils/categories';
|
||||||
} from '../../utils/radarMath';
|
import { horizonRangeFa } from '../../utils/horizon';
|
||||||
import {
|
|
||||||
toPersianDigits,
|
|
||||||
getCategoryLabelFa,
|
|
||||||
getHorizonLabelFa,
|
|
||||||
getCategoryColor
|
|
||||||
} from '../../utils/persianNumbers';
|
|
||||||
import { Eye, Clock, Zap } from 'lucide-react';
|
|
||||||
|
|
||||||
interface FuturesRadarProps {
|
interface FuturesRadarProps {
|
||||||
items: IntelligenceItem[];
|
items: IntelligenceItem[];
|
||||||
|
|
@ -20,355 +13,347 @@ interface FuturesRadarProps {
|
||||||
height?: number;
|
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> = ({
|
export const FuturesRadar: React.FC<FuturesRadarProps> = ({
|
||||||
items,
|
items,
|
||||||
selectedCategory = 'all',
|
selectedCategory = 'all',
|
||||||
selectedHorizon = 'all',
|
selectedHorizon = 'all',
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
height = 640
|
height = 480
|
||||||
}) => {
|
}) => {
|
||||||
const [hoveredItem, setHoveredItem] = useState<{
|
const [hovered, setHovered] = useState<{
|
||||||
item: IntelligenceItem;
|
item: IntelligenceItem;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const size = 720;
|
const size = 760;
|
||||||
const center = size / 2;
|
const center = size / 2;
|
||||||
const maxRadius = size / 2 - 36;
|
const maxRadius = size / 2 - 54;
|
||||||
|
|
||||||
// Filter items according to props
|
const filtered = useMemo(
|
||||||
const filteredItems = useMemo(() => {
|
() =>
|
||||||
return items.filter((item) => {
|
items.filter((item) => {
|
||||||
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
||||||
if (selectedHorizon !== 'all' && item.horizon !== selectedHorizon) return false;
|
if (selectedHorizon !== 'all' && item.horizon !== selectedHorizon) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
}),
|
||||||
}, [items, selectedCategory, selectedHorizon]);
|
[items, selectedCategory, selectedHorizon]
|
||||||
|
);
|
||||||
|
|
||||||
// Group items by category to calculate non-overlapping angles
|
// Packed so dots never sit on top of one another.
|
||||||
const itemsWithCoords = useMemo(() => {
|
const placed = useMemo(
|
||||||
const groups: Record<Category, IntelligenceItem[]> = {
|
() =>
|
||||||
trend: [],
|
layoutRadarItems(
|
||||||
technology: [],
|
filtered,
|
||||||
policy: [],
|
(i) => i.category,
|
||||||
weak_signal: []
|
(i) => i.horizon,
|
||||||
};
|
dotRadius,
|
||||||
|
center,
|
||||||
filteredItems.forEach((item) => {
|
maxRadius
|
||||||
groups[item.category].push(item);
|
),
|
||||||
});
|
[filtered, center, maxRadius]
|
||||||
|
);
|
||||||
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,
|
|
||||||
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 (
|
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
|
<svg
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
className="w-full max-w-[680px] h-auto drop-shadow-md"
|
className="h-auto w-full max-w-[680px] overflow-visible"
|
||||||
style={{ overflow: 'visible' }}
|
role="img"
|
||||||
|
aria-label="رادار آینده: موارد رصدشده بر اساس افق زمانی و دسته"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
{/* Radial radar background gradient for Neo-Clean Bento canvas */}
|
{/* Plot backdrop: brighter at the centre so depth reads outward. */}
|
||||||
<radialGradient id="radarBgGrad" cx="50%" cy="50%" r="50%">
|
<radialGradient id="radarField" cx="50%" cy="50%" r="50%">
|
||||||
<stop offset="0%" stopColor="#ffffff" stopOpacity="1" />
|
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.1" />
|
||||||
<stop offset="70%" stopColor="#fbfcfe" stopOpacity="1" />
|
<stop offset="45%" stopColor="var(--primary)" stopOpacity="0.035" />
|
||||||
<stop offset="100%" stopColor="#f3f6fa" stopOpacity="1" />
|
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
{/* Sweeping radar scan gradient */}
|
{/* Sweep: a trailing wedge that fades away from the leading edge. */}
|
||||||
<linearGradient id="scanGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="radarSweep" x1="100%" y1="0%" x2="0%" y2="100%">
|
||||||
<stop offset="0%" stopColor="#00b894" stopOpacity="0.25" />
|
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.28" />
|
||||||
<stop offset="60%" stopColor="#00b894" stopOpacity="0.04" />
|
<stop offset="45%" stopColor="var(--primary)" stopOpacity="0.07" />
|
||||||
<stop offset="100%" stopColor="#00b894" stopOpacity="0" />
|
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
{/* Glow Filters for high-impact dots */}
|
{/* Soft halo behind each dot. */}
|
||||||
<filter id="radarShadow" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="dotGlow" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#0f172a" floodOpacity="0.12" />
|
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
|
|
||||||
|
{/* Keeps the sweep inside the plot circle. */}
|
||||||
|
<clipPath id="radarClip">
|
||||||
|
<circle cx={center} cy={center} r={maxRadius} />
|
||||||
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Outer Circular Bounds */}
|
|
||||||
<circle
|
<circle
|
||||||
cx={center}
|
cx={center}
|
||||||
cy={center}
|
cy={center}
|
||||||
r={maxRadius}
|
r={maxRadius}
|
||||||
fill="url(#radarBgGrad)"
|
fill="var(--card)"
|
||||||
stroke="#e2e8f0"
|
stroke="var(--border)"
|
||||||
strokeWidth="1.5"
|
|
||||||
filter="url(#radarShadow)"
|
|
||||||
/>
|
/>
|
||||||
|
<circle cx={center} cy={center} r={maxRadius} fill="url(#radarField)" />
|
||||||
|
|
||||||
{/* Quadrant Sector Pastel Tint Washes */}
|
{/* One faint wash per quadrant so each category owns a region. */}
|
||||||
{/* Top-Right: Trends (Soft Sky) */}
|
<g clipPath="url(#radarClip)">
|
||||||
<path
|
{CATEGORY_ORDER.map((cat, i) => {
|
||||||
d={`M ${center} ${center} L ${center + maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center - maxRadius} Z`}
|
const a0 = (i * Math.PI) / 2;
|
||||||
fill="rgba(2, 132, 199, 0.035)"
|
const a1 = a0 + Math.PI / 2;
|
||||||
/>
|
return (
|
||||||
{/* Top-Left: Technologies (Soft Mint) */}
|
<path
|
||||||
<path
|
key={cat}
|
||||||
d={`M ${center} ${center} L ${center} ${center - maxRadius} A ${maxRadius} ${maxRadius} 0 0 0 ${center - maxRadius} ${center} Z`}
|
d={`M ${center} ${center} L ${center + maxRadius * Math.cos(a0)} ${
|
||||||
fill="rgba(0, 184, 148, 0.035)"
|
center + maxRadius * Math.sin(a0)
|
||||||
/>
|
} A ${maxRadius} ${maxRadius} 0 0 1 ${
|
||||||
{/* Bottom-Left: Weak Signals (Soft Coral/Rose) */}
|
center + maxRadius * Math.cos(a1)
|
||||||
<path
|
} ${center + maxRadius * Math.sin(a1)} Z`}
|
||||||
d={`M ${center} ${center} L ${center - maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center + maxRadius} Z`}
|
fill={CATEGORY_META[cat].color}
|
||||||
fill="rgba(244, 63, 94, 0.035)"
|
fillOpacity="0.04"
|
||||||
/>
|
/>
|
||||||
{/* 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)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Sweeping Radar Scan Line */}
|
<g className="radar-sweep-line pointer-events-none">
|
||||||
<g className="radar-sweep-line pointer-events-none">
|
<path
|
||||||
<path
|
d={`M ${center} ${center} L ${center + maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center - maxRadius} Z`}
|
||||||
d={`M ${center} ${center} L ${center + maxRadius} ${center} A ${maxRadius} ${maxRadius} 0 0 0 ${center} ${center - maxRadius} Z`}
|
fill="url(#radarSweep)"
|
||||||
fill="url(#scanGradient)"
|
/>
|
||||||
/>
|
<line
|
||||||
<line
|
x1={center}
|
||||||
x1={center}
|
y1={center}
|
||||||
y1={center}
|
x2={center + maxRadius}
|
||||||
x2={center + maxRadius}
|
y2={center}
|
||||||
y2={center}
|
stroke="var(--primary)"
|
||||||
stroke="#00b894"
|
strokeWidth="1.5"
|
||||||
strokeWidth="1.5"
|
strokeOpacity="0.55"
|
||||||
strokeOpacity="0.75"
|
/>
|
||||||
/>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Concentric Horizon Rings */}
|
{/* Horizon rings, labelled with their year band. */}
|
||||||
{rings.map((ring, idx) => {
|
{HORIZONS.map((horizon, idx) => {
|
||||||
const r = maxRadius * ring.ratio;
|
const band = getHorizonBand(horizon);
|
||||||
const isHighlighted = selectedHorizon === ring.horizon;
|
const r = maxRadius * band.outer;
|
||||||
|
const active = selectedHorizon === horizon;
|
||||||
return (
|
return (
|
||||||
<g key={ring.horizon}>
|
<g key={horizon}>
|
||||||
<circle
|
<circle
|
||||||
cx={center}
|
cx={center}
|
||||||
cy={center}
|
cy={center}
|
||||||
r={r}
|
r={r}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={isHighlighted ? '#00b894' : '#e2e8f0'}
|
stroke={active ? 'var(--primary)' : 'var(--border)'}
|
||||||
strokeWidth={isHighlighted ? '2' : '1'}
|
strokeWidth={active ? 1.5 : 1}
|
||||||
strokeDasharray={idx === 0 ? 'none' : '4,4'}
|
strokeDasharray={idx === HORIZONS.length - 1 ? 'none' : '3 6'}
|
||||||
opacity={isHighlighted ? 1 : 0.85}
|
strokeOpacity={active ? 0.9 : 0.7}
|
||||||
/>
|
/>
|
||||||
{/* Ring Label (Horizon text on vertical axis) */}
|
|
||||||
<text
|
<text
|
||||||
x={center}
|
x={center}
|
||||||
y={center - r + 13}
|
y={center - r + 15}
|
||||||
fill="#64748b"
|
fill="var(--muted-foreground)"
|
||||||
fontSize="9.5"
|
fontSize="10.5"
|
||||||
fontWeight="600"
|
fontWeight="500"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
opacity="0.75"
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
>
|
>
|
||||||
{ring.label}
|
{horizonRangeFa(horizon)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Quadrant Divider Axes */}
|
{/* Quadrant dividers */}
|
||||||
<line
|
<line
|
||||||
x1={center - maxRadius}
|
x1={center - maxRadius}
|
||||||
y1={center}
|
y1={center}
|
||||||
x2={center + maxRadius}
|
x2={center + maxRadius}
|
||||||
y2={center}
|
y2={center}
|
||||||
stroke="#cbd5e1"
|
stroke="var(--border)"
|
||||||
strokeWidth="1.2"
|
strokeDasharray="2 7"
|
||||||
strokeDasharray="4,4"
|
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
x1={center}
|
x1={center}
|
||||||
y1={center - maxRadius}
|
y1={center - maxRadius}
|
||||||
x2={center}
|
x2={center}
|
||||||
y2={center + maxRadius}
|
y2={center + maxRadius}
|
||||||
stroke="#cbd5e1"
|
stroke="var(--border)"
|
||||||
strokeWidth="1.2"
|
strokeDasharray="2 7"
|
||||||
strokeDasharray="4,4"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Center Institutional Hub */}
|
{/* Quadrant names, set outside the plot so they never sit under dots. */}
|
||||||
<circle cx={center} cy={center} r={20} fill="#ffffff" stroke="#00b894" strokeWidth="2.5" filter="url(#radarShadow)" />
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
<circle cx={center} cy={center} r={7} fill="#00b894" className="anim-pulse" />
|
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
|
<text
|
||||||
x={center}
|
x={center}
|
||||||
y={center + 32}
|
y={center + 33}
|
||||||
fill="#1e293b"
|
fill="var(--muted-foreground)"
|
||||||
fontSize="9.5"
|
fontSize="10.5"
|
||||||
fontWeight="800"
|
fontWeight="600"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
>
|
>
|
||||||
دانشگاه اصفهان
|
دانشگاه اصفهان
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Quadrant Corner Badges (Modern Bento Pills) */}
|
{/* Blips */}
|
||||||
{/* Top-Right: Trends */}
|
{placed.map(({ item, x, y }, idx) => {
|
||||||
<g transform={`translate(${center + maxRadius * 0.48}, ${center - maxRadius * 0.78})`}>
|
const color = CATEGORY_META[item.category].color;
|
||||||
<rect x="-65" y="-13" width="130" height="26" rx="13" fill="#ffffff" stroke="#bae6fd" strokeWidth="1.5" filter="url(#radarShadow)" />
|
const r = dotRadius(item);
|
||||||
<text textAnchor="middle" y="4.5" fill="#0284c7" fontSize="11" fontWeight="800">روندها (Trends)</text>
|
const isCritical = item.status === 'critical';
|
||||||
</g>
|
const isHovered = hovered?.item.id === item.id;
|
||||||
|
|
||||||
{/* 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={item.id}
|
key={item.id}
|
||||||
transform={`translate(${x}, ${y})`}
|
transform={`translate(${x}, ${y})`}
|
||||||
className="cursor-pointer transition-transform duration-150"
|
className="cursor-pointer"
|
||||||
style={{ transformBox: 'fill-box' }}
|
|
||||||
onClick={() => onSelectItem(item)}
|
onClick={() => onSelectItem(item)}
|
||||||
onMouseEnter={() => setHoveredItem({ item, x, y })}
|
onMouseEnter={() => setHovered({ item, x, y })}
|
||||||
onMouseLeave={() => setHoveredItem(null)}
|
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 && (
|
{isCritical && (
|
||||||
<circle
|
<circle
|
||||||
r={dotR + 6}
|
r={r + 7}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.25"
|
||||||
opacity="0.4"
|
opacity="0.4"
|
||||||
className="anim-pulse"
|
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
|
<circle
|
||||||
r={dotR}
|
r={isHovered ? r + 2 : r}
|
||||||
fill={color}
|
fill={color}
|
||||||
stroke="#ffffff"
|
stroke="var(--card)"
|
||||||
strokeWidth="2.5"
|
strokeWidth="2"
|
||||||
filter="url(#radarShadow)"
|
filter={isHovered ? 'url(#dotGlow)' : undefined}
|
||||||
|
style={{ transition: 'r 160ms cubic-bezier(0.16,1,0.3,1)' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Impact Number */}
|
|
||||||
<text
|
<text
|
||||||
y="3.5"
|
y="3.6"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="#ffffff"
|
fontSize={r > 11 ? '10.5' : '9.5'}
|
||||||
fontSize={dotR > 10 ? '9.5' : '8.5'}
|
fontWeight="700"
|
||||||
fontWeight="900"
|
fill="#fff"
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
>
|
>
|
||||||
{toPersianDigits(item.impactScore)}
|
{toPersianDigits(item.impactScore)}
|
||||||
</text>
|
</text>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Floating Hover Tooltip Card (Bento Light Style) */}
|
{hovered && (
|
||||||
{hoveredItem && (
|
|
||||||
<div
|
<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={{
|
style={{
|
||||||
top: Math.max(10, hoveredItem.y - 120),
|
top: `${(hovered.y / size) * 100}%`,
|
||||||
left: Math.max(10, Math.min(size - 220, hoveredItem.x - 90))
|
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
|
<span
|
||||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full"
|
className="inline-flex items-center gap-1.5 text-[10.5px] font-semibold"
|
||||||
style={{
|
style={{ color: CATEGORY_META[hovered.item.category].color }}
|
||||||
backgroundColor: `${getCategoryColor(hoveredItem.item.category)}18`,
|
|
||||||
color: getCategoryColor(hoveredItem.item.category)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{getCategoryLabelFa(hoveredItem.item.category)}
|
<span
|
||||||
|
className="cat-dot"
|
||||||
|
style={{ background: CATEGORY_META[hovered.item.category].color }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[hovered.item.category].label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-semibold text-slate-400">
|
<span className="text-[10.5px] text-muted-foreground">
|
||||||
{getHorizonLabelFa(hoveredItem.item.horizon)}
|
{getHorizonLabelFa(hovered.item.horizon)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5 className="text-slate-900 font-extrabold text-xs leading-snug mb-1">
|
<p className="text-[12px] font-bold leading-snug">{hovered.item.title}</p>
|
||||||
{hoveredItem.item.title}
|
<p className="mt-1 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
</h5>
|
{hovered.item.whyItMatters}
|
||||||
|
</p>
|
||||||
<p className="text-slate-500 text-[11px] line-clamp-2 leading-relaxed mb-2">
|
<p className="tnum mt-2 border-t pt-1.5 text-[10.5px] text-muted-foreground">
|
||||||
{hoveredItem.item.whyItMatters}
|
شدت اثر {toPersianDigits(hovered.item.impactScore)} از ۵
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 {
|
import {
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Microscope,
|
Microscope,
|
||||||
Cpu,
|
Settings2,
|
||||||
Users,
|
Users,
|
||||||
|
UserCog,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Target,
|
Target
|
||||||
Sparkles
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ImpactGaugesProps {
|
interface ImpactGaugesProps {
|
||||||
impact: ImpactAssessment;
|
impact: ImpactAssessment;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DimensionConfig {
|
const AREAS: {
|
||||||
key: keyof ImpactAssessment;
|
key: keyof ImpactAssessment;
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
icon: React.ElementType;
|
||||||
icon: React.ReactNode;
|
}[] = [
|
||||||
color: string;
|
{ 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 MAX = 5;
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
/**
|
||||||
<div className="flex flex-col gap-3">
|
* Seven areas on one shared 1–5 scale. A segmented meter rather than a bar,
|
||||||
{dimensions.map((dim) => {
|
* so the reader can count the score without reading the number.
|
||||||
const score = impact[dim.key] || 1;
|
*/
|
||||||
const percent = (score / 5) * 100;
|
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 (
|
return (
|
||||||
<div
|
<li key={area.key} className="flex items-center gap-3 py-2.5">
|
||||||
key={dim.key}
|
<Icon className="size-4 shrink-0 text-muted-foreground" strokeWidth={1.9} />
|
||||||
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="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>
|
<span className="min-w-0 flex-1 truncate text-[12.5px]">{area.title}</span>
|
||||||
|
|
||||||
{/* Score Bar */}
|
<span className="flex shrink-0 items-center gap-1" aria-hidden>
|
||||||
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden flex">
|
{Array.from({ length: MAX }, (_, i) => (
|
||||||
<div
|
<span
|
||||||
className="h-full rounded-full transition-all duration-500"
|
key={i}
|
||||||
|
className="h-1.5 w-4 rounded-full transition-colors"
|
||||||
style={{
|
style={{
|
||||||
width: `${percent}%`,
|
background: i < score ? 'var(--primary)' : 'var(--muted)'
|
||||||
backgroundColor: dim.color
|
|
||||||
}}
|
}}
|
||||||
></div>
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* 5 Dots Indicator */}
|
<span className="tnum w-8 shrink-0 text-left text-[12px] font-semibold">
|
||||||
<div className="flex items-center justify-between px-1 text-[9px] text-slate-500">
|
{toPersianDigits(score)}
|
||||||
<span>خیلی کم (۱)</span>
|
<span className="text-muted-foreground">/۵</span>
|
||||||
<span>کم (۲)</span>
|
</span>
|
||||||
<span>متوسط (۳)</span>
|
</li>
|
||||||
<span>زیاد (۴)</span>
|
);
|
||||||
<span>بسیار زیاد (۵)</span>
|
})}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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 { JobEntity, SkillEntity } from '../../types/futures';
|
||||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
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 {
|
interface LaborChartsProps {
|
||||||
jobs: JobEntity[];
|
jobs: JobEntity[];
|
||||||
skills: SkillEntity[];
|
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 }) => {
|
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 topGrowing = useMemo(
|
||||||
const transformingJobs = jobs.filter((j) => j.type === 'transforming').slice(0, 8);
|
() =>
|
||||||
|
jobs
|
||||||
|
.filter((j) => j.type === 'growing')
|
||||||
|
.sort((a, b) => b.growthRatePercent - a.growthRatePercent)
|
||||||
|
.slice(0, 6),
|
||||||
|
[jobs]
|
||||||
|
);
|
||||||
|
|
||||||
const skillCategories = [
|
const topAtRisk = useMemo(
|
||||||
{ key: 'cognitive', title: 'شناختی و تحلیلی', icon: <Brain size={15} />, color: '#8b5cf6', count: 10 },
|
() =>
|
||||||
{ key: 'digital', title: 'دیجیتال و هوش مصنوعی', icon: <Code size={15} />, color: '#00b894', count: 12 },
|
jobs
|
||||||
{ key: 'social', title: 'اجتماعی و انسانی', icon: <Users size={15} />, color: '#f59e0b', count: 8 },
|
.filter((j) => j.type === 'transforming')
|
||||||
{ key: 'technical', title: 'فنی و مهندسی', icon: <Sparkles size={15} />, color: '#0284c7', count: 6 }
|
.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 (
|
return (
|
||||||
<div className="w-full flex flex-col gap-5 animate-fadeIn">
|
<div>
|
||||||
{/* Tab Switcher (Pill Style) */}
|
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-200 pb-3">
|
<TabsList className="h-9">
|
||||||
<div className="flex items-center bg-slate-100 p-1 rounded-full text-xs shadow-sm">
|
<TabsTrigger value="jobs">مشاغل</TabsTrigger>
|
||||||
<button
|
<TabsTrigger value="skills">مهارتها</TabsTrigger>
|
||||||
onClick={() => setActiveTab('jobs')}
|
</TabsList>
|
||||||
className={`py-1.5 px-4 rounded-full font-bold flex items-center gap-1.5 transition-all ${
|
</Tabs>
|
||||||
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>
|
|
||||||
|
|
||||||
<span className="text-slate-400 text-xs font-semibold hidden md:inline">
|
{tab === 'jobs' && (
|
||||||
تحلیل انطباق با زیستبوم صنعتی و فناوری استان اصفهان
|
<div className="mt-3 grid animate-in grid-cols-1 gap-3 fade-in duration-200 lg:grid-cols-2">
|
||||||
</span>
|
<Card data-card>
|
||||||
</div>
|
<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)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{activeTab === 'jobs' && (
|
<Card data-card>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 animate-fadeIn">
|
<CardHeader className="border-b pb-3">
|
||||||
{/* Top Growing Jobs Ranking */}
|
<CardTitle className="text-[14px]">بیشترین ریسک اتوماسیون</CardTitle>
|
||||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col gap-3">
|
<p className="text-[11.5px] text-muted-foreground">
|
||||||
<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">
|
</p>
|
||||||
<TrendingUp size={16} className="text-teal-600" />
|
</CardHeader>
|
||||||
بیشترین نرخ رشد تقاضای شغلی (افق تا ۱۴۱۵)
|
<CardContent className="pt-1">
|
||||||
</h4>
|
<ul className="divide-y">
|
||||||
<span className="badge badge-teal">رشد تصاعدی</span>
|
{topAtRisk.map((j) => (
|
||||||
</div>
|
<BarRow
|
||||||
|
key={j.id}
|
||||||
<div className="flex flex-col gap-2.5 mt-1">
|
title={j.title}
|
||||||
{growingJobs.map((j) => (
|
subtitle={j.discipline}
|
||||||
<div key={j.id} className="bg-slate-50 p-3 rounded-2xl border border-slate-200 text-xs">
|
value={`${toPersianDigits(j.automationRiskPercent)}٪`}
|
||||||
<div className="flex items-center justify-between mb-1">
|
ratio={j.automationRiskPercent}
|
||||||
<span className="font-extrabold text-slate-900">{j.title}</span>
|
color="var(--negative)"
|
||||||
<span className="font-bold text-teal-700 font-mono text-[13px]">
|
/>
|
||||||
+{toPersianDigits(j.growthRatePercent)}٪
|
))}
|
||||||
</span>
|
</ul>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500 font-medium">
|
</Card>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'skills' && (
|
{tab === 'skills' && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 animate-fadeIn">
|
<div className="mt-3 grid animate-in grid-cols-2 gap-3 fade-in duration-200 lg:grid-cols-4">
|
||||||
{skillCategories.map((cat) => (
|
{skillBreakdown.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">
|
const Icon = cat.icon;
|
||||||
<div className="flex items-center justify-between">
|
return (
|
||||||
<span className="text-xs font-bold text-slate-900">{cat.title}</span>
|
<Card data-card key={cat.key} className="gap-0 p-4">
|
||||||
<span
|
<div className="flex items-start justify-between">
|
||||||
className="p-2 rounded-xl"
|
<span className="text-[12px] font-medium text-muted-foreground">
|
||||||
style={{ backgroundColor: `${cat.color}15`, color: cat.color }}
|
{cat.title}
|
||||||
>
|
</span>
|
||||||
{cat.icon}
|
<Icon className="size-4 text-muted-foreground/70" strokeWidth={1.9} />
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="my-1">
|
<div className="mt-2 flex items-baseline gap-1">
|
||||||
<span className="kpi-number text-3xl font-black text-slate-900">
|
<span className="kpi-number text-2xl font-bold">
|
||||||
{toPersianDigits(cat.count)}
|
{toPersianDigits(cat.total)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-400 mr-1 font-bold">مهارت کلیدی</span>
|
<span className="text-[11px] text-muted-foreground">مهارت</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-slate-500 border-t border-slate-100 pt-2 font-medium">
|
|
||||||
تأکید در کوریکولوم دانشگاه اصفهان
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
</span>
|
<div
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { University1415Dimension } from '../../types/futures';
|
import { University1415Dimension } from '../../types/futures';
|
||||||
import { getSpiderCoordinates, pointsToSvgPath } from '../../utils/radarMath';
|
import { getSpiderCoordinates, pointsToSvgPath } from '../../utils/radarMath';
|
||||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||||
|
import { HORIZON_END } from '../../utils/horizon';
|
||||||
|
|
||||||
interface SpiderChartProps {
|
interface SpiderChartProps {
|
||||||
dimensions: University1415Dimension[];
|
dimensions: University1415Dimension[];
|
||||||
|
|
@ -9,89 +10,90 @@ interface SpiderChartProps {
|
||||||
height?: number;
|
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> = ({
|
export const SpiderChart: React.FC<SpiderChartProps> = ({
|
||||||
dimensions,
|
dimensions,
|
||||||
onSelectDimension,
|
onSelectDimension,
|
||||||
height = 580
|
height = 560
|
||||||
}) => {
|
}) => {
|
||||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
const [hovered, setHovered] = useState<number | null>(null);
|
||||||
|
|
||||||
const size = 640;
|
const size = 640;
|
||||||
const center = size / 2;
|
const center = size / 2;
|
||||||
const maxRadius = size / 2 - 70;
|
const maxRadius = size / 2 - 76;
|
||||||
const numAxes = dimensions.length; // 10
|
const axes = dimensions.length;
|
||||||
|
|
||||||
const currentScores = dimensions.map((d) => d.currentScore);
|
if (!axes) return null;
|
||||||
const targetScores = dimensions.map((d) => d.targetScore);
|
|
||||||
|
|
||||||
const currentPoints = getSpiderCoordinates(currentScores, 10, center, maxRadius);
|
const currentPoints = getSpiderCoordinates(
|
||||||
const targetPoints = getSpiderCoordinates(targetScores, 10, center, maxRadius);
|
dimensions.map((d) => d.currentScore),
|
||||||
|
MAX_SCORE,
|
||||||
const currentPath = pointsToSvgPath(currentPoints);
|
center,
|
||||||
const targetPath = pointsToSvgPath(targetPoints);
|
maxRadius
|
||||||
|
);
|
||||||
// Concentric polygon grids (2, 4, 6, 8, 10)
|
const targetPoints = getSpiderCoordinates(
|
||||||
const gridLevels = [2, 4, 6, 8, 10];
|
dimensions.map((d) => d.targetScore),
|
||||||
|
MAX_SCORE,
|
||||||
|
center,
|
||||||
|
maxRadius
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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
|
<svg
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
className="w-full max-w-[620px] h-auto drop-shadow-2xl"
|
className="h-auto w-full max-w-[600px]"
|
||||||
style={{ overflow: 'visible' }}
|
role="img"
|
||||||
|
aria-label={`نمودار عنکبوتی شکاف وضع موجود و هدف ${toPersianDigits(HORIZON_END)}`}
|
||||||
>
|
>
|
||||||
<defs>
|
{/* Web */}
|
||||||
{/* Gradients */}
|
{GRID_LEVELS.map((level) => {
|
||||||
<linearGradient id="spiderTargetGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
const path = pointsToSvgPath(
|
||||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.38" />
|
getSpiderCoordinates(Array(axes).fill(level), MAX_SCORE, center, maxRadius)
|
||||||
<stop offset="100%" stopColor="#0284c7" stopOpacity="0.22" />
|
);
|
||||||
</linearGradient>
|
const isOuter = level === MAX_SCORE;
|
||||||
|
|
||||||
<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);
|
|
||||||
return (
|
return (
|
||||||
<g key={lvl}>
|
<g key={level}>
|
||||||
<path
|
<path
|
||||||
d={path}
|
d={path}
|
||||||
fill={lvl === 10 ? 'rgba(241, 245, 249, 0.5)' : 'none'}
|
fill={isOuter ? 'var(--muted)' : 'none'}
|
||||||
stroke="#e2e8f0"
|
fillOpacity={isOuter ? 0.4 : 0}
|
||||||
strokeWidth={lvl === 10 ? '1.5' : '1'}
|
stroke="var(--border)"
|
||||||
strokeDasharray={lvl === 10 ? 'none' : '3,3'}
|
strokeDasharray={isOuter ? 'none' : '3 4'}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={center + 5}
|
x={center + 6}
|
||||||
y={center - (lvl / 10) * maxRadius + 11}
|
y={center - (level / MAX_SCORE) * maxRadius + 11}
|
||||||
fill="#94a3b8"
|
fill="var(--muted-foreground)"
|
||||||
fontSize="9"
|
fontSize="9"
|
||||||
fontWeight="600"
|
opacity="0.7"
|
||||||
>
|
>
|
||||||
{toPersianDigits(lvl)}
|
{toPersianDigits(level)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Radial Axis Lines */}
|
{/* Axes + labels */}
|
||||||
{dimensions.map((dim, idx) => {
|
{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 endX = center + maxRadius * Math.cos(angle);
|
||||||
const endY = center + maxRadius * Math.sin(angle);
|
const endY = center + maxRadius * Math.sin(angle);
|
||||||
|
const labelDist = maxRadius + 34;
|
||||||
// Label positions (slightly outside maxRadius)
|
|
||||||
const labelDist = maxRadius + 36;
|
|
||||||
const lx = center + labelDist * Math.cos(angle);
|
const lx = center + labelDist * Math.cos(angle);
|
||||||
const ly = center + labelDist * Math.sin(angle);
|
const ly = center + labelDist * Math.sin(angle);
|
||||||
|
const isHovered = hovered === idx;
|
||||||
const isHovered = hoveredIdx === idx;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={dim.id}>
|
<g key={dim.id}>
|
||||||
|
|
@ -100,35 +102,30 @@ export const SpiderChart: React.FC<SpiderChartProps> = ({
|
||||||
y1={center}
|
y1={center}
|
||||||
x2={endX}
|
x2={endX}
|
||||||
y2={endY}
|
y2={endY}
|
||||||
stroke="#e2e8f0"
|
stroke="var(--border)"
|
||||||
strokeWidth="1.2"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Interactive Label on Axis End */}
|
|
||||||
<g
|
<g
|
||||||
transform={`translate(${lx}, ${ly})`}
|
transform={`translate(${lx}, ${ly})`}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => onSelectDimension && onSelectDimension(dim)}
|
onClick={() => onSelectDimension?.(dim)}
|
||||||
onMouseEnter={() => setHoveredIdx(idx)}
|
onMouseEnter={() => setHovered(idx)}
|
||||||
onMouseLeave={() => setHoveredIdx(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
x="-50"
|
x="-46"
|
||||||
y="-12"
|
y="-11"
|
||||||
width="100"
|
width="92"
|
||||||
height="24"
|
height="22"
|
||||||
rx="8"
|
rx="6"
|
||||||
fill={isHovered ? '#e6f7f3' : '#ffffff'}
|
fill={isHovered ? 'var(--primary)' : 'var(--card)'}
|
||||||
stroke={isHovered ? '#00b894' : '#cbd5e1'}
|
stroke={isHovered ? 'var(--primary)' : 'var(--border)'}
|
||||||
strokeWidth="1.2"
|
|
||||||
filter="drop-shadow(0 1px 3px rgba(0,0,0,0.06))"
|
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
y="4"
|
y="4"
|
||||||
fill={isHovered ? '#008f73' : '#1e293b'}
|
fontSize="10"
|
||||||
fontSize="9.5"
|
fontWeight="600"
|
||||||
fontWeight={isHovered ? '900' : '700'}
|
fill={isHovered ? 'var(--primary-foreground)' : 'var(--foreground)'}
|
||||||
>
|
>
|
||||||
{dim.shortTag}
|
{dim.shortTag}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -137,104 +134,96 @@ export const SpiderChart: React.FC<SpiderChartProps> = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Target 1415 Polygon (دانشگاه اصفهان ۱۴۱۵) */}
|
{/* Target ring — outline only. */}
|
||||||
<path
|
<path
|
||||||
d={targetPath}
|
d={pointsToSvgPath(targetPoints)}
|
||||||
fill="url(#spiderTargetGrad)"
|
fill="none"
|
||||||
stroke="#00b894"
|
stroke="var(--primary)"
|
||||||
strokeWidth="2.5"
|
strokeWidth="2"
|
||||||
className="transition-all duration-300"
|
strokeDasharray="5 4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Current State Polygon (وضع موجود) */}
|
{/* Current state — filled, so the shortfall reads as empty space. */}
|
||||||
<path
|
<path
|
||||||
d={currentPath}
|
d={pointsToSvgPath(currentPoints)}
|
||||||
fill="url(#spiderCurrentGrad)"
|
fill="var(--primary)"
|
||||||
stroke="#f59e0b"
|
fillOpacity="0.16"
|
||||||
|
stroke="var(--primary)"
|
||||||
strokeWidth="2.5"
|
strokeWidth="2.5"
|
||||||
className="transition-all duration-300"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Target 1415 Vertex Dots */}
|
|
||||||
{targetPoints.map((pt, idx) => (
|
{targetPoints.map((pt, idx) => (
|
||||||
<circle
|
<circle
|
||||||
key={`target-pt-${idx}`}
|
key={`t-${idx}`}
|
||||||
cx={pt.x}
|
cx={pt.x}
|
||||||
cy={pt.y}
|
cy={pt.y}
|
||||||
r="4.5"
|
r="3.5"
|
||||||
fill="#00b894"
|
fill="var(--card)"
|
||||||
stroke="#ffffff"
|
stroke="var(--primary)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
className="hover:scale-150 transition-transform cursor-pointer"
|
className="cursor-pointer"
|
||||||
onMouseEnter={() => setHoveredIdx(idx)}
|
onMouseEnter={() => setHovered(idx)}
|
||||||
onMouseLeave={() => setHoveredIdx(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Current State Vertex Dots */}
|
|
||||||
{currentPoints.map((pt, idx) => (
|
{currentPoints.map((pt, idx) => (
|
||||||
<circle
|
<circle
|
||||||
key={`curr-pt-${idx}`}
|
key={`c-${idx}`}
|
||||||
cx={pt.x}
|
cx={pt.x}
|
||||||
cy={pt.y}
|
cy={pt.y}
|
||||||
r="4.5"
|
r="4"
|
||||||
fill="#f59e0b"
|
fill="var(--primary)"
|
||||||
stroke="#ffffff"
|
stroke="var(--card)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
className="hover:scale-150 transition-transform cursor-pointer"
|
className="cursor-pointer"
|
||||||
onMouseEnter={() => setHoveredIdx(idx)}
|
onMouseEnter={() => setHovered(idx)}
|
||||||
onMouseLeave={() => setHoveredIdx(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Center Hub Marker */}
|
|
||||||
<circle cx={center} cy={center} r="4" fill="#64748b" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Interactive Hover Tooltip (Light Bento Card) */}
|
{hovered !== null && (
|
||||||
{hoveredIdx !== 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">
|
||||||
<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">
|
<p className="text-[13px] font-bold">{dimensions[hovered].title}</p>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
<span className="text-xs font-black text-slate-900">{dimensions[hoveredIdx].title}</span>
|
{dimensions[hovered].titleEn}
|
||||||
<span
|
</p>
|
||||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full"
|
|
||||||
style={{
|
<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]">
|
||||||
backgroundColor: `${dimensions[hoveredIdx].pillarColor}15`,
|
<span>
|
||||||
color: dimensions[hoveredIdx].pillarColor
|
موجود{' '}
|
||||||
}}
|
<b className="font-semibold">
|
||||||
>
|
{toPersianDigits(dimensions[hovered].currentScore)}
|
||||||
آمادگی: {toPersianDigits(dimensions[hoveredIdx].readinessPercentage)}٪
|
</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>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="mt-2 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
<span className="text-amber-600 font-bold">
|
{dimensions[hovered].vision}
|
||||||
وضع موجود: {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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spider Chart Legend */}
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[12px]">
|
||||||
<div className="flex items-center gap-6 mt-4 text-xs font-bold">
|
<span className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<span className="inline-block h-2.5 w-4 rounded-sm bg-primary/25 ring-1 ring-primary" />
|
||||||
<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>
|
</span>
|
||||||
</div>
|
<span className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<span className="inline-block h-0 w-4 border-t-2 border-dashed border-primary" />
|
||||||
<span className="w-3.5 h-3.5 rounded-full bg-amber-500 border border-white shadow-sm"></span>
|
هدف {toPersianDigits(HORIZON_END)}
|
||||||
<span className="text-slate-800">وضعیت موجود دانشگاه (مبنای سنجش)</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../../types/futures';
|
import {
|
||||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
IntelligenceItem,
|
||||||
import { ArrowLeft, GitFork, Cpu, GraduationCap, Briefcase, Target } from 'lucide-react';
|
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 {
|
interface StrategicGraphProps {
|
||||||
items: IntelligenceItem[];
|
items: IntelligenceItem[];
|
||||||
|
|
@ -11,6 +20,13 @@ interface StrategicGraphProps {
|
||||||
onSelectEntity?: (type: string, id: string) => void;
|
onSelectEntity?: (type: string, id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Chain = {
|
||||||
|
item: IntelligenceItem;
|
||||||
|
skill?: SkillEntity;
|
||||||
|
job?: JobEntity;
|
||||||
|
dimension?: University1415Dimension;
|
||||||
|
};
|
||||||
|
|
||||||
export const StrategicGraph: React.FC<StrategicGraphProps> = ({
|
export const StrategicGraph: React.FC<StrategicGraphProps> = ({
|
||||||
items,
|
items,
|
||||||
dimensions,
|
dimensions,
|
||||||
|
|
@ -18,221 +34,183 @@ export const StrategicGraph: React.FC<StrategicGraphProps> = ({
|
||||||
skills,
|
skills,
|
||||||
onSelectEntity
|
onSelectEntity
|
||||||
}) => {
|
}) => {
|
||||||
// Pre-configured end-to-end strategic disruption pathways for demonstration
|
// Chains are built from the links stored on each item, not hard-coded, so
|
||||||
const scenarios = [
|
// they stay correct when the dataset changes.
|
||||||
{
|
const chains: Chain[] = useMemo(() => {
|
||||||
id: 'sc-1',
|
return items
|
||||||
title: 'زنجیره هوش مصنوعی مولد و دانشگاه هوشمند (AI-Native Disruption)',
|
.filter(
|
||||||
tech: items.find((i) => i.id === 'tc-1') || items[0],
|
(item) =>
|
||||||
trend: items.find((i) => i.id === 'tr-1') || items[0],
|
item.relatedDimensionIds?.length &&
|
||||||
skill: skills.find((s) => s.id === 'sk-1') || skills[0],
|
(item.relatedSkillIds?.length || item.relatedJobIds?.length)
|
||||||
job: jobs.find((j) => j.id === 'job-1') || jobs[0],
|
)
|
||||||
dimension: dimensions.find((d) => d.id === 'dim-1') || dimensions[0],
|
.sort((a, b) => b.impactScore - a.impactScore)
|
||||||
gapAnalysis: 'شکاف ۵.۳ نمرهای در آمادگی زیرساخت هوش مصنوعی و نیاز به ۳۰ سرور پردازش گرافیکی بومی.'
|
.slice(0, 6)
|
||||||
},
|
.map((item) => ({
|
||||||
{
|
item,
|
||||||
id: 'sc-2',
|
skill: skills.find((s) => item.relatedSkillIds?.includes(s.id)),
|
||||||
title: 'زنجیره بحران آب، فرونشست زمین و دانشگاه مسئلهمحور (Water & Subsidence Mission)',
|
job: jobs.find((j) => item.relatedJobIds?.includes(j.id)),
|
||||||
tech: items.find((i) => i.id === 'tc-12') || items[1],
|
dimension: dimensions.find((d) => item.relatedDimensionIds?.includes(d.id))
|
||||||
trend: items.find((i) => i.id === 'tr-10') || items[1],
|
}));
|
||||||
skill: skills.find((s) => s.id === 'sk-4') || skills[1],
|
}, [items, skills, jobs, dimensions]);
|
||||||
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: 'شکاف ۴.۶ نمرهای در آموزشهای مبتنی بر شایستگی و مقاومت در حذف دروس تئوری غیرکاربردی.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const [activeScenarioIdx, setActiveScenarioIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const activeScenario = scenarios[activeScenarioIdx];
|
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 (
|
return (
|
||||||
<div className="w-full flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Scenario Selector Header */}
|
{/* Chain picker */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 bg-[#0d162a] border border-slate-800 p-4 rounded-xl">
|
<div className="scroll-x">
|
||||||
<div className="flex items-center gap-2 text-white font-bold text-sm">
|
<div className="flex items-center gap-1.5">
|
||||||
<GitFork size={18} className="text-teal-400" />
|
{chains.map((chain, idx) => (
|
||||||
<span>زنجیره ارزش تحول راهبردی:</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{scenarios.map((sc, idx) => (
|
|
||||||
<button
|
<button
|
||||||
key={sc.id}
|
key={chain.item.id}
|
||||||
onClick={() => setActiveScenarioIdx(idx)}
|
onClick={() => setActiveIdx(idx)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
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 ${
|
||||||
activeScenarioIdx === idx
|
activeIdx === idx
|
||||||
? 'bg-teal-500 text-slate-950 shadow-md shadow-teal-500/20 font-bold'
|
? 'border-transparent bg-foreground text-background'
|
||||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disruption Chain Visual Flow */}
|
{/* The chain itself */}
|
||||||
<div className="intel-card border-slate-700/80 p-6 lg:p-8 flex flex-col gap-6">
|
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="border-b border-slate-800 pb-3">
|
<ChainStep
|
||||||
<h3 className="text-white font-black text-base lg:text-lg">
|
step={1}
|
||||||
{activeScenario.title}
|
label="پیشران"
|
||||||
</h3>
|
icon={Cpu}
|
||||||
<p className="text-slate-400 text-xs mt-1">
|
color={meta.color}
|
||||||
ردگیری مسیر تبدیل سیگنال/فناوری بیرونی به شکاف قابلیت در دانشگاه اصفهان
|
title={active.item.title}
|
||||||
</p>
|
body={active.item.executiveSummary}
|
||||||
</div>
|
footer={`${meta.label} · ${getHorizonShortFa(active.item.horizon)} · اثر ${toPersianDigits(
|
||||||
|
active.item.impactScore
|
||||||
|
)}/۵`}
|
||||||
|
onClick={() => onSelectEntity?.('item', active.item.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Linear Step Flow Cards */}
|
<ChainStep
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 relative">
|
step={2}
|
||||||
{/* Step 1: Technology / Signal */}
|
label="مهارت موردنیاز"
|
||||||
<div
|
icon={Brain}
|
||||||
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"
|
color="var(--cat-trend)"
|
||||||
onClick={() => onSelectEntity && onSelectEntity('item', activeScenario.tech.id)}
|
title={active.skill?.title ?? '—'}
|
||||||
>
|
body={active.skill?.description ?? 'مهارت مرتبطی ثبت نشده است.'}
|
||||||
<div>
|
footer={
|
||||||
<div className="flex items-center justify-between text-[11px] text-teal-400 font-bold mb-2">
|
active.skill
|
||||||
<span className="flex items-center gap-1">
|
? `اهمیت ${toPersianDigits(active.skill.importanceScore)}/۵`
|
||||||
<Cpu size={13} />
|
: undefined
|
||||||
۱. پیشران فناورانه
|
}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Step 2: Educational Disruption */}
|
<ChainStep
|
||||||
<div
|
step={3}
|
||||||
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"
|
label="شغل متأثر"
|
||||||
onClick={() => onSelectEntity && onSelectEntity('item', activeScenario.trend.id)}
|
icon={Briefcase}
|
||||||
>
|
color="var(--cat-policy)"
|
||||||
<div>
|
title={active.job?.title ?? '—'}
|
||||||
<div className="flex items-center justify-between text-[11px] text-sky-400 font-bold mb-2">
|
body={active.job?.description ?? 'شغل مرتبطی ثبت نشده است.'}
|
||||||
<span className="flex items-center gap-1">
|
footer={active.job?.discipline}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Step 3: Required Skills */}
|
<ChainStep
|
||||||
<div className="bg-[#091122] border border-purple-500/40 p-4 rounded-xl flex flex-col justify-between">
|
step={4}
|
||||||
<div>
|
label={`شکاف قابلیت ${toPersianDigits(HORIZON_END)}`}
|
||||||
<div className="flex items-center justify-between text-[11px] text-purple-400 font-bold mb-2">
|
icon={Target}
|
||||||
<span className="flex items-center gap-1">
|
color="var(--negative)"
|
||||||
<ArrowLeft size={13} />
|
title={active.dimension?.title ?? '—'}
|
||||||
۳. شایستگی نوظهور
|
body={active.dimension?.vision ?? 'بعد مرتبطی ثبت نشده است.'}
|
||||||
</span>
|
footer={
|
||||||
<span className="badge badge-purple">مهارت الزامی</span>
|
active.dimension
|
||||||
</div>
|
? `موجود ${toPersianDigits(
|
||||||
<h5 className="text-white font-bold text-xs leading-snug mb-1">
|
active.dimension.currentScore
|
||||||
{activeScenario.skill.title}
|
)} · هدف ${toPersianDigits(
|
||||||
</h5>
|
active.dimension.targetScore
|
||||||
<p className="text-slate-400 text-[11px] line-clamp-3">
|
)} · شکاف ${toPersianDigits(active.dimension.gap)}`
|
||||||
{activeScenario.skill.description}
|
: undefined
|
||||||
</p>
|
}
|
||||||
</div>
|
onClick={
|
||||||
<div className="pt-2 mt-3 border-t border-slate-800/80 text-[10px] text-slate-500">
|
active.dimension
|
||||||
رده: {activeScenario.skill.category}
|
? () => onSelectEntity?.('dimension', active.dimension!.id)
|
||||||
</div>
|
: undefined
|
||||||
</div>
|
}
|
||||||
|
isLast
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
</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 { FuturesForce, ForceType } from '../../types/futures';
|
||||||
import { toPersianDigits } from '../../utils/persianNumbers';
|
import { toPersianDigits } from '../../utils/persianNumbers';
|
||||||
import {
|
|
||||||
Compass,
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
ArrowUpRight,
|
import { cn } from '@/lib/utils';
|
||||||
ShieldAlert,
|
|
||||||
Sparkles,
|
|
||||||
TrendingUp,
|
|
||||||
Target
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface TriangleViewProps {
|
interface TriangleViewProps {
|
||||||
forces: FuturesForce[];
|
forces: FuturesForce[];
|
||||||
onSelectForce?: (force: FuturesForce) => void;
|
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 }) => {
|
export const TriangleView: React.FC<TriangleViewProps> = ({ forces, onSelectForce }) => {
|
||||||
const [selectedForce, setSelectedForce] = useState<FuturesForce>(forces[0]);
|
const [selected, setSelected] = useState<FuturesForce | null>(forces[0] ?? null);
|
||||||
const [activeTab, setActiveTab] = useState<ForceType>('pull');
|
|
||||||
|
|
||||||
const pullForces = forces.filter((f) => f.forceType === 'pull');
|
const grouped = useMemo(
|
||||||
const pushForces = forces.filter((f) => f.forceType === 'push');
|
() => ({
|
||||||
const weightForces = forces.filter((f) => f.forceType === 'weight');
|
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) => {
|
const strength = (type: ForceType) => {
|
||||||
setSelectedForce(f);
|
const list = grouped[type];
|
||||||
setActiveTab(f.forceType);
|
if (!list.length) return 0;
|
||||||
if (onSelectForce) onSelectForce(f);
|
return list.reduce((sum, f) => sum + f.strength, 0) / list.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sleek Equilateral Geometry in 800x560 coordinate space
|
const pick = (force: FuturesForce) => {
|
||||||
// Base: 650 - 150 = 500, Height: 480 - 50 = 430 (Proportion ~0.86, perfectly sleek)
|
setSelected(force);
|
||||||
const apex = { x: 400, y: 50 };
|
onSelectForce?.(force);
|
||||||
const rightCorner = { x: 650, y: 480 };
|
};
|
||||||
const leftCorner = { x: 150, y: 480 };
|
|
||||||
const centroid = { x: 400, y: 336 };
|
if (!forces.length) return null;
|
||||||
|
|
||||||
|
const selectedGroup = selected
|
||||||
|
? GROUPS.find((g) => g.type === selected.forceType)!
|
||||||
|
: GROUPS[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-6 animate-fadeIn">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Visual Triangle Composition Canvas */}
|
<Card data-card>
|
||||||
<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">
|
<CardContent className="p-3 sm:p-6">
|
||||||
{/* Subtle Ambient Background Gradients */}
|
<svg
|
||||||
<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>
|
viewBox="0 0 640 470"
|
||||||
<div className="absolute bottom-12 right-12 w-64 h-64 bg-sky-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
className="mx-auto h-auto w-full max-w-[620px]"
|
||||||
<div className="absolute bottom-12 left-12 w-64 h-64 bg-rose-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
role="img"
|
||||||
|
aria-label="مثلث آینده: کشش آینده، فشارهای حال و وزن گذشته"
|
||||||
|
>
|
||||||
|
{/* 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"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 1. TOP VERTEX APEX CLUSTER: PULL OF THE FUTURE (کشش آینده) */}
|
{/* Each vertex is scaled by the average strength of its group,
|
||||||
<div className="w-full flex flex-col items-center mb-3 z-10">
|
so the shape shows which way the university is being pulled. */}
|
||||||
<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">
|
{GROUPS.map((group, i) => {
|
||||||
<Sparkles size={16} className="text-purple-600" />
|
const vertex = [APEX, RIGHT, LEFT][i];
|
||||||
<span>کشش آینده — تصویر آرمانی دانشگاه</span>
|
const avg = strength(group.type);
|
||||||
</div>
|
const r = 26 + avg * 2.6;
|
||||||
<span className="text-[11px] text-slate-500 font-semibold mt-1">
|
const count = grouped[group.type].length;
|
||||||
جاذبهها، چشماندازهای نو و الگوهای مطلوب آینده
|
|
||||||
</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 (
|
return (
|
||||||
<button
|
<g key={group.type}>
|
||||||
key={f.id}
|
<circle
|
||||||
onClick={() => handleForceClick(f)}
|
cx={vertex.x}
|
||||||
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm ${
|
cy={vertex.y}
|
||||||
isSelected
|
r={r}
|
||||||
? 'bg-slate-900 text-white shadow-md scale-105'
|
fill={group.color}
|
||||||
: 'bg-white text-slate-800 hover:bg-purple-50 hover:text-purple-900 border border-purple-200'
|
fillOpacity="0.12"
|
||||||
}`}
|
stroke={group.color}
|
||||||
>
|
strokeWidth="1.5"
|
||||||
<span>{f.title}</span>
|
/>
|
||||||
<span className={`text-[10px] px-1.5 py-0.2 rounded-full font-bold ${
|
<text
|
||||||
isSelected ? 'bg-slate-800 text-purple-300' : 'bg-purple-100 text-purple-800'
|
x={vertex.x}
|
||||||
}`}>
|
y={vertex.y - 4}
|
||||||
{toPersianDigits(f.strength)}
|
textAnchor="middle"
|
||||||
</span>
|
fontSize="12"
|
||||||
</button>
|
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 */}
|
{/* The university sits at the centroid, between the three pulls. */}
|
||||||
<div className="relative w-full max-w-[720px] my-2 select-none">
|
<circle cx="320" cy="292" r="5" fill="var(--primary)" />
|
||||||
<svg
|
<text
|
||||||
viewBox="0 0 800 550"
|
x="320"
|
||||||
className="w-full h-auto drop-shadow-sm"
|
y="313"
|
||||||
style={{ overflow: 'visible' }}
|
textAnchor="middle"
|
||||||
>
|
fontSize="11"
|
||||||
<defs>
|
fontWeight="600"
|
||||||
{/* Triangular Gradient Fill */}
|
fill="var(--muted-foreground)"
|
||||||
<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" />
|
</text>
|
||||||
<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>
|
</svg>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{group.hint}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 3. BOTTOM TWO CORNER PANELS (PUSH & WEIGHT) */}
|
{/* Detail of the selected force */}
|
||||||
<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">
|
{selected && (
|
||||||
{/* PUSH PANEL (Bottom-Right Vertex) */}
|
<Card data-card className="xl:col-span-5">
|
||||||
<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">
|
<CardHeader className="border-b pb-3">
|
||||||
<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">
|
<div className="flex items-center gap-2">
|
||||||
<TrendingUp size={15} className="text-sky-600" />
|
<span
|
||||||
<span>فشارهای زمان حال</span>
|
className="rounded px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||||
</div>
|
style={{ background: selectedGroup.soft, color: selectedGroup.color }}
|
||||||
<span className="text-[11px] text-slate-500 font-semibold mb-2.5">
|
>
|
||||||
روندها، فناوریهای فعال و تحولات جاری
|
{selectedGroup.label}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 justify-center md:justify-start">
|
<CardContent className="flex flex-col gap-3 pt-4">
|
||||||
{pushForces.map((f) => {
|
<DetailBlock title="شواهد" body={selected.evidence} />
|
||||||
const isSelected = selectedForce.id === f.id;
|
<DetailBlock title="اثر" body={selected.impact} />
|
||||||
return (
|
<DetailBlock title="جهت حرکت" body={selected.direction} />
|
||||||
<button
|
<DetailBlock title="دلالت راهبردی" body={selected.strategicImplication} />
|
||||||
key={f.id}
|
</CardContent>
|
||||||
onClick={() => handleForceClick(f)}
|
</Card>
|
||||||
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>
|
</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">
|
|
||||||
<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'
|
|
||||||
? 'فشار زمان حال'
|
|
||||||
: 'وزن گذشته'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</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 };
|
||||||
|
}
|
||||||
1244
src/index.css
1244
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 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 {
|
import {
|
||||||
Sparkles,
|
IntelligenceItem,
|
||||||
TrendingUp,
|
University1415Dimension,
|
||||||
Cpu,
|
Category,
|
||||||
BookOpen,
|
Horizon,
|
||||||
Briefcase,
|
UserSession
|
||||||
Target,
|
} from '../types/futures';
|
||||||
ArrowUpRight,
|
import { FuturesRadar } from '../components/visual/FuturesRadar';
|
||||||
ArrowLeft,
|
import { PageHeader } from '../components/common/PageHeader';
|
||||||
AlertTriangle,
|
import { StatCard } from '../components/common/StatCard';
|
||||||
Lightbulb,
|
import { toPersianDigits } from '../utils/persianNumbers';
|
||||||
CheckCircle2,
|
import { CATEGORY_META, CATEGORY_ORDER } from '../utils/categories';
|
||||||
Calendar,
|
import { HORIZON_START, HORIZON_END } from '../utils/horizon';
|
||||||
Layers,
|
|
||||||
Triangle,
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
GitFork,
|
import { Button } from '@/components/ui/button';
|
||||||
FileText
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
} from 'lucide-react';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ArrowLeft, FileText, Triangle, GitFork, Target } from 'lucide-react';
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
currentSession: UserSession | null;
|
currentSession: UserSession | null;
|
||||||
|
|
@ -33,7 +29,6 @@ interface DashboardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dashboard: React.FC<DashboardProps> = ({
|
export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
currentSession,
|
|
||||||
items,
|
items,
|
||||||
dimensions,
|
dimensions,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
|
|
@ -41,463 +36,308 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
onNavigate
|
onNavigate
|
||||||
}) => {
|
}) => {
|
||||||
const [radarCategory, setRadarCategory] = useState<Category | 'all'>('all');
|
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
|
const criticalItems = items
|
||||||
.filter((i) => i.status === 'critical' || i.impactScore === 5)
|
.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, 4);
|
||||||
const topGaps = [...dimensions].sort((a, b) => b.gap - a.gap).slice(0, 3);
|
|
||||||
|
|
||||||
// Average readiness
|
const avgReadiness = dimensions.length
|
||||||
const avgReadiness = Math.round(
|
? Math.round(
|
||||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) /
|
||||||
);
|
dimensions.length
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const countFor = (cat: Category) => items.filter((i) => i.category === cat).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Persona Context Reminder */}
|
<PageHeader
|
||||||
<PersonaBanner currentSession={currentSession} />
|
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 */}
|
{/* The four observation streams, plus the readiness index. */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-1">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||||
<div>
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
<div className="flex items-center gap-2 mb-1">
|
const meta = CATEGORY_META[cat];
|
||||||
<span className="text-xl font-bold">👋</span>
|
return (
|
||||||
<h1 className="text-2xl lg:text-3xl font-black text-slate-900 tracking-tight">
|
<StatCard
|
||||||
رادار آینده دانشگاه اصفهان
|
key={cat}
|
||||||
</h1>
|
label={meta.label}
|
||||||
<span className="badge badge-teal font-bold">سال تحصیلی ۱۴۰۳-۱۴۰۴</span>
|
value={countFor(cat)}
|
||||||
</div>
|
meta="مورد رصدشده"
|
||||||
<p className="text-slate-500 text-xs mt-0.5">
|
accent={meta.color}
|
||||||
دیدهبانی مستمر پیشرانها، فناوریها، سیگنالهای ضعیف و سنجش آمادگی دانشگاه در افق ۱۴۱۵
|
onClick={() => onNavigate(`/radar?category=${cat}`)}
|
||||||
</p>
|
/>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 self-start md:self-auto">
|
<StatCard
|
||||||
<button
|
label="آمادگی ۱۴۱۵"
|
||||||
onClick={() => onNavigate('/reports')}
|
value={`${toPersianDigits(avgReadiness)}٪`}
|
||||||
className="btn btn-secondary text-xs flex items-center gap-2 py-2 px-4 shadow-sm"
|
meta="میانگین ۱۰ ویژگی"
|
||||||
>
|
icon={Target}
|
||||||
<FileText size={14} className="text-slate-500" />
|
accent="var(--primary)"
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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')}
|
onClick={() => onNavigate('/gap-analysis')}
|
||||||
className="intel-card interactive p-4 flex flex-col justify-between border-teal-200"
|
className="col-span-2 lg:col-span-1"
|
||||||
>
|
/>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Main Centerpiece: Split Layout (Futures Radar on Left + Readiness & Alerts on Right) */}
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
|
{/* Radar */}
|
||||||
{/* Main Left Column: Interactive Futures Radar Bento Card (7 Cols) */}
|
<Card data-card className="xl:col-span-7">
|
||||||
<div className="xl:col-span-7 intel-card flex flex-col p-6">
|
<CardHeader className="gap-3 border-b pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-100">
|
<div className="min-w-0">
|
||||||
<div>
|
<CardTitle className="text-[15px]">رادار آینده</CardTitle>
|
||||||
<h3 className="text-slate-900 font-black text-base flex items-center gap-2">
|
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||||
<Target size={18} className="text-teal-600" />
|
چهار افق زمانی؛ فاصله از مرکز بر اساس دوری زمانی و اندازه نقطه بر
|
||||||
<span>رادار تعاملی آیندهپژوهی آموزش عالی</span>
|
اساس شدت اثر.
|
||||||
</h3>
|
|
||||||
<p className="text-slate-500 text-xs mt-0.5">
|
|
||||||
موقعیتیابی بر اساس ۴ افق زمانی، شدت اثر و دستهبندی موضوعی
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pill Filter Switcher (Inspired by Image 4 Top Nav Pills) */}
|
<div className="scroll-x">
|
||||||
<div className="flex items-center bg-slate-100 p-1 rounded-full text-xs">
|
<Tabs
|
||||||
<button
|
value={radarCategory}
|
||||||
onClick={() => setRadarCategory('all')}
|
onValueChange={(v) => setRadarCategory(v as Category | '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)})
|
<TabsList className="h-8">
|
||||||
</button>
|
<TabsTrigger value="all" className="text-[12px]">
|
||||||
<button
|
همه
|
||||||
onClick={() => setRadarCategory('trend')}
|
</TabsTrigger>
|
||||||
className={`px-3 py-1 rounded-full font-bold transition-all ${
|
{CATEGORY_ORDER.map((cat) => (
|
||||||
radarCategory === 'trend'
|
<TabsTrigger key={cat} value={cat} className="text-[12px]">
|
||||||
? 'bg-sky-600 text-white shadow-sm'
|
{CATEGORY_META[cat].label}
|
||||||
: 'text-sky-700 hover:text-sky-900'
|
</TabsTrigger>
|
||||||
}`}
|
))}
|
||||||
>
|
</TabsList>
|
||||||
روندها
|
</Tabs>
|
||||||
</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>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Radar Visualization Canvas */}
|
<CardContent className="flex flex-col items-center gap-3 pt-4">
|
||||||
<div className="py-2 flex items-center justify-center">
|
|
||||||
<FuturesRadar
|
<FuturesRadar
|
||||||
items={items}
|
items={items}
|
||||||
selectedCategory={radarCategory}
|
selectedCategory={radarCategory}
|
||||||
selectedHorizon={radarHorizon}
|
selectedHorizon={radarHorizon}
|
||||||
onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
height={520}
|
height={480}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Radar Action Footer */}
|
<div className="flex w-full items-center justify-between border-t pt-3 text-[12px]">
|
||||||
<div className="pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-500">
|
<span className="text-muted-foreground">
|
||||||
<span>برای بررسی پرونده تحلیلی، روی هر نقطه کلیک کنید.</span>
|
برای دیدن پرونده، روی هر نقطه کلیک کنید.
|
||||||
<button
|
</span>
|
||||||
onClick={() => onNavigate('/radar')}
|
<button
|
||||||
className="text-teal-600 font-bold hover:underline flex items-center gap-1"
|
onClick={() => onNavigate('/radar')}
|
||||||
>
|
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
|
||||||
نمایش تمامصفحه رادار با ماتریس تحلیلی
|
>
|
||||||
<ArrowLeft size={13} />
|
نمای کامل
|
||||||
</button>
|
<ArrowLeft className="size-3.5" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Arc Progress Visualization */}
|
{/* Readiness + critical items */}
|
||||||
<div className="flex items-center justify-between gap-4 py-1">
|
<div className="flex flex-col gap-4 xl:col-span-5">
|
||||||
<div className="relative w-36 h-36 shrink-0 flex items-center justify-center">
|
<Card data-card>
|
||||||
{/* SVG Semi-Circle Arc Meter */}
|
<CardHeader className="border-b pb-4">
|
||||||
<svg viewBox="0 0 100 100" className="w-full h-full transform -rotate-90">
|
<CardTitle className="text-[15px]">آمادگی افق ۱۴۱۵</CardTitle>
|
||||||
<circle
|
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||||
cx="50"
|
نسبت وضع موجود به وضع مطلوب در ۱۰ ویژگی دانشگاه.
|
||||||
cy="50"
|
</p>
|
||||||
r="40"
|
</CardHeader>
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Priority Gaps breakdown */}
|
<CardContent className="pt-4">
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
<div className="mb-4 flex items-center gap-4">
|
||||||
<span className="text-xs font-bold text-slate-700">بزرگترین شکافهای نیازمند مداخله:</span>
|
<ReadinessDial value={avgReadiness} />
|
||||||
{topGaps.map((dim) => (
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div className="text-[12px] text-muted-foreground">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="font-bold text-slate-800">{dim.title}</span>
|
|
||||||
<span className="text-rose-600 font-black">
|
|
||||||
شکاف: {toPersianDigits(dim.gap)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
</div>
|
{topGaps.map((dim) => (
|
||||||
</div>
|
<button
|
||||||
|
key={dim.id}
|
||||||
<button
|
onClick={() => onSelectDimension(dim)}
|
||||||
onClick={() => onNavigate('/gap-analysis')}
|
className="group flex items-center gap-2 text-right"
|
||||||
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 className="w-14 shrink-0 text-[11px] text-muted-foreground">
|
||||||
<span>مشاهده نمودار عنکبوتی ۱۰ بعدی و جزئیات</span>
|
{dim.shortTag}
|
||||||
<ArrowLeft size={13} />
|
</span>
|
||||||
</button>
|
<Progress
|
||||||
</div>
|
value={dim.readinessPercentage}
|
||||||
|
className="h-1.5 flex-1"
|
||||||
{/* Card 2: Immediate Foresight Alerts List */}
|
/>
|
||||||
<div className="intel-card p-5 flex flex-col gap-3.5">
|
<span className="tnum w-7 shrink-0 text-[11px] font-semibold text-negative">
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
|
{toPersianDigits(dim.gap)}
|
||||||
<div className="flex items-center gap-2">
|
</span>
|
||||||
<div className="w-8 h-8 rounded-full bg-rose-50 border border-rose-200 text-rose-600 flex items-center justify-center">
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* List of Critical Items as Pastel Pill Rows */}
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{criticalItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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)}/۵
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-500 text-[11px] line-clamp-1 mt-0.5 leading-relaxed">
|
|
||||||
{item.whyItMatters}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => onNavigate('/gap-analysis')}
|
||||||
|
>
|
||||||
|
نمودار عنکبوتی ۱۰ بعدی
|
||||||
|
<ArrowLeft />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="group w-full py-2.5 text-right"
|
||||||
|
>
|
||||||
|
<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="mt-0.5 line-clamp-1 text-[11.5px] text-muted-foreground">
|
||||||
|
{item.whyItMatters}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Bento Row: Futures Triangle + Strategic Impact Value Chain */}
|
{/* Two analytical views that sit outside the radar. */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{/* Futures Triangle Teaser */}
|
<NavCard
|
||||||
<div
|
icon={Triangle}
|
||||||
|
title="مثلث آینده"
|
||||||
|
description="کشش آینده، فشارهای حال و وزن گذشته — ۱۵ نیروی مؤثر بر مسیر دانشگاه."
|
||||||
onClick={() => onNavigate('/futures-triangle')}
|
onClick={() => onNavigate('/futures-triangle')}
|
||||||
className="intel-card interactive bento-card-purple p-6 flex flex-col justify-between"
|
/>
|
||||||
>
|
<NavCard
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-purple-200/60">
|
icon={GitFork}
|
||||||
<div className="flex items-center gap-2.5">
|
title="شبکه روابط"
|
||||||
<div className="w-10 h-10 rounded-2xl bg-white text-purple-600 flex items-center justify-center shadow-sm">
|
description="زنجیره اثر: فناوری، آموزش، مهارت، شغل و در نهایت شکاف قابلیت."
|
||||||
<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
|
|
||||||
onClick={() => onNavigate('/network')}
|
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>
|
||||||
</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 React from 'react';
|
||||||
import { FuturesForce } from '../types/futures';
|
import { FuturesForce } from '../types/futures';
|
||||||
import { TriangleView } from '../components/visual/TriangleView';
|
import { TriangleView } from '../components/visual/TriangleView';
|
||||||
|
import { PageHeader } from '../components/common/PageHeader';
|
||||||
import { toPersianDigits } from '../utils/persianNumbers';
|
import { toPersianDigits } from '../utils/persianNumbers';
|
||||||
import { Sparkles, TrendingUp, ShieldAlert } from 'lucide-react';
|
|
||||||
|
|
||||||
interface FuturesTrianglePageProps {
|
interface FuturesTrianglePageProps {
|
||||||
forces: FuturesForce[];
|
forces: FuturesForce[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FuturesTrianglePage: React.FC<FuturesTrianglePageProps> = ({ forces }) => {
|
export const FuturesTrianglePage: React.FC<FuturesTrianglePageProps> = ({ forces }) => (
|
||||||
return (
|
<div className="animate-in fade-in duration-300">
|
||||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
<PageHeader
|
||||||
{/* Clean Executive Header (Zero AI Slop) */}
|
title="مثلث آینده"
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-200">
|
description="سه دسته نیرویی که مسیر دانشگاه را تعیین میکنند: کشش آینده، فشارهای حال و وزن گذشته."
|
||||||
<div>
|
actions={
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<span className="tnum rounded-md border bg-muted/50 px-2.5 py-1.5 text-[12px] text-muted-foreground">
|
||||||
<span className="text-slate-400 text-xs font-bold">برنامهریزی راهبردی / تحلیل تعادل نیروها</span>
|
{toPersianDigits(forces.length)} نیرو
|
||||||
</div>
|
</span>
|
||||||
<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">
|
<TriangleView forces={forces} />
|
||||||
<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">
|
</div>
|
||||||
{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 React, { useState } from 'react';
|
||||||
import { University1415Dimension } from '../types/futures';
|
import { University1415Dimension } from '../types/futures';
|
||||||
import { SpiderChart } from '../components/visual/SpiderChart';
|
import { SpiderChart } from '../components/visual/SpiderChart';
|
||||||
|
import { PageHeader } from '../components/common/PageHeader';
|
||||||
|
import { StatCard } from '../components/common/StatCard';
|
||||||
import { toPersianDigits } from '../utils/persianNumbers';
|
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 {
|
import {
|
||||||
Target,
|
Table,
|
||||||
ArrowLeft,
|
TableBody,
|
||||||
AlertTriangle,
|
TableCell,
|
||||||
Award,
|
TableHead,
|
||||||
TrendingUp,
|
TableHeader,
|
||||||
Lightbulb,
|
TableRow
|
||||||
ArrowUpRight
|
} from '@/components/ui/table';
|
||||||
} from 'lucide-react';
|
import { Target, AlertTriangle, Award } from 'lucide-react';
|
||||||
|
|
||||||
interface GapAnalysisPageProps {
|
interface GapAnalysisPageProps {
|
||||||
dimensions: University1415Dimension[];
|
dimensions: University1415Dimension[];
|
||||||
|
|
@ -21,254 +28,186 @@ export const GapAnalysisPage: React.FC<GapAnalysisPageProps> = ({
|
||||||
dimensions,
|
dimensions,
|
||||||
onSelectDimension
|
onSelectDimension
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<'spider' | 'breakdown'>('spider');
|
const [tab, setTab] = useState<'spider' | 'table'>('spider');
|
||||||
|
|
||||||
// Calculations
|
if (!dimensions.length) return null;
|
||||||
const sortedByGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
|
||||||
const sortedByScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
|
|
||||||
|
|
||||||
const largestGaps = sortedByGap.slice(0, 4);
|
const byGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||||
const strongestDims = sortedByScore.slice(0, 3);
|
const byScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
|
||||||
|
|
||||||
const avgReadiness = Math.round(
|
const avgReadiness = Math.round(
|
||||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const widestGap = byGap[0];
|
||||||
|
const strongest = byScore[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-200">
|
title="تحلیل شکاف"
|
||||||
<div>
|
description={`فاصله وضع موجود دانشگاه تا هدف ${toPersianDigits(
|
||||||
<div className="flex items-center gap-2 mb-1">
|
HORIZON_END
|
||||||
<span className="badge badge-rose">سنجش بلوغ سازمانی</span>
|
)} در ۱۰ ویژگی.`}
|
||||||
<span className="text-slate-400 text-xs font-semibold">ارزیابی شکاف آمادگی افق ۱۴۱۵</span>
|
actions={
|
||||||
</div>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||||
<h1 className="page-title text-2xl lg:text-3xl text-slate-900">
|
<TabsList className="h-9">
|
||||||
تحلیل جامع شکاف راهبردی (Gap Analysis)
|
<TabsTrigger value="spider">نمودار عنکبوتی</TabsTrigger>
|
||||||
</h1>
|
<TabsTrigger value="table">جدول</TabsTrigger>
|
||||||
<p className="page-subtitle text-slate-500 text-xs mt-1">
|
</TabsList>
|
||||||
مقایسه چندبُعدی وضعیت موجود دانشگاه اصفهان با اهداف آرمانی دانشگاه آینده در سال ۱۴۱۵
|
</Tabs>
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Pill Tab Switcher */}
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<div className="flex items-center bg-slate-100 border border-slate-200 p-1 rounded-full text-xs shadow-sm">
|
<StatCard
|
||||||
<button
|
label="آمادگی کل"
|
||||||
onClick={() => setActiveTab('spider')}
|
value={`${toPersianDigits(avgReadiness)}٪`}
|
||||||
className={`px-4 py-1.5 rounded-full transition-all font-bold ${
|
meta={`فاصله تا هدف ${toPersianDigits(100 - avgReadiness)}٪`}
|
||||||
activeTab === 'spider'
|
icon={Target}
|
||||||
? 'bg-slate-900 text-white shadow-sm'
|
accent="var(--primary)"
|
||||||
: 'text-slate-600 hover:text-slate-900'
|
/>
|
||||||
}`}
|
<StatCard
|
||||||
>
|
label="بزرگترین شکاف"
|
||||||
نمودار عنکبوتی ۱۰ بعدی
|
value={toPersianDigits(widestGap.gap)}
|
||||||
</button>
|
meta={widestGap.title}
|
||||||
<button
|
icon={AlertTriangle}
|
||||||
onClick={() => setActiveTab('breakdown')}
|
accent="var(--negative)"
|
||||||
className={`px-4 py-1.5 rounded-full transition-all font-bold ${
|
onClick={() => onSelectDimension(widestGap)}
|
||||||
activeTab === 'breakdown'
|
/>
|
||||||
? 'bg-slate-900 text-white shadow-sm'
|
<StatCard
|
||||||
: 'text-slate-600 hover:text-slate-900'
|
label="دومین شکاف"
|
||||||
}`}
|
value={toPersianDigits(byGap[1].gap)}
|
||||||
>
|
meta={byGap[1].title}
|
||||||
ماتریس اولویتبندی مداخله
|
icon={AlertTriangle}
|
||||||
</button>
|
accent="var(--cat-policy)"
|
||||||
</div>
|
onClick={() => onSelectDimension(byGap[1])}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="قویترین بعد"
|
||||||
|
value={`${toPersianDigits(strongest.currentScore)}/۱۰`}
|
||||||
|
meta={strongest.title}
|
||||||
|
icon={Award}
|
||||||
|
accent="var(--positive)"
|
||||||
|
onClick={() => onSelectDimension(strongest)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards Row (Bento Pastel Cards matching Image 1 & 3) */}
|
{tab === 'spider' && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="mt-4 grid animate-in grid-cols-1 gap-4 fade-in duration-200 xl:grid-cols-12">
|
||||||
{/* Overall Readiness (Mint) */}
|
<Card data-card className="xl:col-span-8">
|
||||||
<div className="intel-card bento-card-mint p-5 flex flex-col justify-between">
|
<CardContent className="flex items-center justify-center p-3 sm:p-6">
|
||||||
<div className="flex items-center justify-between text-xs text-emerald-800 font-bold mb-2">
|
<SpiderChart
|
||||||
<span>شاخص کل آمادگی دانشگاه</span>
|
dimensions={dimensions}
|
||||||
<Target size={17} className="text-emerald-600" />
|
onSelectDimension={onSelectDimension}
|
||||||
</div>
|
height={540}
|
||||||
<div className="text-3xl font-black text-slate-900">
|
/>
|
||||||
{toPersianDigits(avgReadiness)}٪
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<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) */}
|
<Card data-card className="xl:col-span-4">
|
||||||
<div className="intel-card bento-card-pink p-5 flex flex-col justify-between">
|
<CardHeader className="border-b pb-3">
|
||||||
<div className="flex items-center justify-between text-xs text-rose-800 font-bold mb-1">
|
<CardTitle className="text-[14px]">رتبهبندی شکافها</CardTitle>
|
||||||
<span>بزرگترین شکاف عملکردی</span>
|
<p className="text-[11.5px] text-muted-foreground">
|
||||||
<AlertTriangle size={17} className="text-rose-600" />
|
از بیشترین فاصله تا کمترین.
|
||||||
</div>
|
</p>
|
||||||
<div className="text-sm font-extrabold text-slate-900 mt-1">
|
</CardHeader>
|
||||||
{largestGaps[0].title}
|
<CardContent className="pt-2">
|
||||||
</div>
|
<ul className="divide-y">
|
||||||
<div className="text-xl font-black text-rose-700 mt-1">
|
{byGap.map((dim, idx) => (
|
||||||
شکاف: {toPersianDigits(largestGaps[0].gap)} نمره
|
<li key={dim.id}>
|
||||||
</div>
|
<button
|
||||||
<span className="text-[11px] text-rose-800 font-semibold">
|
onClick={() => onSelectDimension(dim)}
|
||||||
آمادگی: {toPersianDigits(largestGaps[0].readinessPercentage)}٪
|
className="group flex w-full items-center gap-2.5 py-2 text-right"
|
||||||
</span>
|
>
|
||||||
</div>
|
<span className="tnum w-5 shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sortedByGap.map((dim, idx) => (
|
|
||||||
<div
|
|
||||||
key={dim.id}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{toPersianDigits(idx + 1)}
|
{toPersianDigits(idx + 1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-extrabold text-slate-800">{dim.shortTag}</span>
|
<span className="min-w-0 flex-1">
|
||||||
</div>
|
<span className="block truncate text-[12.5px] font-semibold transition-colors group-hover:text-primary">
|
||||||
|
{dim.shortTag}
|
||||||
<div className="flex items-center gap-3">
|
</span>
|
||||||
<span className="text-amber-700 font-bold">
|
<Progress
|
||||||
{toPersianDigits(dim.currentScore)}/۱۰
|
value={dim.readinessPercentage}
|
||||||
|
className="mt-1 h-1.5"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-rose-700 font-bold bg-rose-50 border border-rose-200 px-2 py-0.5 rounded-full text-[11px]">
|
<span
|
||||||
شکاف: {toPersianDigits(dim.gap)}
|
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>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 2: Detailed Matrix Table Breakdown */}
|
{tab === 'table' && (
|
||||||
{activeTab === 'breakdown' && (
|
<Card data-card className="mt-4 animate-in overflow-hidden p-0 fade-in duration-200">
|
||||||
<div className="intel-card overflow-x-auto p-0 bg-white border border-slate-200 rounded-3xl shadow-sm animate-fadeIn">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-right text-xs border-collapse">
|
<Table>
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr className="bg-slate-50 text-slate-500 border-b border-slate-200">
|
<TableRow>
|
||||||
<th className="p-4 font-bold">کلانویژگی دانشگاه ۱۴۱۵</th>
|
<TableHead className="text-right">ویژگی</TableHead>
|
||||||
<th className="p-4 font-bold">وضع موجود (۱-۱۰)</th>
|
<TableHead className="text-right">موجود</TableHead>
|
||||||
<th className="p-4 font-bold">هدف ۱۴۱۵ (۱-۱۰)</th>
|
<TableHead className="text-right">هدف</TableHead>
|
||||||
<th className="p-4 font-bold">میزان شکاف</th>
|
<TableHead className="text-right">شکاف</TableHead>
|
||||||
<th className="p-4 font-bold">درصد آمادگی</th>
|
<TableHead className="text-right">آمادگی</TableHead>
|
||||||
<th className="p-4 font-bold">اقدام راهبردی اولویتدار</th>
|
<TableHead className="text-right">اقدام پیشنهادی</TableHead>
|
||||||
<th className="p-4 font-bold">اقدام</th>
|
</TableRow>
|
||||||
</tr>
|
</TableHeader>
|
||||||
</thead>
|
<TableBody>
|
||||||
<tbody className="divide-y divide-slate-100">
|
{dimensions.map((dim) => (
|
||||||
{dimensions.map((dim) => (
|
<TableRow
|
||||||
<tr
|
key={dim.id}
|
||||||
key={dim.id}
|
onClick={() => onSelectDimension(dim)}
|
||||||
onClick={() => onSelectDimension(dim)}
|
className="cursor-pointer"
|
||||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
>
|
||||||
>
|
<TableCell className="font-semibold">
|
||||||
<td className="p-4 font-extrabold text-slate-900">
|
<span className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span
|
||||||
<span
|
className="cat-dot"
|
||||||
className="w-2.5 h-2.5 rounded-full"
|
style={{ background: dim.pillarColor }}
|
||||||
style={{ backgroundColor: dim.pillarColor }}
|
/>
|
||||||
></span>
|
{dim.title}
|
||||||
{dim.title}
|
</span>
|
||||||
</span>
|
</TableCell>
|
||||||
</td>
|
<TableCell className="tnum">
|
||||||
<td className="p-4 text-amber-700 font-bold">{toPersianDigits(dim.currentScore)}</td>
|
{toPersianDigits(dim.currentScore)}
|
||||||
<td className="p-4 text-teal-700 font-bold">{toPersianDigits(dim.targetScore)}</td>
|
</TableCell>
|
||||||
<td className="p-4 text-rose-700 font-extrabold">
|
<TableCell className="tnum text-primary">
|
||||||
{toPersianDigits(dim.gap)}
|
{toPersianDigits(dim.targetScore)}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="p-4">
|
<TableCell className="tnum font-semibold text-negative">
|
||||||
<div className="flex items-center gap-2">
|
{toPersianDigits(dim.gap)}
|
||||||
<div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
</TableCell>
|
||||||
<div
|
<TableCell>
|
||||||
className="h-full rounded-full"
|
<span className="flex items-center gap-2">
|
||||||
style={{
|
<Progress value={dim.readinessPercentage} className="h-1.5 w-16" />
|
||||||
width: `${dim.readinessPercentage}%`,
|
<span className="tnum text-[11.5px]">
|
||||||
backgroundColor: dim.pillarColor
|
{toPersianDigits(dim.readinessPercentage)}٪
|
||||||
}}
|
</span>
|
||||||
></div>
|
</span>
|
||||||
</div>
|
</TableCell>
|
||||||
<span className="text-slate-700 font-bold">{toPersianDigits(dim.readinessPercentage)}٪</span>
|
<TableCell className="max-w-sm text-muted-foreground">
|
||||||
</div>
|
<span className="line-clamp-1">
|
||||||
</td>
|
{dim.strategicRecommendations[0]}
|
||||||
<td className="p-4 text-slate-600 max-w-sm line-clamp-1">
|
</span>
|
||||||
{dim.strategicRecommendations[0]}
|
</TableCell>
|
||||||
</td>
|
</TableRow>
|
||||||
<td className="p-4">
|
))}
|
||||||
<button className="text-teal-600 font-bold hover:underline">
|
</TableBody>
|
||||||
جزئیات بعد ↗
|
</Table>
|
||||||
</button>
|
</div>
|
||||||
</td>
|
</Card>
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,30 +6,19 @@ import {
|
||||||
SkillEntity
|
SkillEntity
|
||||||
} from '../types/futures';
|
} from '../types/futures';
|
||||||
import { ImpactGauges } from '../components/visual/ImpactGauges';
|
import { ImpactGauges } from '../components/visual/ImpactGauges';
|
||||||
import {
|
import { toPersianDigits, getHorizonLabelFa } from '../utils/persianNumbers';
|
||||||
toPersianDigits,
|
import { CATEGORY_META } from '../utils/categories';
|
||||||
getCategoryLabelFa,
|
|
||||||
getCategoryColor,
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
getHorizonLabelFa
|
import { Button } from '@/components/ui/button';
|
||||||
} from '../utils/persianNumbers';
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Clock,
|
CheckCircle2,
|
||||||
Zap,
|
|
||||||
Globe,
|
|
||||||
HelpCircle,
|
|
||||||
FileText,
|
|
||||||
Building,
|
|
||||||
AlertOctagon,
|
AlertOctagon,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
CheckCircle2,
|
|
||||||
AlertTriangle,
|
|
||||||
Lightbulb,
|
|
||||||
Target,
|
Target,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Brain,
|
Brain
|
||||||
Layers,
|
|
||||||
ArrowUpRight
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ItemDetailPageProps {
|
interface ItemDetailPageProps {
|
||||||
|
|
@ -43,6 +32,12 @@ interface ItemDetailPageProps {
|
||||||
onSelectDimension: (dim: University1415Dimension) => void;
|
onSelectDimension: (dim: University1415Dimension) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONFIDENCE_LABELS: Record<string, string> = {
|
||||||
|
high: 'بالا',
|
||||||
|
medium: 'متوسط',
|
||||||
|
low: 'پایین'
|
||||||
|
};
|
||||||
|
|
||||||
export const ItemDetailPage: React.FC<ItemDetailPageProps> = ({
|
export const ItemDetailPage: React.FC<ItemDetailPageProps> = ({
|
||||||
item,
|
item,
|
||||||
allItems,
|
allItems,
|
||||||
|
|
@ -53,323 +48,324 @@ export const ItemDetailPage: React.FC<ItemDetailPageProps> = ({
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onSelectDimension
|
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 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 relatedJobs = jobs.filter((j) => item.relatedJobIds?.includes(j.id));
|
||||||
const relatedSkills = skills.filter((s) => item.relatedSkillIds?.includes(s.id));
|
const relatedSkills = skills.filter((s) => item.relatedSkillIds?.includes(s.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 animate-fadeIn pb-12">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Back Button & Top Navigation */}
|
<Button variant="ghost" size="sm" onClick={onBack} className="mb-3 -ms-2">
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-slate-200">
|
<ArrowRight />
|
||||||
<button
|
بازگشت
|
||||||
onClick={onBack}
|
</Button>
|
||||||
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="flex items-center gap-2">
|
{/* Overview */}
|
||||||
<span
|
<Card data-card className="mb-4 overflow-hidden p-0">
|
||||||
className="text-xs font-bold px-3 py-1 rounded-full"
|
<span
|
||||||
style={{
|
aria-hidden
|
||||||
backgroundColor: `${catColor}15`,
|
className="block h-0.5 w-full"
|
||||||
color: catColor
|
style={{ background: meta.color }}
|
||||||
}}
|
/>
|
||||||
>
|
<div className="p-5 sm:p-6">
|
||||||
{getCategoryLabelFa(item.category)}
|
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11.5px] text-muted-foreground">
|
||||||
</span>
|
<span
|
||||||
<span className="badge badge-gray font-mono">شناسه: {item.id}</span>
|
className="inline-flex items-center gap-1.5 font-semibold"
|
||||||
|
style={{ color: meta.color }}
|
||||||
|
>
|
||||||
|
<span className="cat-dot" style={{ background: meta.color }} />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span className="tnum">
|
||||||
|
شدت اثر {toPersianDigits(item.impactScore)}/۵
|
||||||
|
</span>
|
||||||
|
<span>{getHorizonLabelFa(item.horizon)}</span>
|
||||||
|
<span>{item.geographicScope}</span>
|
||||||
|
<span className="tnum">
|
||||||
|
عدم قطعیت {toPersianDigits(item.uncertaintyScore)}/۵
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-bold leading-relaxed sm:text-xl">{item.title}</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 rounded-lg border bg-muted/40 p-3.5 text-[12.5px] leading-relaxed">
|
||||||
|
{item.executiveSummary}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* 1. OVERVIEW SECTION (Bento Card) */}
|
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div
|
<Card data-card>
|
||||||
className="intel-card bg-white border border-slate-200 rounded-3xl p-6 lg:p-8 shadow-sm"
|
<CardHeader className="border-b pb-3">
|
||||||
style={{ borderRight: `5px solid ${catColor}` }}
|
<CardTitle className="text-[14px]">چرا اهمیت دارد؟</CardTitle>
|
||||||
>
|
</CardHeader>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-500 font-semibold mb-3">
|
<CardContent className="pt-4">
|
||||||
<span className="flex items-center gap-1.5 text-teal-700 font-bold">
|
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
||||||
<Zap size={14} className="text-teal-600" />
|
|
||||||
شدت اثر کلی: {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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-slate-900 font-black text-xl lg:text-2xl leading-tight mb-3">
|
|
||||||
{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>
|
|
||||||
{item.executiveSummary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
{item.whyItMatters}
|
{item.whyItMatters}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="p-3 bg-teal-50 border border-teal-200 rounded-xl text-[11.5px] text-teal-800 font-medium">
|
</Card>
|
||||||
نقطه عطف: این پدیده ساختارهای یادگیری و سنجش را دگرگون ساخته و انفعال در برابر آن موجب افت تقاضای داوطلبان خواهد شد.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="intel-card bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between gap-3">
|
<Card data-card>
|
||||||
<div>
|
<CardHeader className="border-b pb-3">
|
||||||
<div className="flex items-center gap-2 text-sky-700 font-extrabold text-sm mb-2">
|
<CardTitle className="text-[14px]">ارتباط با دانشگاه اصفهان</CardTitle>
|
||||||
<Building size={17} className="text-sky-600" />
|
</CardHeader>
|
||||||
<span>ارتباط مستقیم با وضعیت دانشگاه اصفهان</span>
|
<CardContent className="flex flex-col gap-3 pt-4">
|
||||||
</div>
|
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
||||||
<p className="text-slate-600 text-xs leading-relaxed">
|
|
||||||
{item.relevanceToUniversity}
|
{item.relevanceToUniversity}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{item.implications.affectedDepartments.length > 0 && (
|
||||||
<div className="p-3 bg-sky-50 border border-sky-200 rounded-xl text-[11.5px] text-sky-800 font-medium">
|
<div className="flex flex-wrap gap-1">
|
||||||
دانشکدهها و واحدهای درگیر: {item.implications.affectedDepartments.join('، ')}
|
{item.implications.affectedDepartments.map((dept, i) => (
|
||||||
</div>
|
<span
|
||||||
</div>
|
key={i}
|
||||||
</div>
|
className="rounded border bg-muted/60 px-1.5 py-0.5 text-[10.5px] text-muted-foreground"
|
||||||
|
>
|
||||||
{/* 3. MULTI-DIMENSIONAL IMPACT MODEL & TIME HORIZON */}
|
{dept}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
</span>
|
||||||
{/* 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImpactGauges impact={item.impact} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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="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>
|
|
||||||
</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.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>
|
</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">
|
</CardContent>
|
||||||
«{ev.snippet}»
|
</Card>
|
||||||
</p>
|
</div>
|
||||||
</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} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 5. STRATEGIC TRACEABILITY */}
|
{item.evidence.length > 0 && (
|
||||||
<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">
|
<Card data-card className="mb-4">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
<CardHeader className="border-b pb-3">
|
||||||
<h3 className="text-slate-900 font-black text-sm flex items-center gap-2">
|
<CardTitle className="text-[14px]">شواهد و منابع</CardTitle>
|
||||||
<Layers size={17} className="text-teal-600" />
|
<p className="tnum text-[11.5px] text-muted-foreground">
|
||||||
<span>زنجیره ارتباطات راهبردی و ردپای اثر (Strategic Traceability)</span>
|
{toPersianDigits(item.evidence.length)} منبع
|
||||||
</h3>
|
</p>
|
||||||
<span className="text-slate-400 text-xs">اتصالات پایگاه داده هوشمندی</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="grid grid-cols-1 gap-3 pt-4 md:grid-cols-2">
|
||||||
|
{item.evidence.map((ev, idx) => (
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 text-xs">
|
{/* Traceability */}
|
||||||
{/* Linked 1415 Dimensions */}
|
<Card data-card>
|
||||||
<div className="flex flex-col gap-2.5">
|
<CardHeader className="border-b pb-3">
|
||||||
<div className="text-teal-700 font-bold flex items-center gap-1.5">
|
<CardTitle className="text-[14px]">ارتباطات</CardTitle>
|
||||||
<Target size={14} />
|
<p className="text-[11.5px] text-muted-foreground">
|
||||||
ابعاد مرتبط در دانشگاه اصفهان ۱۴۱۵:
|
اتصال این مورد به ویژگیهای ۱۴۱۵، مشاغل و مهارتها.
|
||||||
</div>
|
</p>
|
||||||
<div className="flex flex-col gap-2">
|
</CardHeader>
|
||||||
{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}
|
key={dim.id}
|
||||||
onClick={() => onSelectDimension(dim)}
|
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="truncate">{dim.title}</span>
|
||||||
<span className="text-rose-600 font-bold text-[11px]">شکاف: {toPersianDigits(dim.gap)}</span>
|
<span className="tnum shrink-0 text-[11px] text-negative">
|
||||||
</div>
|
شکاف {toPersianDigits(dim.gap)}
|
||||||
))}
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
))
|
||||||
|
) : (
|
||||||
|
<Empty />
|
||||||
|
)}
|
||||||
|
</LinkColumn>
|
||||||
|
|
||||||
{/* Linked Jobs */}
|
<LinkColumn title="مشاغل" icon={Briefcase}>
|
||||||
<div className="flex flex-col gap-2.5">
|
{relatedJobs.length ? (
|
||||||
<div className="text-amber-700 font-bold flex items-center gap-1.5">
|
relatedJobs.map((job) => (
|
||||||
<Briefcase size={14} />
|
|
||||||
مشاغل تحت تأثیر:
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{relatedJobs.map((job) => (
|
|
||||||
<div
|
<div
|
||||||
key={job.id}
|
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="truncate">{job.title}</span>
|
||||||
<span className={`badge ${job.type === 'growing' ? 'badge-teal' : 'badge-rose'}`}>
|
<span
|
||||||
{job.type === 'growing' ? 'صعودی' : 'در تحول'}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
{relatedJobs.length === 0 && (
|
) : (
|
||||||
<span className="text-slate-400 text-[11px] italic">مشاغل عمومی آموزش عالی</span>
|
<Empty />
|
||||||
)}
|
)}
|
||||||
</div>
|
</LinkColumn>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Linked Skills */}
|
<LinkColumn title="مهارتها" icon={Brain}>
|
||||||
<div className="flex flex-col gap-2.5">
|
{relatedSkills.length ? (
|
||||||
<div className="text-purple-700 font-bold flex items-center gap-1.5">
|
relatedSkills.map((sk) => (
|
||||||
<Brain size={14} />
|
|
||||||
شایستگیهای موردنیاز:
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{relatedSkills.map((sk) => (
|
|
||||||
<div
|
<div
|
||||||
key={sk.id}
|
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="truncate">{sk.title}</span>
|
||||||
<span className="text-slate-500 text-[11px]">اهمیت: {toPersianDigits(sk.importanceScore)}/۵</span>
|
<span className="tnum shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{toPersianDigits(sk.importanceScore)}/۵
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
{relatedSkills.length === 0 && (
|
) : (
|
||||||
<span className="text-slate-400 text-[11px] italic">مهارتهای تفکر سیستمی و دیجیتال</span>
|
<Empty />
|
||||||
)}
|
)}
|
||||||
</div>
|
</LinkColumn>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other Related Trends / Tech */}
|
|
||||||
{relatedItems.length > 0 && (
|
{relatedItems.length > 0 && (
|
||||||
<div className="pt-4 border-t border-slate-100 flex flex-col gap-2">
|
<CardContent className="border-t pt-4">
|
||||||
<span className="text-slate-500 text-xs font-bold">سایر موارد همبسته در رادار:</span>
|
<div className="mb-2 text-[12px] font-semibold text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-2">
|
موارد همبسته در رادار
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{relatedItems.map((rItem) => (
|
{relatedItems.map((rItem) => (
|
||||||
<button
|
<button
|
||||||
key={rItem.id}
|
key={rItem.id}
|
||||||
onClick={() => onSelectItem(rItem)}
|
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>
|
<span
|
||||||
<ArrowRight size={12} className="text-teal-600" />
|
className="cat-dot"
|
||||||
|
style={{ background: CATEGORY_META[rItem.category].color }}
|
||||||
|
/>
|
||||||
|
{rItem.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</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 { JobEntity, SkillEntity, Horizon } from '../types/futures';
|
||||||
import { JobCard, SkillCard } from '../components/cards/JobSkillCard';
|
import { JobCard, SkillCard } from '../components/cards/JobSkillCard';
|
||||||
import { LaborCharts } from '../components/visual/LaborCharts';
|
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 {
|
import {
|
||||||
Briefcase,
|
Select,
|
||||||
TrendingUp,
|
SelectContent,
|
||||||
AlertTriangle,
|
SelectItem,
|
||||||
Brain,
|
SelectTrigger,
|
||||||
Filter,
|
SelectValue
|
||||||
Search,
|
} from '@/components/ui/select';
|
||||||
School,
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
Sparkles
|
import { Search, X } from 'lucide-react';
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface LaborMarketPageProps {
|
interface LaborMarketPageProps {
|
||||||
jobs: JobEntity[];
|
jobs: JobEntity[];
|
||||||
skills: SkillEntity[];
|
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 }) => {
|
export const LaborMarketPage: React.FC<LaborMarketPageProps> = ({ jobs, skills }) => {
|
||||||
const [activeQuadrant, setActiveQuadrant] = useState<
|
const [scope, setScope] = useState<'global' | 'local'>('global');
|
||||||
'all' | 'growing_jobs' | 'transforming_jobs' | 'growing_skills' | 'declining_skills'
|
const [bucket, setBucket] = useState<BucketKey | 'all'>('all');
|
||||||
>('all');
|
const [discipline, setDiscipline] = useState('all');
|
||||||
const [selectedDiscipline, setSelectedDiscipline] = useState<string>('all');
|
const [horizon, setHorizon] = useState<Horizon | 'all'>('all');
|
||||||
const [selectedHorizon, setSelectedHorizon] = useState<Horizon | 'all'>('all');
|
const [query, setQuery] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
|
|
||||||
// Extract unique disciplines
|
const disciplines = useMemo(
|
||||||
const disciplines = useMemo(() => {
|
() => Array.from(new Set(jobs.map((j) => j.discipline).filter(Boolean))),
|
||||||
const set = new Set<string>();
|
[jobs]
|
||||||
jobs.forEach((j) => {
|
);
|
||||||
if (j.discipline) set.add(j.discipline);
|
|
||||||
});
|
|
||||||
return Array.from(set);
|
|
||||||
}, [jobs]);
|
|
||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
const q = query.toLowerCase().trim();
|
||||||
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 filteredSkills = useMemo(() => {
|
const filteredJobs = useMemo(
|
||||||
return skills.filter((s) => {
|
() =>
|
||||||
if (searchQuery.trim() !== '') {
|
jobs.filter((j) => {
|
||||||
const q = searchQuery.toLowerCase().trim();
|
if (horizon !== 'all' && j.horizon !== horizon) return false;
|
||||||
const inTitle = s.title.toLowerCase().includes(q);
|
if (discipline !== 'all' && j.discipline !== discipline) return false;
|
||||||
const inDesc = s.description.toLowerCase().includes(q);
|
if (!q) return true;
|
||||||
if (!inTitle && !inDesc) return false;
|
return (
|
||||||
}
|
j.title.toLowerCase().includes(q) ||
|
||||||
return true;
|
j.description.toLowerCase().includes(q) ||
|
||||||
});
|
j.discipline.toLowerCase().includes(q)
|
||||||
}, [skills, searchQuery]);
|
);
|
||||||
|
}),
|
||||||
|
[jobs, horizon, discipline, q]
|
||||||
|
);
|
||||||
|
|
||||||
const growingJobs = filteredJobs.filter((j) => j.type === 'growing');
|
const filteredSkills = useMemo(
|
||||||
const transformingJobs = filteredJobs.filter((j) => j.type === 'transforming');
|
() =>
|
||||||
const growingSkills = filteredSkills.filter((s) => s.type === 'growing');
|
skills.filter((s) => {
|
||||||
const decliningSkills = filteredSkills.filter((s) => s.type === 'declining');
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
title="بازار کار آینده"
|
||||||
<div>
|
description="مشاغل و مهارتهایی که تقاضای بازار کار برای آنها در حال رشد یا کاهش است."
|
||||||
<div className="flex items-center gap-2 mb-1">
|
actions={
|
||||||
<span className="badge badge-amber">تحلیل اشتغالپذیری و آمایش رشتهها</span>
|
<Tabs value={scope} onValueChange={(v) => setScope(v as typeof scope)}>
|
||||||
<span className="text-slate-400 text-xs">دیدهبانی مهارتهای نوظهور</span>
|
<TabsList className="h-9">
|
||||||
|
<TabsTrigger value="global">چشمانداز جهانی</TabsTrigger>
|
||||||
|
<TabsTrigger value="local">دانشگاه اصفهان</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{scope === 'global' && <GlobalOutlook />}
|
||||||
|
|
||||||
|
{scope === 'local' && (
|
||||||
|
<>
|
||||||
|
<LaborCharts jobs={jobs} skills={skills} />
|
||||||
|
|
||||||
|
{/* 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="همه"
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||||
|
<X />
|
||||||
|
پاککردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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
|
||||||
|
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} />
|
||||||
|
))
|
||||||
|
: (list as SkillEntity[]).map((skill) => (
|
||||||
|
<SkillCard key={skill.id} skill={skill} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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="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>
|
|
||||||
|
|
||||||
{/* Analytical Charts Section */}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<Search size={14} className="absolute right-2.5 top-2.5 text-slate-400" />
|
|
||||||
</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}>
|
|
||||||
{d}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
مشاغل سنتی با ریسک اتوماسیون بالا که برای بقا نیازمند بازطراحی مهارتی و همآفرینی با فناوری هستند
|
|
||||||
</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) => (
|
|
||||||
<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) => (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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 React, { useState } from 'react';
|
||||||
import { authService, PRESET_USERS } from '../services/authService';
|
import { authService } from '../services/authService';
|
||||||
import { PersonaType } from '../types/futures';
|
|
||||||
import { ForgotPasswordModal } from '../components/modals/ForgotPasswordModal';
|
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 {
|
interface LoginProps {
|
||||||
onLoginSuccess: () => void;
|
onLoginSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||||
const [activeTab, setActiveTab] = useState<'password' | 'otp' | 'persona'>('password');
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [mobileNumber, setMobileNumber] = useState('');
|
const [mobileNumber, setMobileNumber] = useState('');
|
||||||
|
|
@ -18,355 +26,201 @@ export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||||
const [simulatedCode, setSimulatedCode] = useState('');
|
const [simulatedCode, setSimulatedCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
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();
|
e.preventDefault();
|
||||||
if (!username.trim()) return;
|
if (!username.trim()) return;
|
||||||
setLoading(true);
|
run(async () => {
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await authService.loginWithCredentials(username, password);
|
await authService.loginWithCredentials(username, password);
|
||||||
setLoading(false);
|
|
||||||
onLoginSuccess();
|
onLoginSuccess();
|
||||||
} catch (err: any) {
|
}, 'خطا در ورود');
|
||||||
setLoading(false);
|
|
||||||
setErrorMsg(err.message || 'خطا در ورود');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendOtp = async (e: React.FormEvent) => {
|
const handleSendOtp = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!mobileNumber.trim()) return;
|
if (!mobileNumber.trim()) return;
|
||||||
setLoading(true);
|
run(async () => {
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
const res = await authService.sendOtp(mobileNumber);
|
const res = await authService.sendOtp(mobileNumber);
|
||||||
setOtpSent(true);
|
|
||||||
setSimulatedCode(res.simulatedOtp);
|
setSimulatedCode(res.simulatedOtp);
|
||||||
setLoading(false);
|
setOtpSent(true);
|
||||||
} catch (err: any) {
|
}, 'خطا در ارسال کد');
|
||||||
setLoading(false);
|
|
||||||
setErrorMsg('خطا در ارسال پیامک کد یکبار مصرف');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
const handleVerifyOtp = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!otpCode.trim()) return;
|
if (!otpCode.trim()) return;
|
||||||
setLoading(true);
|
run(async () => {
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await authService.verifyOtp(otpCode);
|
await authService.verifyOtp(otpCode);
|
||||||
setLoading(false);
|
|
||||||
onLoginSuccess();
|
onLoginSuccess();
|
||||||
} catch (err: any) {
|
}, 'کد وارد شده نامعتبر است');
|
||||||
setLoading(false);
|
|
||||||
setErrorMsg(err.message || 'کد وارد شده نامعتبر است');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickPersonaLogin = (role: PersonaType) => {
|
|
||||||
authService.switchPersona(role);
|
|
||||||
onLoginSuccess();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full flex items-center justify-center p-4 bg-radial-gradient">
|
<div className="flex min-h-svh w-full items-center justify-center bg-background p-4">
|
||||||
<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">
|
<div className="w-full max-w-sm">
|
||||||
{/* Institutional Emblem & Title */}
|
{/* Brand */}
|
||||||
<div className="flex flex-col items-center text-center mb-6">
|
<div className="mb-6 flex flex-col items-center text-center">
|
||||||
<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">
|
<span className="mb-3 flex size-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
<Compass size={32} className="text-teal-400" />
|
<Compass className="size-6" strokeWidth={2} />
|
||||||
</div>
|
</span>
|
||||||
<h2 className="text-white font-black text-xl lg:text-2xl tracking-tight">
|
<h1 className="text-lg font-bold">رادار آینده دانشگاه اصفهان</h1>
|
||||||
رادار آینده دانشگاه اصفهان
|
<p className="tnum mt-1 text-[12px] text-muted-foreground">
|
||||||
</h2>
|
افق {toPersianDigits(HORIZON_START)} تا {toPersianDigits(HORIZON_END)}
|
||||||
<p className="text-slate-400 text-xs mt-1">
|
|
||||||
سامانه پایش هوشمندی راهبردی و آیندهپژوهی آموزش عالی
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tab Switcher */}
|
<Card data-card>
|
||||||
<div className="grid grid-cols-3 gap-1 bg-[#081020] p-1 rounded-xl mb-5 text-xs font-semibold">
|
<CardContent className="p-5">
|
||||||
<button
|
<Tabs
|
||||||
onClick={() => {
|
defaultValue="password"
|
||||||
setActiveTab('password');
|
onValueChange={() => setErrorMsg('')}
|
||||||
setErrorMsg('');
|
className="gap-4"
|
||||||
}}
|
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
کد یکبار مصرف
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-slate-500">
|
|
||||||
(راهنما: برای ورود با هر نام کاربری میتوانید تست نمایید)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 text-right">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 font-semibold">رمز عبور:</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowForgotModal(true)}
|
|
||||||
className="text-[11px] text-teal-400 hover:underline"
|
|
||||||
>
|
|
||||||
فراموشی رمز عبور؟
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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 ? (
|
<TabsList className="w-full">
|
||||||
<span>در حال اعتبارسنجی...</span>
|
<TabsTrigger value="password" className="flex-1">
|
||||||
) : (
|
رمز عبور
|
||||||
<>
|
</TabsTrigger>
|
||||||
<KeyRound size={15} />
|
<TabsTrigger value="otp" className="flex-1">
|
||||||
<span>ورود امن به سامانه آیندهپژوهی</span>
|
کد یکبار مصرف
|
||||||
</>
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle />
|
||||||
|
<AlertDescription>{errorMsg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab 2: OTP Login Flow */}
|
<TabsContent value="password">
|
||||||
{activeTab === 'otp' && (
|
<form onSubmit={handlePasswordLogin} className="flex flex-col gap-3.5">
|
||||||
<div className="flex flex-col gap-4 text-xs text-right">
|
<div className="flex flex-col gap-1.5">
|
||||||
{!otpSent ? (
|
<Label htmlFor="username">نام کاربری</Label>
|
||||||
<form onSubmit={handleSendOtp} className="flex flex-col gap-4">
|
<Input
|
||||||
<p className="text-slate-300 leading-relaxed text-[11.5px]">
|
id="username"
|
||||||
کد ۵ رقمی یکبار مصرف به شماره تلفن همراه ثبتشده شما در اتوماسیون دانشگاه ارسال خواهد شد.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-slate-300 font-semibold">شماره تلفن همراه:</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
required
|
required
|
||||||
value={mobileNumber}
|
value={username}
|
||||||
onChange={(e) => setMobileNumber(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="0913xxxxxxx"
|
placeholder="president"
|
||||||
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"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
<Smartphone size={16} className="absolute left-3 top-3 text-slate-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div className="flex flex-col gap-1.5">
|
||||||
type="submit"
|
<div className="flex items-center justify-between">
|
||||||
disabled={loading}
|
<Label htmlFor="password">رمز عبور</Label>
|
||||||
className="btn btn-primary w-full py-3 mt-2 font-bold text-xs"
|
<button
|
||||||
>
|
type="button"
|
||||||
{loading ? 'در حال ارسال...' : 'ارسال کد تأیید پیامکی'}
|
onClick={() => setShowForgot(true)}
|
||||||
</button>
|
className="text-[11.5px] text-primary hover:underline"
|
||||||
</form>
|
>
|
||||||
) : (
|
فراموشی رمز عبور؟
|
||||||
<form onSubmit={handleVerifyOtp} className="flex flex-col gap-4 animate-fadeIn">
|
</button>
|
||||||
<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">
|
</div>
|
||||||
<CheckCircle size={16} className="shrink-0 text-teal-400" />
|
<Input
|
||||||
<div>
|
id="password"
|
||||||
کد یکبار مصرف شبیهسازی شده آزمایشی:{' '}
|
type="password"
|
||||||
<strong className="font-mono text-white text-sm bg-teal-900/60 px-2 py-0.5 rounded">
|
value={password}
|
||||||
{simulatedCode}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</strong>
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Button type="submit" disabled={loading} className="mt-1 w-full">
|
||||||
<label className="text-slate-300 font-semibold">کد تأیید پیامکشده:</label>
|
{loading && <Loader2 className="animate-spin" />}
|
||||||
<input
|
ورود
|
||||||
type="text"
|
</Button>
|
||||||
required
|
</form>
|
||||||
maxLength={5}
|
</TabsContent>
|
||||||
autoFocus
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<TabsContent value="otp">
|
||||||
type="submit"
|
{!otpSent ? (
|
||||||
disabled={loading}
|
<form onSubmit={handleSendOtp} className="flex flex-col gap-3.5">
|
||||||
className="btn btn-primary w-full py-3 font-bold text-xs"
|
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||||
>
|
کد پنجرقمی به شماره ثبتشده شما پیامک میشود.
|
||||||
{loading ? 'در حال بررسی...' : 'تأیید کد و ورود به سامانه'}
|
</p>
|
||||||
</button>
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="mobile">شماره تلفن همراه</Label>
|
||||||
|
<Input
|
||||||
|
id="mobile"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
dir="ltr"
|
||||||
|
value={mobileNumber}
|
||||||
|
onChange={(e) => setMobileNumber(e.target.value)}
|
||||||
|
placeholder="09130000000"
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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-3.5">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="tnum">
|
||||||
|
کد آزمایشی:{' '}
|
||||||
|
<b className="font-semibold text-foreground">{simulatedCode}</b>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<button
|
<div className="flex flex-col gap-1.5">
|
||||||
type="button"
|
<Label htmlFor="otp">کد تأیید</Label>
|
||||||
onClick={() => setOtpSent(false)}
|
<Input
|
||||||
className="text-slate-400 hover:text-white text-center text-[11px] mt-1"
|
id="otp"
|
||||||
>
|
required
|
||||||
ویرایش شماره تلفن همراه
|
maxLength={5}
|
||||||
</button>
|
autoFocus
|
||||||
</form>
|
dir="ltr"
|
||||||
)}
|
value={otpCode}
|
||||||
</div>
|
onChange={(e) => setOtpCode(e.target.value)}
|
||||||
)}
|
className="text-center tracking-[0.4em]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab 3: Quick Persona Selection (For University Decision-Makers Testing) */}
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{activeTab === 'persona' && (
|
{loading && <Loader2 className="animate-spin" />}
|
||||||
<div className="flex flex-col gap-2.5 animate-fadeIn">
|
تأیید و ورود
|
||||||
<p className="text-slate-400 text-[11px] mb-1 text-right">
|
</Button>
|
||||||
جهت ارزیابی فوری پلتفرم، یکی از نقشهای سازمانی زیر را انتخاب فرمایید:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Persona 1: President */}
|
<Button
|
||||||
<button
|
type="button"
|
||||||
onClick={() => handleQuickPersonaLogin('president')}
|
variant="ghost"
|
||||||
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"
|
size="sm"
|
||||||
>
|
onClick={() => setOtpSent(false)}
|
||||||
<div>
|
>
|
||||||
<div className="font-bold text-white text-xs group-hover:text-teal-300">
|
ویرایش شماره
|
||||||
{PRESET_USERS.president.fullName}
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
<div className="text-[11px] text-teal-400 mt-0.5">
|
)}
|
||||||
{PRESET_USERS.president.roleTitleFa}
|
</TabsContent>
|
||||||
</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 */}
|
</Tabs>
|
||||||
<button
|
</CardContent>
|
||||||
onClick={() => handleQuickPersonaLogin('researcher')}
|
</Card>
|
||||||
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 */}
|
<p className="mt-5 text-center text-[11px] text-muted-foreground">
|
||||||
<button
|
مرکز رصد فناوری و آیندهپژوهی دانشگاه اصفهان
|
||||||
onClick={() => handleQuickPersonaLogin('planner')}
|
</p>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<ForgotPasswordModal
|
<ForgotPasswordModal isOpen={showForgot} onClose={() => setShowForgot(false)} />
|
||||||
isOpen={showForgotModal}
|
|
||||||
onClose={() => setShowForgotModal(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,30 @@ import React, { useState, useMemo } from 'react';
|
||||||
import { IntelligenceItem, Category, Horizon } from '../types/futures';
|
import { IntelligenceItem, Category, Horizon } from '../types/futures';
|
||||||
import { FuturesRadar } from '../components/visual/FuturesRadar';
|
import { FuturesRadar } from '../components/visual/FuturesRadar';
|
||||||
import { IntelligenceCard } from '../components/cards/IntelligenceCard';
|
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 {
|
import {
|
||||||
toPersianDigits,
|
|
||||||
getCategoryLabelFa,
|
|
||||||
getHorizonLabelFa,
|
|
||||||
getCategoryColor
|
|
||||||
} from '../utils/persianNumbers';
|
|
||||||
import {
|
|
||||||
Radar,
|
|
||||||
Grid,
|
|
||||||
Table,
|
Table,
|
||||||
Search,
|
TableBody,
|
||||||
Clock,
|
TableCell,
|
||||||
Zap,
|
TableHead,
|
||||||
Filter,
|
TableHeader,
|
||||||
Plus
|
TableRow
|
||||||
} from 'lucide-react';
|
} 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 {
|
interface RadarPageProps {
|
||||||
items: IntelligenceItem[];
|
items: IntelligenceItem[];
|
||||||
|
|
@ -26,312 +34,272 @@ interface RadarPageProps {
|
||||||
initialCategory?: Category | 'all';
|
initialCategory?: Category | 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HORIZON_OPTIONS: (Horizon | 'all')[] = ['all', 'now', 'near', 'mid', 'long'];
|
||||||
|
|
||||||
export const RadarPage: React.FC<RadarPageProps> = ({
|
export const RadarPage: React.FC<RadarPageProps> = ({
|
||||||
items,
|
items,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onOpenCreateModal,
|
onOpenCreateModal,
|
||||||
initialCategory = 'all'
|
initialCategory = 'all'
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>(initialCategory);
|
const [category, setCategory] = useState<Category | 'all'>(initialCategory);
|
||||||
const [selectedHorizon, setSelectedHorizon] = useState<Horizon | 'all'>('all');
|
const [horizon, setHorizon] = useState<Horizon | 'all'>('all');
|
||||||
const [minImpact, setMinImpact] = useState<number>(1);
|
const [minImpact, setMinImpact] = useState(1);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [query, setQuery] = useState('');
|
||||||
const [viewMode, setViewMode] = useState<'radar' | 'grid' | 'table'>('radar');
|
const [view, setView] = useState<'radar' | 'grid' | 'table'>('radar');
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filtered = useMemo(
|
||||||
return items.filter((item) => {
|
() =>
|
||||||
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
|
items.filter((item) => {
|
||||||
if (selectedHorizon !== 'all' && item.horizon !== selectedHorizon) return false;
|
if (category !== 'all' && item.category !== category) return false;
|
||||||
if (item.impactScore < minImpact) return false;
|
if (horizon !== 'all' && item.horizon !== horizon) return false;
|
||||||
if (searchQuery.trim() !== '') {
|
if (item.impactScore < minImpact) return false;
|
||||||
const q = searchQuery.toLowerCase().trim();
|
if (query.trim()) {
|
||||||
const inTitle = item.title.toLowerCase().includes(q);
|
const q = query.toLowerCase().trim();
|
||||||
const inSummary = item.executiveSummary.toLowerCase().includes(q);
|
return (
|
||||||
const inWhy = item.whyItMatters.toLowerCase().includes(q);
|
item.title.toLowerCase().includes(q) ||
|
||||||
if (!inTitle && !inSummary && !inWhy) return false;
|
item.executiveSummary.toLowerCase().includes(q) ||
|
||||||
}
|
item.whyItMatters.toLowerCase().includes(q)
|
||||||
return true;
|
);
|
||||||
});
|
}
|
||||||
}, [items, selectedCategory, selectedHorizon, minImpact, searchQuery]);
|
return true;
|
||||||
|
}),
|
||||||
|
[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 (
|
return (
|
||||||
<div className="flex flex-col gap-6 animate-fadeIn">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Page Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-200">
|
title="رادار آینده"
|
||||||
<div>
|
description="روندها، فناوریها، سیاستها و سیگنالهای آینده مؤثر بر آموزش عالی."
|
||||||
<div className="flex items-center gap-2 mb-1">
|
actions={
|
||||||
<span className="badge badge-teal">سامانه رصد مستمر</span>
|
<>
|
||||||
<span className="text-slate-400 text-xs font-medium">دیدهبانی آینده آموزش عالی</span>
|
<Tabs value={view} onValueChange={(v) => setView(v as typeof view)}>
|
||||||
</div>
|
<TabsList className="h-9">
|
||||||
<h1 className="page-title text-2xl lg:text-3xl text-slate-900">
|
<TabsTrigger value="radar" className="gap-1.5">
|
||||||
رادار آینده دانشگاه (Futures Radar)
|
<RadarIcon className="size-3.5" />
|
||||||
</h1>
|
<span className="hidden sm:inline">رادار</span>
|
||||||
<p className="page-subtitle text-slate-500 text-xs mt-1">
|
</TabsTrigger>
|
||||||
دیدهبانی جامع و پایش چندبُعدی روندها، فناوریها، سیاستها و سیگنالهای ضعیف تغییر
|
<TabsTrigger value="grid" className="gap-1.5">
|
||||||
</p>
|
<LayoutGrid className="size-3.5" />
|
||||||
</div>
|
<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">
|
<Button size="sm" onClick={onOpenCreateModal}>
|
||||||
{/* View Mode Toggle (Pill Switcher) */}
|
<Plus />
|
||||||
<div className="flex items-center bg-slate-100 border border-slate-200 p-1 rounded-full text-xs shadow-sm">
|
<span className="hidden sm:inline">ثبت مورد</span>
|
||||||
<button
|
</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
|
{/* Filters */}
|
||||||
onClick={onOpenCreateModal}
|
<Card data-card className="mb-4">
|
||||||
className="btn btn-primary text-xs py-2 px-4 flex items-center gap-1.5 shadow-sm"
|
<CardContent className="flex flex-col gap-3 p-3.5">
|
||||||
>
|
<div className="scroll-x">
|
||||||
<Plus size={15} />
|
<div className="flex items-center gap-1.5">
|
||||||
<span>ثبت سیگنال جدید</span>
|
<FilterChip
|
||||||
</button>
|
active={category === 'all'}
|
||||||
</div>
|
onClick={() => setCategory('all')}
|
||||||
</div>
|
label={`همه (${toPersianDigits(items.length)})`}
|
||||||
|
/>
|
||||||
{/* Analytical Filter Controls Bar (Bento White Card) */}
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
<div className="intel-card p-5 bg-white border border-slate-200 rounded-3xl shadow-sm flex flex-col gap-4">
|
const meta = CATEGORY_META[cat];
|
||||||
{/* Category Pills */}
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<FilterChip
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
key={cat}
|
||||||
<span className="text-slate-500 font-bold flex items-center gap-1 ml-1">
|
active={category === cat}
|
||||||
<Filter size={13} />
|
onClick={() => setCategory(cat)}
|
||||||
دستهبندی موضوعی:
|
label={meta.label}
|
||||||
</span>
|
color={meta.color}
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<Search size={14} className="absolute right-3 top-2.5 text-slate-400" />
|
|
||||||
</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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{h === 'all' ? 'همه افقها' : getHorizonLabelFa(h)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<FuturesRadar
|
|
||||||
items={filteredItems}
|
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
selectedHorizon={selectedHorizon}
|
|
||||||
onSelectItem={onSelectItem}
|
|
||||||
height={680}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{filteredItems.length === 0 && (
|
|
||||||
<div className="col-span-full py-16 text-center text-slate-400 text-sm">
|
|
||||||
هیچ موردی منطبق با فیلترهای انتخابی یافت نشد.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<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)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||||
|
<X />
|
||||||
|
پاککردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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={filtered}
|
||||||
|
selectedCategory={category}
|
||||||
|
selectedHorizon={horizon}
|
||||||
|
onSelectItem={onSelectItem}
|
||||||
|
height={560}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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)} />
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <EmptyState onReset={resetFilters} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View Mode 3: Matrix Table */}
|
{view === 'table' && (
|
||||||
{viewMode === 'table' && (
|
<Card data-card className="animate-in overflow-hidden p-0 fade-in duration-200">
|
||||||
<div className="intel-card overflow-x-auto p-0 bg-white border border-slate-200 rounded-3xl shadow-sm animate-fadeIn">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-right text-xs border-collapse">
|
<Table>
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr className="bg-slate-50 text-slate-500 border-b border-slate-200">
|
<TableRow>
|
||||||
<th className="p-4 font-bold">عنوان پدیده / سیگنال</th>
|
<TableHead className="text-right">عنوان</TableHead>
|
||||||
<th className="p-4 font-bold">دستهبندی</th>
|
<TableHead className="text-right">دسته</TableHead>
|
||||||
<th className="p-4 font-bold">افق زمانی</th>
|
<TableHead className="text-right">افق</TableHead>
|
||||||
<th className="p-4 font-bold">شدت اثر</th>
|
<TableHead className="text-right">شدت اثر</TableHead>
|
||||||
<th className="p-4 font-bold">عدم قطعیت</th>
|
<TableHead className="text-right">عدم قطعیت</TableHead>
|
||||||
<th className="p-4 font-bold">دامنه</th>
|
<TableHead className="text-right">دامنه</TableHead>
|
||||||
<th className="p-4 font-bold">وضعیت</th>
|
</TableRow>
|
||||||
<th className="p-4 font-bold">اقدام</th>
|
</TableHeader>
|
||||||
</tr>
|
<TableBody>
|
||||||
</thead>
|
{filtered.map((item) => {
|
||||||
<tbody className="divide-y divide-slate-100">
|
const meta = CATEGORY_META[item.category];
|
||||||
{filteredItems.map((item) => (
|
return (
|
||||||
<tr
|
<TableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onSelectItem(item)}
|
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">
|
|
||||||
<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)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{getCategoryLabelFa(item.category)}
|
<TableCell className="max-w-xs font-semibold">
|
||||||
</span>
|
{item.title}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="p-4 text-slate-600">{getHorizonLabelFa(item.horizon)}</td>
|
<TableCell>
|
||||||
<td className="p-4 font-bold text-teal-600 font-mono">{toPersianDigits(item.impactScore)}/۵</td>
|
<span
|
||||||
<td className="p-4 text-slate-500 font-mono">{toPersianDigits(item.uncertaintyScore)}/۵</td>
|
className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11.5px] font-medium"
|
||||||
<td className="p-4 text-slate-600">{item.geographicScope}</td>
|
style={{ color: meta.color }}
|
||||||
<td className="p-4">
|
>
|
||||||
<span className="badge badge-gray">{item.status}</span>
|
<span className="cat-dot" style={{ background: meta.color }} />
|
||||||
</td>
|
{meta.label}
|
||||||
<td className="p-4">
|
</span>
|
||||||
<button className="text-teal-600 font-bold hover:underline">
|
</TableCell>
|
||||||
مشاهده پرونده ↗
|
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||||
</button>
|
{getHorizonLabelFa(item.horizon)}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
<TableCell className="tnum font-semibold">
|
||||||
))}
|
{toPersianDigits(item.impactScore)}/۵
|
||||||
</tbody>
|
</TableCell>
|
||||||
</table>
|
<TableCell className="tnum text-muted-foreground">
|
||||||
</div>
|
{toPersianDigits(item.uncertaintyScore)}/۵
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{item.geographicScope}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && <EmptyState onReset={resetFilters} />}
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 React from 'react';
|
||||||
import { IntelligenceItem, University1415Dimension, JobEntity } from '../types/futures';
|
import { IntelligenceItem, University1415Dimension, JobEntity } from '../types/futures';
|
||||||
import { toPersianDigits, getCategoryLabelFa, getHorizonLabelFa } from '../utils/persianNumbers';
|
import { PageHeader } from '../components/common/PageHeader';
|
||||||
import { Printer, Download, FileText, CheckCircle2, AlertTriangle, Target, Compass } from 'lucide-react';
|
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 {
|
interface ReportsPageProps {
|
||||||
items: IntelligenceItem[];
|
items: IntelligenceItem[];
|
||||||
|
|
@ -10,193 +24,160 @@ interface ReportsPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReportsPage: React.FC<ReportsPageProps> = ({ items, dimensions, jobs }) => {
|
export const ReportsPage: React.FC<ReportsPageProps> = ({ items, dimensions, jobs }) => {
|
||||||
const handlePrint = () => {
|
const criticalItems = items.filter(
|
||||||
window.print();
|
(i) => i.status === 'critical' || i.impactScore === 5
|
||||||
};
|
|
||||||
|
|
||||||
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 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 (
|
return (
|
||||||
<div className="flex flex-col gap-6 max-w-5xl mx-auto">
|
<div className="mx-auto max-w-5xl animate-in fade-in duration-300">
|
||||||
{/* Action Header (Hidden in Print) */}
|
<div className="no-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">
|
<PageHeader
|
||||||
<div>
|
title="گزارشها"
|
||||||
<div className="flex items-center gap-2 mb-1">
|
description="خلاصه وضعیت رصد و شکافها، آماده چاپ."
|
||||||
<span className="badge badge-teal">مرکز هوشمندی راهبردی</span>
|
actions={
|
||||||
<span className="text-slate-400 text-xs">گزارش مدیریتی و نهادی</span>
|
<Button size="sm" onClick={() => window.print()}>
|
||||||
</div>
|
<Printer />
|
||||||
<h1 className="page-title text-2xl text-white">
|
چاپ
|
||||||
گزارشهای راهبردی و بولتنهای آیندهپژوهی
|
</Button>
|
||||||
</h1>
|
}
|
||||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
/>
|
||||||
نسخه آماده چاپ و خروجی رسمی ویژه اعضای هیئت رئیسه و شورای دانشگاه اصفهان
|
|
||||||
</p>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Printable Institutional Report Document */}
|
<Card data-card className="p-6 sm:p-8">
|
||||||
<div className="intel-card p-8 lg:p-12 flex flex-col gap-8 bg-[#0c162b] border-[#1c2c4d] shadow-2xl">
|
{/* Document header */}
|
||||||
{/* Document Header */}
|
<header className="mb-6 border-b pb-5">
|
||||||
<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">
|
<h2 className="text-lg font-bold">
|
||||||
<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">
|
</h2>
|
||||||
<Compass size={28} />
|
<p className="tnum mt-1 text-[12px] text-muted-foreground">
|
||||||
</div>
|
افق راهبردی {toPersianDigits(HORIZON_START)} تا {toPersianDigits(HORIZON_END)}
|
||||||
<div>
|
|
||||||
<h2 className="text-white font-black text-xl lg:text-2xl">
|
|
||||||
دانشگاه اصفهان — رادار آیندهپژوهی و هوشمندی راهبردی
|
|
||||||
</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>
|
||||||
<p>
|
</header>
|
||||||
بزرگترین شکافهای عملکردی در بعد{' '}
|
|
||||||
<strong className="text-rose-400 font-bold">«هوشمصنوعیبنیان» (شکاف ۵.۳ نمره)</strong> و{' '}
|
|
||||||
<strong className="text-amber-400 font-bold">«انعطافپذیر و چابک» (شکاف ۵.۴ نمره)</strong> متمرکز است که به دلیل حفظ الگوهای سنتی امتحانات و دیوانسالاری اداری ایجاد شده است. در نقطه مقابل، بعد «انسانمحور» و «بینرشتهای» با ۶۰٪ آمادگی به عنوان مزیت رقابتی دانشگاه شناخته میشوند.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* High Priority Critical Items Table */}
|
{/* Figures, all derived from the dataset. */}
|
||||||
<div className="flex flex-col gap-3 text-xs">
|
<section className="mb-7">
|
||||||
<h3 className="text-rose-400 font-extrabold text-sm flex items-center gap-2">
|
<SectionTitle>در یک نگاه</SectionTitle>
|
||||||
<AlertTriangle size={16} />
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<span>جدول پیشرانهای با فوریت بحرانی و نیازمند واکنش فوری</span>
|
<Figure label="موارد رصدشده" value={toPersianDigits(items.length)} />
|
||||||
</h3>
|
<Figure label="موارد بحرانی" value={toPersianDigits(criticalItems.length)} />
|
||||||
|
<Figure label="آمادگی ۱۴۱۵" value={`${toPersianDigits(avgReadiness)}٪`} />
|
||||||
<div className="overflow-x-auto">
|
<Figure label="مشاغل رو به رشد" value={toPersianDigits(growingJobs)} />
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 1415 Readiness & Gap Matrix */}
|
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div className="flex flex-col gap-3 text-xs">
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
<h3 className="text-teal-400 font-extrabold text-sm flex items-center gap-2">
|
const meta = CATEGORY_META[cat];
|
||||||
<Target size={16} />
|
const count = items.filter((i) => i.category === cat).length;
|
||||||
<span>ماتریس شکافهای راهبردی دانشگاه اصفهان در افق ۱۴۱۵</span>
|
return (
|
||||||
</h3>
|
<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="grid grid-cols-1 md:grid-cols-2 gap-3">
|
{/* Critical items */}
|
||||||
{sortedGaps.map((dim) => (
|
{criticalItems.length > 0 && (
|
||||||
<div
|
<section className="mb-7">
|
||||||
key={dim.id}
|
<SectionTitle>مواردی که نیازمند تصمیماند</SectionTitle>
|
||||||
className="bg-[#091122] p-3 rounded-xl border border-slate-800 flex flex-col gap-1.5"
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
>
|
<Table>
|
||||||
<div className="flex items-center justify-between">
|
<TableHeader>
|
||||||
<span className="font-bold text-white">{dim.title}</span>
|
<TableRow>
|
||||||
<span className="text-rose-400 font-bold bg-rose-500/10 px-2 py-0.5 rounded text-[11px]">
|
<TableHead className="text-right">عنوان</TableHead>
|
||||||
شکاف: {toPersianDigits(dim.gap)} نمره
|
<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>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
شکاف {toPersianDigits(dim.gap)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-slate-400 text-[11px]">
|
|
||||||
<span>وضع موجود: {toPersianDigits(dim.currentScore)}/۱۰</span>
|
<div className="tnum flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||||
<span>هدف ۱۴۱۵: {toPersianDigits(dim.targetScore)}/۱۰</span>
|
<span>موجود {toPersianDigits(dim.currentScore)}/۱۰</span>
|
||||||
<span className="text-teal-300 font-semibold">{toPersianDigits(dim.readinessPercentage)}٪ آمادگی</span>
|
<span>هدف {toPersianDigits(dim.targetScore)}/۱۰</span>
|
||||||
</div>
|
<span>{toPersianDigits(dim.readinessPercentage)}٪ آمادگی</span>
|
||||||
<div className="text-slate-300 text-[11px] mt-1 border-t border-slate-800 pt-1">
|
|
||||||
<strong>اقدام کلیدی:</strong> {dim.strategicRecommendations[0]}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
</Card>
|
||||||
{/* 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>
|
|
||||||
</div>
|
</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 React, { useState } from 'react';
|
||||||
import { IntelligenceItem, University1415Dimension, JobEntity, SkillEntity } from '../types/futures';
|
import {
|
||||||
|
IntelligenceItem,
|
||||||
|
University1415Dimension,
|
||||||
|
JobEntity,
|
||||||
|
SkillEntity
|
||||||
|
} from '../types/futures';
|
||||||
import { StrategicGraph } from '../components/visual/StrategicGraph';
|
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 {
|
interface StrategicNetworkPageProps {
|
||||||
items: IntelligenceItem[];
|
items: IntelligenceItem[];
|
||||||
|
|
@ -20,59 +30,65 @@ export const StrategicNetworkPage: React.FC<StrategicNetworkPageProps> = ({
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onSelectDimension
|
onSelectDimension
|
||||||
}) => {
|
}) => {
|
||||||
|
const [view, setView] = useState<'network' | 'chain'>('network');
|
||||||
|
|
||||||
const handleSelectEntity = (type: string, id: string) => {
|
const handleSelectEntity = (type: string, id: string) => {
|
||||||
if (type === 'item') {
|
if (type === 'item') {
|
||||||
const it = items.find((i) => i.id === id);
|
const found = items.find((i) => i.id === id);
|
||||||
if (it) onSelectItem(it);
|
if (found) onSelectItem(found);
|
||||||
} else if (type === 'dimension') {
|
} else if (type === 'dimension') {
|
||||||
const dim = dimensions.find((d) => d.id === id);
|
const found = dimensions.find((d) => d.id === id);
|
||||||
if (dim) onSelectDimension(dim);
|
if (found) onSelectDimension(found);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
title="شبکه روابط"
|
||||||
<div>
|
description="مسیر اثر هر پیشران: از فناوری یا سیگنال بیرونی تا مهارت، شغل و شکاف قابلیت دانشگاه."
|
||||||
<div className="flex items-center gap-2 mb-1">
|
actions={
|
||||||
<span className="badge badge-teal">تحلیل سیستمی و سناریوسازی</span>
|
<Tabs value={view} onValueChange={(v) => setView(v as typeof view)}>
|
||||||
<span className="text-slate-400 text-xs">نگاشت شبکه علّی اثرات</span>
|
<TabsList className="h-9">
|
||||||
</div>
|
<TabsTrigger value="network" className="gap-1.5">
|
||||||
<h1 className="page-title text-2xl text-white">
|
<Share2 className="size-3.5" />
|
||||||
شبکه روابط و زنجیره ارزش تحول راهبردی (Strategic Impact Network)
|
<span className="hidden sm:inline">شبکه</span>
|
||||||
</h1>
|
</TabsTrigger>
|
||||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
<TabsTrigger value="chain" className="gap-1.5">
|
||||||
ردگیری مسیر علّی و همبستگی پدیدهها: پیشرانهای بیرونی ← اختلال در آموزش عالی ← مهارتها ← مشاغل ← ابعاد ۱۴۱۵ دانشگاه اصفهان
|
<Route className="size-3.5" />
|
||||||
</p>
|
<span className="hidden sm:inline">زنجیره</span>
|
||||||
</div>
|
</TabsTrigger>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Strategic Graph Component */}
|
|
||||||
<StrategicGraph
|
|
||||||
items={items}
|
|
||||||
dimensions={dimensions}
|
|
||||||
jobs={jobs}
|
|
||||||
skills={skills}
|
|
||||||
onSelectEntity={handleSelectEntity}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'chain' && (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<StrategicGraph
|
||||||
|
items={items}
|
||||||
|
dimensions={dimensions}
|
||||||
|
jobs={jobs}
|
||||||
|
skills={skills}
|
||||||
|
onSelectEntity={handleSelectEntity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { University1415Dimension, IntelligenceItem } from '../types/futures';
|
import { University1415Dimension, IntelligenceItem } from '../types/futures';
|
||||||
import { Dimension1415Card } from '../components/cards/Dimension1415Card';
|
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 { dataService } from '../services/dataService';
|
||||||
import { toPersianDigits } from '../utils/persianNumbers';
|
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 {
|
import {
|
||||||
Target,
|
Dialog,
|
||||||
ArrowLeft,
|
DialogContent,
|
||||||
X,
|
DialogHeader,
|
||||||
Lightbulb,
|
DialogTitle
|
||||||
CheckCircle2,
|
} from '@/components/ui/dialog';
|
||||||
TrendingUp,
|
import { Button } from '@/components/ui/button';
|
||||||
Cpu,
|
import { Input } from '@/components/ui/input';
|
||||||
BookOpen,
|
import { Label } from '@/components/ui/label';
|
||||||
Sliders,
|
import { Progress } from '@/components/ui/progress';
|
||||||
Save
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
SlidersHorizontal,
|
||||||
|
Save,
|
||||||
|
Flower,
|
||||||
|
BarChartHorizontal,
|
||||||
|
LayoutGrid
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface University1415PageProps {
|
interface University1415PageProps {
|
||||||
|
|
@ -23,285 +37,340 @@ interface University1415PageProps {
|
||||||
onRefreshDimensions: () => void;
|
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> = ({
|
export const University1415Page: React.FC<University1415PageProps> = ({
|
||||||
dimensions,
|
dimensions,
|
||||||
items,
|
items,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onRefreshDimensions
|
onRefreshDimensions
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedDimension, setSelectedDimension] = useState<University1415Dimension | null>(null);
|
const [view, setView] = useState<View>('bloom');
|
||||||
const [isEditingScores, setIsEditingScores] = useState(false);
|
const [selected, setSelected] = useState<University1415Dimension | null>(null);
|
||||||
const [editCurrent, setEditCurrent] = useState<number>(0);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editTarget, setEditTarget] = useState<number>(0);
|
const [editCurrent, setEditCurrent] = useState(0);
|
||||||
|
const [editTarget, setEditTarget] = useState(0);
|
||||||
|
|
||||||
const handleOpenDetail = (dim: University1415Dimension) => {
|
const openDetail = (dim: University1415Dimension) => {
|
||||||
setSelectedDimension(dim);
|
setSelected(dim);
|
||||||
setEditCurrent(dim.currentScore);
|
setEditCurrent(dim.currentScore);
|
||||||
setEditTarget(dim.targetScore);
|
setEditTarget(dim.targetScore);
|
||||||
setIsEditingScores(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveScores = () => {
|
const saveScores = () => {
|
||||||
if (!selectedDimension) return;
|
if (!selected) return;
|
||||||
dataService.updateDimensionScores(selectedDimension.id, editCurrent, editTarget);
|
dataService.updateDimensionScores(selected.id, editCurrent, editTarget);
|
||||||
onRefreshDimensions();
|
onRefreshDimensions();
|
||||||
const updated = dataService.getDimensionById(selectedDimension.id);
|
const updated = dataService.getDimensionById(selected.id);
|
||||||
if (updated) setSelectedDimension(updated);
|
if (updated) setSelected(updated);
|
||||||
setIsEditingScores(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find linked items for selected dimension
|
const stats = useMemo(() => {
|
||||||
const linkedTrends = selectedDimension
|
if (!dimensions.length) {
|
||||||
? items.filter((i) => selectedDimension.relatedTrendIds?.includes(i.id))
|
return { avg: 0, widest: null as University1415Dimension | null, totalGap: 0, strongest: null as University1415Dimension | null };
|
||||||
: [];
|
}
|
||||||
const linkedTech = selectedDimension
|
const byGap = [...dimensions].sort((a, b) => b.gap - a.gap);
|
||||||
? items.filter((i) => selectedDimension.relatedTechIds?.includes(i.id))
|
const byScore = [...dimensions].sort((a, b) => b.currentScore - a.currentScore);
|
||||||
: [];
|
return {
|
||||||
const linkedPolicies = selectedDimension
|
avg: Math.round(
|
||||||
? items.filter((i) => selectedDimension.relatedPolicyIds?.includes(i.id))
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="animate-in fade-in duration-300">
|
||||||
{/* Page Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b border-slate-800">
|
title={`دانشگاه اصفهان در سال ${toPersianDigits(HORIZON_END)}`}
|
||||||
<div>
|
description="ده ویژگی دانشگاه مطلوب، نمره وضع موجود و فاصلهای که تا هدف مانده است."
|
||||||
<div className="flex items-center gap-2 mb-1">
|
actions={
|
||||||
<span className="badge badge-teal">سند راهبردی چشمانداز دانشگاه آینده</span>
|
<Tabs value={view} onValueChange={(v) => setView(v as View)}>
|
||||||
<span className="text-slate-400 text-xs">افق دهساله ۱۴۱۵</span>
|
<TabsList className="h-9">
|
||||||
</div>
|
<TabsTrigger value="bloom" className="gap-1.5">
|
||||||
<h1 className="page-title text-2xl lg:text-3xl text-white">
|
<Flower className="size-3.5" />
|
||||||
دانشگاه اصفهان در سال ۱۴۱۵ (University 1415)
|
<span className="hidden sm:inline">بلوغ</span>
|
||||||
</h1>
|
</TabsTrigger>
|
||||||
<p className="page-subtitle text-slate-300 text-xs mt-1">
|
<TabsTrigger value="gap" className="gap-1.5">
|
||||||
ده کلانویژگی، قابلیتهای بنیادین، وضعیت موجود، نمره مطلوب و اقدامات راهبردی تحول دانشگاه
|
<BarChartHorizontal className="size-3.5" />
|
||||||
</p>
|
<span className="hidden sm:inline">شکاف</span>
|
||||||
</div>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="cards" className="gap-1.5">
|
||||||
|
<LayoutGrid className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">کارتها</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Headline figures */}
|
||||||
<div className="bg-[#0c1833] border border-teal-500/30 rounded-xl px-4 py-2 text-right">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<span className="text-[11px] text-slate-400 block">میانگین آمادگی سازمانی:</span>
|
<Figure
|
||||||
<span className="text-lg font-black text-teal-400 font-mono">
|
label="میانگین آمادگی"
|
||||||
{toPersianDigits(
|
value={stats.avg}
|
||||||
Math.round(
|
suffix="٪"
|
||||||
dimensions.reduce((acc, d) => acc + d.readinessPercentage, 0) / dimensions.length
|
tone="var(--primary)"
|
||||||
)
|
sub={`سال ${toPersianDigits(HORIZON_YEAR_INDEX)} از ${toPersianDigits(HORIZON_SPAN)}`}
|
||||||
)}
|
/>
|
||||||
٪
|
<Figure
|
||||||
</span>
|
label="مجموع شکاف"
|
||||||
</div>
|
value={stats.totalGap}
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 10 Dimension Cards Grid */}
|
{/* Main view */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
<Card data-card className="mt-4">
|
||||||
{dimensions.map((dim) => (
|
<CardContent className="p-3 sm:p-6">
|
||||||
<Dimension1415Card
|
{view === 'bloom' && (
|
||||||
key={dim.id}
|
<div className="animate-in fade-in duration-300">
|
||||||
dimension={dim}
|
<DimensionBloom dimensions={dimensions} onSelect={openDetail} />
|
||||||
onClick={() => handleOpenDetail(dim)}
|
<p className="mt-2 text-center text-[11.5px] text-muted-foreground">
|
||||||
/>
|
طول هر گلبرگ نمره وضع موجود است و بخش راهراه، فاصله تا هدف. رنگ از
|
||||||
))}
|
قرمز به سبز، نشاندهنده درصد آمادگی است.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Vision & Definition */}
|
{view === 'gap' && (
|
||||||
<div className="flex flex-col gap-3 text-xs">
|
<div className="animate-in fade-in duration-300">
|
||||||
<div className="bg-[#091122] p-4 rounded-xl border border-slate-800">
|
<DimensionGapTrack dimensions={dimensions} onSelect={openDetail} />
|
||||||
<span className="text-teal-400 font-bold block mb-1">چشمانداز و تعریف (Definition & Vision):</span>
|
</div>
|
||||||
<p className="text-white font-semibold leading-relaxed mb-2">
|
)}
|
||||||
{selectedDimension.vision}
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-300 leading-relaxed">
|
|
||||||
{selectedDimension.definition}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Score Assessment & Gap Calibration */}
|
{view === 'cards' && (
|
||||||
<div className="bg-[#0c162d] p-4 rounded-xl border border-slate-800 flex flex-col gap-3">
|
<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">
|
||||||
<div className="flex items-center justify-between">
|
{dimensions.map((dim, i) => (
|
||||||
<span className="text-white font-bold">ارزیابی نمرات و شکاف راهبردی (Gap Analysis):</span>
|
<div
|
||||||
<button
|
key={dim.id}
|
||||||
onClick={() => setIsEditingScores(!isEditingScores)}
|
className="animate-in fade-in slide-in-from-bottom-2"
|
||||||
className="text-teal-400 hover:underline text-[11px] font-semibold"
|
style={{ animationDelay: `${i * 45}ms`, animationFillMode: 'both' }}
|
||||||
>
|
>
|
||||||
{isEditingScores ? 'لغو کالیبراسیون' : 'کالیبراسیون نمرات سازمانی'}
|
<Dimension1415Card dimension={dim} onClick={() => openDetail(dim)} />
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{!isEditingScores ? (
|
{/* Detail */}
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
||||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
|
||||||
<span className="text-slate-400 text-[11px] block">وضع موجود:</span>
|
{selected && (
|
||||||
<span className="text-amber-400 font-black text-base">
|
<>
|
||||||
{toPersianDigits(selectedDimension.currentScore)} از ۱۰
|
<DialogHeader className="gap-1">
|
||||||
</span>
|
<span
|
||||||
</div>
|
className="h-0.5 w-10 rounded-full"
|
||||||
<div className="bg-[#091122] p-3 rounded-lg border border-slate-800">
|
style={{ background: selected.pillarColor }}
|
||||||
<span className="text-slate-400 text-[11px] block">هدف افق ۱۴۱۵:</span>
|
/>
|
||||||
<span className="text-teal-400 font-black text-base">
|
<DialogTitle className="text-lg">{selected.title}</DialogTitle>
|
||||||
{toPersianDigits(selectedDimension.targetScore)} از ۱۰
|
<p className="text-[11.5px] text-muted-foreground">{selected.titleEn}</p>
|
||||||
</span>
|
</DialogHeader>
|
||||||
</div>
|
|
||||||
<div className="bg-[#091122] p-3 rounded-lg border border-rose-500/30">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="text-slate-400 text-[11px] block">شکاف راهبردی:</span>
|
<section className="rounded-lg border bg-muted/40 p-3.5">
|
||||||
<span className="text-rose-400 font-black text-base">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
{toPersianDigits(selectedDimension.gap)} نمره
|
<h3 className="text-[13px] font-semibold">نمرات و شکاف</h3>
|
||||||
</span>
|
<Button variant="ghost" size="sm" onClick={() => setEditing(!editing)}>
|
||||||
</div>
|
<SlidersHorizontal />
|
||||||
|
{editing ? 'لغو' : 'ویرایش'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="bg-[#091122] p-4 rounded-lg border border-teal-500/40 flex flex-col gap-3">
|
{!editing ? (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<>
|
||||||
|
<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>
|
||||||
|
<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="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-slate-300 block mb-1">وضع موجود (۱ تا ۱۰):</label>
|
<Label className="mb-1.5 text-[12px]">وضع موجود (۱ تا ۱۰)</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step={0.1}
|
step={0.1}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={editCurrent}
|
value={editCurrent}
|
||||||
onChange={(e) => setEditCurrent(Number(e.target.value))}
|
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>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-slate-300 block mb-1">هدف ۱۴۱۵ (۱ تا ۱۰):</label>
|
<Label className="mb-1.5 text-[12px]">هدف (۱ تا ۱۰)</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step={0.1}
|
step={0.1}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={editTarget}
|
value={editTarget}
|
||||||
onChange={(e) => setEditTarget(Number(e.target.value))}
|
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={saveScores}>
|
||||||
|
<Save />
|
||||||
|
ثبت
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={handleSaveScores}
|
</section>
|
||||||
className="btn btn-primary text-xs py-2 self-end flex items-center gap-1"
|
|
||||||
>
|
<Section title="چشمانداز">
|
||||||
<Save size={13} />
|
<p className="text-[12.5px] leading-relaxed">{selected.vision}</p>
|
||||||
<span>ثبت و بازنگری شکاف</span>
|
<p className="mt-2 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||||
</button>
|
{selected.definition}
|
||||||
</div>
|
</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>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{linked.length > 0 && (
|
||||||
|
<Section title="موارد مرتبط در رادار">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{linked.map(({ item, cat }) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(null);
|
||||||
|
onSelectItem(item);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11.5px] transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="cat-dot"
|
||||||
|
style={{ background: CATEGORY_META[cat].color }}
|
||||||
|
/>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
{/* Supporting Evidence in University of Isfahan */}
|
)}
|
||||||
<div className="bg-[#091122] p-4 rounded-xl border border-slate-800">
|
</DialogContent>
|
||||||
<span className="text-purple-400 font-bold block mb-2 flex items-center gap-1.5">
|
</Dialog>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDimension(null);
|
|
||||||
onSelectItem(t);
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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: {
|
president: {
|
||||||
id: 'usr-1',
|
id: 'usr-1',
|
||||||
username: 'president',
|
username: 'president',
|
||||||
fullName: 'دکتر حسین هرسیج',
|
fullName: 'ریاست دانشگاه',
|
||||||
role: 'president',
|
role: 'president',
|
||||||
roleTitleFa: 'رئیس دانشگاه اصفهان',
|
roleTitleFa: 'رئیس دانشگاه اصفهان',
|
||||||
department: 'ریاست و هیئت رئیسه',
|
department: 'ریاست و هیئت رئیسه',
|
||||||
|
|
@ -16,7 +16,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
||||||
researcher: {
|
researcher: {
|
||||||
id: 'usr-2',
|
id: 'usr-2',
|
||||||
username: 'researcher',
|
username: 'researcher',
|
||||||
fullName: 'دکتر مریم میرمحمدی',
|
fullName: 'پژوهشگر آیندهپژوهی',
|
||||||
role: 'researcher',
|
role: 'researcher',
|
||||||
roleTitleFa: 'پژوهشگر ارشد رصد آینده و سیگنالشناسی',
|
roleTitleFa: 'پژوهشگر ارشد رصد آینده و سیگنالشناسی',
|
||||||
department: 'پژوهشکده مطالعات آینده و علوم شناختی',
|
department: 'پژوهشکده مطالعات آینده و علوم شناختی',
|
||||||
|
|
@ -26,7 +26,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
||||||
planner: {
|
planner: {
|
||||||
id: 'usr-3',
|
id: 'usr-3',
|
||||||
username: 'planner',
|
username: 'planner',
|
||||||
fullName: 'دکتر علیرضا فروغی',
|
fullName: 'برنامهریز راهبردی',
|
||||||
role: 'planner',
|
role: 'planner',
|
||||||
roleTitleFa: 'مدیر کل برنامهریزی راهبردی و تحول',
|
roleTitleFa: 'مدیر کل برنامهریزی راهبردی و تحول',
|
||||||
department: 'معاونت طرح، برنامه و تحول آموزشی',
|
department: 'معاونت طرح، برنامه و تحول آموزشی',
|
||||||
|
|
@ -36,7 +36,7 @@ export const PRESET_USERS: Record<PersonaType, UserSession> = {
|
||||||
admin: {
|
admin: {
|
||||||
id: 'usr-4',
|
id: 'usr-4',
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
fullName: 'مهندس سعید فیروزبخش',
|
fullName: 'راهبر سامانه',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
roleTitleFa: 'راهبر ارشد سامانه رادار هوشمندی',
|
roleTitleFa: 'راهبر ارشد سامانه رادار هوشمندی',
|
||||||
department: 'مرکز فناوری اطلاعات و آمار دانشگاه',
|
department: 'مرکز فناوری اطلاعات و آمار دانشگاه',
|
||||||
|
|
@ -108,11 +108,11 @@ class AuthService {
|
||||||
const u = username.toLowerCase().trim();
|
const u = username.toLowerCase().trim();
|
||||||
let matched: UserSession | undefined;
|
let matched: UserSession | undefined;
|
||||||
|
|
||||||
if (u === 'president' || u.includes('هرسیج') || u === 'p') {
|
if (u === 'president' || u === 'p') {
|
||||||
matched = PRESET_USERS.president;
|
matched = PRESET_USERS.president;
|
||||||
} else if (u === 'researcher' || u.includes('میرمحمدی') || u === 'r') {
|
} else if (u === 'researcher' || u === 'r') {
|
||||||
matched = PRESET_USERS.researcher;
|
matched = PRESET_USERS.researcher;
|
||||||
} else if (u === 'planner' || u.includes('فروغی') || u === 's') {
|
} else if (u === 'planner' || u === 's') {
|
||||||
matched = PRESET_USERS.planner;
|
matched = PRESET_USERS.planner;
|
||||||
} else if (u === 'admin' || u === 'a') {
|
} else if (u === 'admin' || u === 'a') {
|
||||||
matched = PRESET_USERS.admin;
|
matched = PRESET_USERS.admin;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
export type Horizon = 'now' | 'near' | 'mid' | 'long';
|
export type Horizon = 'now' | 'near' | 'mid' | 'long';
|
||||||
// 'now': افق اکنون (۱۴۰۳-۱۴۰۵) / 0-2 سال
|
// 'now': افق اکنون (۱۴۰۵-۱۴۰۷) / 0-2 سال
|
||||||
// 'near': افق نزدیک (۱۴۰۵-۱۴۰۸) / 2-5 سال
|
// 'near': افق نزدیک (۱۴۰۷-۱۴۱۰) / 2-5 سال
|
||||||
// 'mid': افق میانمدت (۱۴۰۸-۱۴۱۱) / 5-8 سال
|
// 'mid': افق میانمدت (۱۴۱۰-۱۴۱۳) / 5-8 سال
|
||||||
// 'long': افق بلندمدت (۱۴۱۱-۱۴۱۵) / 8-12 سال
|
// 'long': افق بلندمدت (۱۴۱۳-۱۴۱۵) / 8-10 سال
|
||||||
|
|
||||||
export type Category = 'trend' | 'technology' | 'policy' | 'weak_signal';
|
export type Category = 'trend' | 'technology' | 'policy' | 'weak_signal';
|
||||||
export type Confidence = 'low' | 'medium' | 'high';
|
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 => {
|
export const toPersianDigits = (num: number | string | null | undefined): string => {
|
||||||
if (num === null || num === undefined) return '';
|
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)]);
|
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 => {
|
export const getHorizonLabelFa = (horizon: Horizon): string => {
|
||||||
switch (horizon) {
|
const name = HORIZON_NAME_FA[horizon];
|
||||||
case 'now':
|
return name ? `${name} (${horizonRangeFa(horizon)})` : horizon;
|
||||||
return 'اکنون (۱۴۰۳-۱۴۰۵)';
|
|
||||||
case 'near':
|
|
||||||
return 'افق نزدیک (۱۴۰۵-۱۴۰۸)';
|
|
||||||
case 'mid':
|
|
||||||
return 'میانمدت (۱۴۰۸-۱۴۱۱)';
|
|
||||||
case 'long':
|
|
||||||
return 'بلندمدت (۱۴۱۱-۱۴۱۵)';
|
|
||||||
default:
|
|
||||||
return horizon;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHorizonShortFa = (horizon: Horizon): string => {
|
export const getHorizonShortFa = (horizon: Horizon): string => {
|
||||||
|
|
@ -79,28 +77,3 @@ export const getForceLabelFa = (type: ForceType): string => {
|
||||||
return type;
|
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,
|
* Radial band each horizon occupies, as a fraction of the plot radius.
|
||||||
category: Category,
|
* Items are packed *inside* their band rather than pinned to a single ring,
|
||||||
horizon: Horizon,
|
* which is what made same-horizon dots stack on top of each other.
|
||||||
indexInGroup: number,
|
*/
|
||||||
totalInGroup: number,
|
export const getHorizonBand = (horizon: Horizon): { inner: number; outer: number } => {
|
||||||
center: number,
|
switch (horizon) {
|
||||||
maxRadius: number
|
case 'now':
|
||||||
): RadarPoint => {
|
return { inner: 0.17, outer: 0.36 };
|
||||||
const baseRatio = getHorizonRadiusRatio(horizon);
|
case 'near':
|
||||||
// deterministic pseudo-random offset based on string id
|
return { inner: 0.4, outer: 0.58 };
|
||||||
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
case 'mid':
|
||||||
const radiusJitter = ((hash % 17) - 8) * 0.009;
|
return { inner: 0.62, outer: 0.79 };
|
||||||
const radius = maxRadius * Math.max(0.15, Math.min(0.98, baseRatio + radiusJitter));
|
case 'long':
|
||||||
|
return { inner: 0.83, outer: 0.98 };
|
||||||
const { startAngle, endAngle } = getCategoryQuadrant(category);
|
default:
|
||||||
const span = endAngle - startAngle;
|
return { inner: 0.4, outer: 0.6 };
|
||||||
|
}
|
||||||
// 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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 = (
|
export const getSpiderCoordinates = (
|
||||||
values: number[], // 10 scores from 1 to 10
|
values: number[], // 10 scores from 1 to 10
|
||||||
maxValue: number, // 10
|
maxValue: number, // 10
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path alias — shadcn components import from "@/..." */
|
||||||
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue