Add user authentication feature

This commit is contained in:
Mohamad Mahdi Jebeli 2026-08-27 09:15:31 +03:30
parent c6fbb9c7f2
commit 1f69cb9e7d
86 changed files with 12041 additions and 6724 deletions

12
.claude/launch.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "uniradar",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}

View File

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

View File

@ -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) تبدیل می‌شود و با صفحه‌کلید
قابل استفاده است.

View File

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

2473
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}

View File

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

View File

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

103
src/components/ui/card.tsx Normal file
View File

@ -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,
}

View File

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

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

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

View File

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

View File

@ -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,
}

View File

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

View File

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

View File

@ -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,
}

View File

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

145
src/components/ui/sheet.tsx Normal file
View File

@ -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,
}

View File

@ -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,
}

View File

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

View File

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

116
src/components/ui/table.tsx Normal file
View File

@ -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,
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 010. 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>
);
};

View File

@ -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 (15). */
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>

View File

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

View File

@ -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 15 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 20252030. `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 2227, 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
};

48
src/hooks/use-count-up.ts Normal file
View File

@ -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;
}

18
src/hooks/use-mobile.ts Normal file
View File

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

55
src/hooks/use-theme.ts Normal file
View File

@ -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 };
}

File diff suppressed because it is too large Load Diff

7
src/lib/utils.ts Normal file
View File

@ -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))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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';

51
src/utils/categories.ts Normal file
View File

@ -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)'
}
};

38
src/utils/horizon.ts Normal file
View File

@ -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 }, // 02 years
near: { from: 1407, to: 1410 }, // 25 years
mid: { from: 1410, to: 1413 }, // 58 years
long: { from: 1413, to: 1415 } // 810 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)}`;

View File

@ -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: 'مدیریت داده‌ها و کالیبراسیون شاخص‌ها'
};
}
};

View File

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

View File

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

View File

@ -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)),
},
},
}) })