add all project files
مخزن کامیت contained در
والد
9e8277b7f5
کامیت
4603ae22b7
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
.next/
|
||||
next-dev*.log
|
||||
next-dev*.err.log
|
||||
.codex-next-dev*.log
|
||||
social-next-dev*.log
|
||||
system-settings-next-dev*.log
|
||||
tsconfig.tsbuildinfo
|
||||
|
|
@ -0,0 +1 @@
|
|||
⚠ Fast Refresh had to perform a full reload due to a runtime error.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
|
||||
> arad-lms-os@0.1.0 dev
|
||||
> next dev --hostname 127.0.0.1 --port 3000
|
||||
|
||||
▲ Next.js 15.5.19
|
||||
- Local: http://127.0.0.1:3000
|
||||
- Network: http://127.0.0.1:3000
|
||||
|
||||
✓ Starting...
|
||||
✓ Ready in 7.4s
|
||||
○ Compiling / ...
|
||||
✓ Compiled / in 8.1s (546 modules)
|
||||
GET / 307 in 9388ms
|
||||
○ Compiling /login ...
|
||||
✓ Compiled /login in 2.1s (578 modules)
|
||||
GET /login 200 in 2703ms
|
||||
✓ Compiled in 196ms (242 modules)
|
||||
✓ Compiled in 93ms (242 modules)
|
||||
✓ Compiled in 135ms (242 modules)
|
||||
✓ Compiled in 99ms (242 modules)
|
||||
✓ Compiled in 83ms (242 modules)
|
||||
✓ Compiled in 94ms (242 modules)
|
||||
✓ Compiled in 116ms (242 modules)
|
||||
✓ Compiled in 97ms (242 modules)
|
||||
✓ Compiled in 103ms (242 modules)
|
||||
✓ Compiled in 100ms (242 modules)
|
||||
✓ Compiled in 118ms (242 modules)
|
||||
✓ Compiled in 117ms (242 modules)
|
||||
✓ Compiled in 91ms (242 modules)
|
||||
✓ Compiled in 93ms (242 modules)
|
||||
✓ Compiled in 98ms (242 modules)
|
||||
✓ Compiled in 86ms (242 modules)
|
||||
✓ Compiled in 102ms (242 modules)
|
||||
✓ Compiled in 98ms (242 modules)
|
||||
✓ Compiled in 109ms (242 modules)
|
||||
✓ Compiled in 88ms (242 modules)
|
||||
✓ Compiled in 99ms (242 modules)
|
||||
✓ Compiled in 118ms (242 modules)
|
||||
✓ Compiled in 87ms (242 modules)
|
||||
○ Compiling /dashboard ...
|
||||
✓ Compiled / in 15.5s (4100 modules)
|
||||
GET / 307 in 7232ms
|
||||
✓ Compiled in 267ms (2009 modules)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: dirname(fileURLToPath(import.meta.url))
|
||||
});
|
||||
|
||||
export default [
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
ignores: [".next/**", "node_modules/**", "next-env.d.ts"]
|
||||
}
|
||||
];
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
Diff را بارگزاری کن
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "arad-lms-os",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-rnd": "^10.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "^15.0.4",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { MacLoginScreen } from "@/components/mac/MacLoginScreen";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MacLoginScreen />;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { homeAppsConfig, isAppDisabledByPlan, type UserRole } from "@/config/homeAppsConfig";
|
||||
import { getCurrentUser } from "@/services/api/lmsApi";
|
||||
import { MacModuleContent } from "@/components/mac/MacModuleContent";
|
||||
|
||||
export default function AppRoutePage() {
|
||||
const params = useParams<{ appId: string }>();
|
||||
const appId = params.appId;
|
||||
const [role, setRole] = useState<UserRole | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentUser().then((user) => setRole(user.role));
|
||||
}, []);
|
||||
|
||||
if (!role) return <div className="grid min-h-screen place-items-center bg-slate-950 text-white" dir="rtl">در حال بارگذاری...</div>;
|
||||
const app = homeAppsConfig.find((item) => item.id === appId);
|
||||
if (!app || !app.roleAccess.includes(role)) return <ForbiddenPage />;
|
||||
if (isAppDisabledByPlan(role, appId)) return <DisabledByPlanPage />;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950 p-4 text-slate-900 dark:text-white" dir="rtl">
|
||||
<div className="mx-auto h-[calc(100vh-32px)] max-w-7xl">
|
||||
<MacModuleContent appId={appId} role={role} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledByPlanPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass max-w-md rounded-[28px] p-6 text-center">
|
||||
<h1 className="text-xl font-black">این بخش در طرح فعلی سازمان فعال نیست.</h1>
|
||||
<p className="mt-3">برای فعالسازی، با مدیر آموزش سازمان یا پشتیبانی سامانه تماس بگیرید.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ForbiddenPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass max-w-md rounded-[28px] p-6 text-center">
|
||||
<h1 className="text-xl font-black">دسترسی غیرمجاز</h1>
|
||||
<p className="mt-3">شما به این بخش دسترسی ندارید.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { UserRole } from "@/config/homeAppsConfig";
|
||||
import { getCurrentUser } from "@/services/api/lmsApi";
|
||||
import { EducationalCalendarPage } from "@/components/calendar/EducationalCalendarPage";
|
||||
|
||||
type CalendarRouteMode = "main" | "day" | "my" | "team" | "organization" | "system" | "events" | "create" | "detail" | "edit" | "attendance" | "participants" | "reminders" | "audit" | "reports" | "settings";
|
||||
|
||||
export function CalendarRouteClient({ mode, eventId = "ev-2", initialDate }: { mode: CalendarRouteMode; eventId?: string; initialDate?: string }) {
|
||||
const [role, setRole] = useState<UserRole | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentUser().then((user) => setRole(user.role));
|
||||
}, []);
|
||||
|
||||
if (!role) return <main className="grid min-h-screen place-items-center bg-slate-950 text-white" dir="rtl">در حال بارگذاری...</main>;
|
||||
if (!canOpen(mode, role)) return <ForbiddenCalendarPage />;
|
||||
|
||||
return (
|
||||
<main className="h-screen overflow-hidden bg-slate-950 p-3 text-slate-900 dark:text-white" dir="rtl">
|
||||
<EducationalCalendarPage role={role} mode={mode} eventId={eventId} initialDate={initialDate} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function canOpen(mode: CalendarRouteMode, role: UserRole) {
|
||||
if (mode === "system") return role === "super_admin";
|
||||
if (["create", "edit", "audit"].includes(mode)) return role !== "employee";
|
||||
return true;
|
||||
}
|
||||
|
||||
function ForbiddenCalendarPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass max-w-md rounded-[28px] p-6 text-center">
|
||||
<h1 className="text-xl font-black">دسترسی غیرمجاز</h1>
|
||||
<p className="mt-3 text-sm opacity-75">شما به این بخش از تقویم آموزشی دسترسی ندارید.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarDayRoutePage({ searchParams }: { searchParams: Promise<{ date?: string }> }) {
|
||||
const { date } = await searchParams;
|
||||
return <CalendarRouteClient mode="day" initialDate={date} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarEventAttendancePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CalendarRouteClient mode="attendance" eventId={id} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarEventAuditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CalendarRouteClient mode="audit" eventId={id} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarEventEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CalendarRouteClient mode="edit" eventId={id} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarEventDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CalendarRouteClient mode="detail" eventId={id} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarEventParticipantsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CalendarRouteClient mode="participants" eventId={id} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../../CalendarRouteClient";
|
||||
|
||||
export default async function CalendarEventRemindersPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CalendarRouteClient mode="reminders" eventId={id} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../../CalendarRouteClient";
|
||||
|
||||
export default function CalendarCreateEventPage() {
|
||||
return <CalendarRouteClient mode="create" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function CalendarEventsPage() {
|
||||
return <CalendarRouteClient mode="events" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function MyCalendarPage() {
|
||||
return <CalendarRouteClient mode="my" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function OrganizationCalendarPage() {
|
||||
return <CalendarRouteClient mode="organization" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "./CalendarRouteClient";
|
||||
|
||||
export default function CalendarPage() {
|
||||
return <CalendarRouteClient mode="main" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function CalendarReportsRoutePage() {
|
||||
return <CalendarRouteClient mode="reports" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function CalendarSettingsRoutePage() {
|
||||
return <CalendarRouteClient mode="settings" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function SystemCalendarPage() {
|
||||
return <CalendarRouteClient mode="system" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { CalendarRouteClient } from "../CalendarRouteClient";
|
||||
|
||||
export default function TeamCalendarPage() {
|
||||
return <CalendarRouteClient mode="team" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { MacOSAppShell } from "@/components/mac/MacOSAppShell";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <MacOSAppShell />;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
export default function GlobalError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass max-w-md rounded-[28px] p-6 text-center">
|
||||
<h1 className="text-xl font-black">خطایی رخ داد</h1>
|
||||
<p className="mt-3 text-sm opacity-75">صفحه با مشکل موقت روبهرو شد. دوباره تلاش کنید.</p>
|
||||
<button onClick={reset} className="mt-5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-slate-950">تلاش دوباره</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export default function ForbiddenPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass max-w-md rounded-[32px] p-8 text-center">
|
||||
<p className="text-7xl font-black">۴۰۳</p>
|
||||
<h1 className="mt-4 text-2xl font-bold">دسترسی مجاز نیست</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-white/75">
|
||||
حساب شما اجازه مشاهده این بخش را ندارد. اگر فکر میکنید اشتباهی رخ داده، با مدیر آموزش سازمان تماس بگیرید.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--font-vazirmatn: Vazirmatn, Tahoma, sans-serif;
|
||||
--color-bg: #eef4fb;
|
||||
--color-bg-soft: #f8fafc;
|
||||
--color-surface: rgba(255, 255, 255, 0.78);
|
||||
--color-surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--color-card: rgba(255, 255, 255, 0.72);
|
||||
--color-elevated: rgba(255, 255, 255, 0.9);
|
||||
--color-text: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #64748b;
|
||||
--color-border: rgba(148, 163, 184, 0.34);
|
||||
--color-divider: rgba(148, 163, 184, 0.24);
|
||||
--color-accent: #0891b2;
|
||||
--color-accent-hover: #0e7490;
|
||||
--color-info: #0284c7;
|
||||
--color-success: #059669;
|
||||
--color-warning: #d97706;
|
||||
--color-danger: #e11d48;
|
||||
--color-disabled-bg: rgba(148, 163, 184, 0.22);
|
||||
--color-disabled-text: #94a3b8;
|
||||
--color-input-bg: rgba(255, 255, 255, 0.86);
|
||||
--color-input-border: rgba(148, 163, 184, 0.42);
|
||||
--color-table-header: rgba(226, 232, 240, 0.7);
|
||||
--color-table-row-hover: rgba(14, 165, 233, 0.1);
|
||||
--color-selected: rgba(14, 165, 233, 0.16);
|
||||
--color-focus-ring: rgba(14, 165, 233, 0.32);
|
||||
--color-overlay: rgba(15, 23, 42, 0.28);
|
||||
--shadow-soft: 0 18px 54px rgba(15, 23, 42, 0.14);
|
||||
--shadow-elevated: 0 28px 90px rgba(15, 23, 42, 0.18);
|
||||
--glass-opacity: 0.72;
|
||||
--glass-light-alpha-strong: calc(0.34 + (var(--glass-opacity) * 0.38));
|
||||
--glass-light-alpha-soft: calc(0.18 + (var(--glass-opacity) * 0.2));
|
||||
--glass-dark-alpha-strong: calc(var(--glass-opacity) * 0.86);
|
||||
--glass-dark-alpha-soft: calc(var(--glass-opacity) * 0.42);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--color-bg: #07111f;
|
||||
--color-bg-soft: #0f172a;
|
||||
--color-surface: rgba(15, 23, 42, 0.78);
|
||||
--color-surface-strong: rgba(15, 23, 42, 0.92);
|
||||
--color-card: rgba(30, 41, 59, 0.66);
|
||||
--color-elevated: rgba(15, 23, 42, 0.9);
|
||||
--color-text: #f8fafc;
|
||||
--color-text-secondary: #cbd5e1;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-border: rgba(226, 232, 240, 0.16);
|
||||
--color-divider: rgba(226, 232, 240, 0.12);
|
||||
--color-accent: #22d3ee;
|
||||
--color-accent-hover: #67e8f9;
|
||||
--color-info: #38bdf8;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-danger: #fb7185;
|
||||
--color-disabled-bg: rgba(71, 85, 105, 0.42);
|
||||
--color-disabled-text: #64748b;
|
||||
--color-input-bg: rgba(15, 23, 42, 0.84);
|
||||
--color-input-border: rgba(226, 232, 240, 0.18);
|
||||
--color-table-header: rgba(30, 41, 59, 0.82);
|
||||
--color-table-row-hover: rgba(34, 211, 238, 0.1);
|
||||
--color-selected: rgba(34, 211, 238, 0.16);
|
||||
--color-focus-ring: rgba(34, 211, 238, 0.32);
|
||||
--color-overlay: rgba(2, 6, 23, 0.58);
|
||||
--shadow-soft: 0 22px 70px rgba(0, 0, 0, 0.36);
|
||||
--shadow-elevated: 0 30px 100px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-vazirmatn);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
select {
|
||||
direction: rtl;
|
||||
min-height: 2.5rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-input-bg) !important;
|
||||
border: 1px solid var(--color-input-border);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(18px) saturate(1.25);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.25);
|
||||
}
|
||||
|
||||
select:not([multiple]) {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-inline-start: 2.5rem !important;
|
||||
padding-inline-end: 0.85rem !important;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||
background-position:
|
||||
1.15rem calc(50% - 0.12rem),
|
||||
0.85rem calc(50% - 0.12rem);
|
||||
background-size: 0.38rem 0.38rem, 0.38rem 0.38rem;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-focus-ring), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
select option,
|
||||
select optgroup {
|
||||
color: rgb(15 23 42);
|
||||
background-color: rgb(248 250 252);
|
||||
}
|
||||
|
||||
select option:checked {
|
||||
background-color: rgb(224 242 254);
|
||||
}
|
||||
|
||||
.dark select {
|
||||
color: rgb(248 250 252);
|
||||
background-color: rgba(15, 23, 42, 0.82) !important;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 14px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.dark select option,
|
||||
.dark select optgroup {
|
||||
color: rgb(248 250 252);
|
||||
background-color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.dark select option:checked {
|
||||
background-color: rgb(30 41 59);
|
||||
}
|
||||
|
||||
.liquid-glass {
|
||||
border: 1px solid var(--color-border);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, var(--glass-light-alpha-strong)), rgba(226, 238, 249, var(--glass-light-alpha-soft))),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(148, 163, 184, 0.08));
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14), 0 2px 16px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.62);
|
||||
backdrop-filter: blur(var(--glass-blur, 26px)) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur, 26px)) saturate(1.35);
|
||||
}
|
||||
|
||||
.dark .liquid-glass {
|
||||
border-color: var(--color-border);
|
||||
background: linear-gradient(135deg, rgba(16, 24, 39, var(--glass-dark-alpha-strong)), rgba(16, 24, 39, var(--glass-dark-alpha-soft)));
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.ui-surface {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(22px) saturate(1.24);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.24);
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-card);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-elevated {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
backdrop-filter: blur(24px) saturate(1.26);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.26);
|
||||
}
|
||||
|
||||
.ui-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-input {
|
||||
border: 1px solid var(--color-input-border);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.mac-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.8) transparent;
|
||||
}
|
||||
|
||||
.mac-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.mac-scrollbar::-webkit-scrollbar-thumb {
|
||||
border-radius: 99px;
|
||||
background: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
|
||||
.persian-digits {
|
||||
font-feature-settings: "ss01";
|
||||
}
|
||||
|
||||
.modern-range {
|
||||
--range-value: 72%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.modern-range::-webkit-slider-runnable-track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||
background:
|
||||
linear-gradient(90deg, rgb(14 165 233) 0 var(--range-value), rgba(255, 255, 255, 0.36) var(--range-value) 100%);
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.14), 0 5px 16px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.modern-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
margin-top: -4.5px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.94);
|
||||
background: linear-gradient(135deg, #ffffff, #dff7ff);
|
||||
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.2), 0 0 0 1px rgba(14, 165, 233, 0.18);
|
||||
}
|
||||
|
||||
.modern-range::-moz-range-track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.36);
|
||||
}
|
||||
|
||||
.modern-range::-moz-range-progress {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgb(14 165 233);
|
||||
}
|
||||
|
||||
.modern-range::-moz-range-thumb {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.92);
|
||||
background: linear-gradient(135deg, #ffffff, #dff7ff);
|
||||
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "سامانه آموزش سازمانی آراد",
|
||||
description: "سیستم عامل یادگیری سازمانی فارسی"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass rounded-[28px] px-6 py-4 text-sm font-black">در حال بارگذاری...</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export default function NotFound() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass max-w-md rounded-[28px] p-6 text-center">
|
||||
<h1 className="text-xl font-black">صفحه پیدا نشد</h1>
|
||||
<p className="mt-3 text-sm opacity-75">مسیر مورد نظر در سامانه وجود ندارد.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function PlanAuditPage() {
|
||||
return <PlansPage mode="audit" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function GoldPlanPage() {
|
||||
return <PlansPage mode="gold" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function PlanModulesPage() {
|
||||
return <PlansPage mode="modules" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function SuperAdminPlansPage() {
|
||||
return <PlansPage mode="overview" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function PlatinumPlanPage() {
|
||||
return <PlansPage mode="platinum" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function PlanPreviewPage() {
|
||||
return <PlansPage mode="preview" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { PlansPage } from "@/components/plans/PlansPage";
|
||||
|
||||
export default function SilverPlanPage() {
|
||||
return <PlansPage mode="silver" />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentUser } from "@/services/api/lmsApi";
|
||||
import { SystemSettingsWindow } from "@/components/system-settings/SystemSettingsWindow";
|
||||
|
||||
type Section =
|
||||
| "health" | "api" | "database" | "storage" | "email" | "sms" | "online-class" | "queue" | "scheduler" | "cache"
|
||||
| "logs" | "backup" | "maintenance" | "security" | "webhooks" | "developer" | "feature-flags" | "environment" | "performance" | "audit";
|
||||
|
||||
export function SystemSettingsRouteClient({ section = "health" }: { section?: Section }) {
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
useEffect(() => { getCurrentUser().then((user) => setRole(user.role)); }, []);
|
||||
if (!role) return <main className="grid min-h-screen place-items-center bg-slate-950 text-white" dir="rtl">در حال بررسی دسترسی...</main>;
|
||||
if (role !== "super_admin") return <Forbidden403 />;
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950 p-4 text-slate-900 dark:text-white" dir="rtl">
|
||||
<div className="mx-auto h-[calc(100vh-32px)] max-w-[1500px]">
|
||||
<SystemSettingsWindow initialSection={section} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Forbidden403() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-950 p-6 text-white" dir="rtl">
|
||||
<section className="liquid-glass w-[min(520px,92vw)] rounded-[30px] p-7 text-center">
|
||||
<div className="mx-auto grid h-16 w-16 place-items-center rounded-[22px] bg-rose-500/20 text-rose-100">۴۰۳</div>
|
||||
<h1 className="mt-5 text-2xl font-black">دسترسی غیرمجاز</h1>
|
||||
<p className="mt-3 leading-7 text-white/75">تنظیمات زیرساخت سامانه فقط برای مدیر کل سامانه قابل مشاهده و مدیریت است.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="api" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="audit" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="backup" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="cache" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="database" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="developer" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="email" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="environment" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="feature-flags" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="health" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="logs" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="maintenance" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="online-class" />; }
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { SystemSettingsRouteClient } from "./SystemSettingsRouteClient";
|
||||
|
||||
export default function Page() {
|
||||
return <SystemSettingsRouteClient section="health" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="performance" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="queue" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="scheduler" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="security" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="sms" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="storage" />; }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { SystemSettingsRouteClient } from "../SystemSettingsRouteClient";
|
||||
export default function Page() { return <SystemSettingsRouteClient section="webhooks" />; }
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { UserRole } from "@/config/homeAppsConfig";
|
||||
import { analyticsExplorerItems, overviewTabByRole } from "@/config/analyticsDashboardConfig";
|
||||
import type { AnalyticsExplorerItem, AnalyticsFilterState } from "@/types/analyticsDashboard";
|
||||
import { requestAnalyticsExport } from "@/services/api/analyticsDashboardService";
|
||||
import { AnalyticsDashboardExplorerGrid } from "./AnalyticsDashboardExplorerGrid";
|
||||
import { AnalyticsDashboardTabContent } from "./AnalyticsDashboardTabContent";
|
||||
import { AnalyticsDashboardTabs, type AnalyticsTab } from "./AnalyticsDashboardTabs";
|
||||
import { AnalyticsFilterDrawer } from "./components/AnalyticsFilterDrawer";
|
||||
import { AnalyticsToolbar } from "./components/AnalyticsToolbar";
|
||||
|
||||
const defaultFilters: AnalyticsFilterState = { dateRange: "this_month" };
|
||||
|
||||
export function AnalyticsDashboardExplorer({ role, onToast }: { role: UserRole; onToast?: (message: string, tone?: "success" | "error") => void }) {
|
||||
const items = useMemo(() => analyticsExplorerItems.filter((item) => item.roles.includes(role)), [role]);
|
||||
const overviewId = overviewTabByRole[role];
|
||||
const overviewTitle = items.find((item) => item.id === overviewId)?.titleFa ?? "نمای اصلی";
|
||||
const [tabs, setTabs] = useState<AnalyticsTab[]>([{ id: overviewId, titleFa: overviewTitle }]);
|
||||
const [activeTabId, setActiveTabId] = useState(overviewId);
|
||||
const [query, setQuery] = useState("");
|
||||
const [filters, setFilters] = useState<AnalyticsFilterState>(defaultFilters);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const value = query.trim();
|
||||
if (!value) return items;
|
||||
return items.filter((item) => `${item.titleFa} ${item.descriptionFa}`.includes(value));
|
||||
}, [items, query]);
|
||||
|
||||
const openTab = (id: string, titleFa: string) => {
|
||||
setTabs((current) => current.some((tab) => tab.id === id) ? current : [...current, { id, titleFa }]);
|
||||
setActiveTabId(id);
|
||||
};
|
||||
const openItem = (item: AnalyticsExplorerItem) => openTab(item.id, item.titleFa);
|
||||
const closeTab = (id: string) => {
|
||||
setTabs((current) => {
|
||||
const next = current.filter((tab) => tab.id !== id);
|
||||
if (activeTabId === id) setActiveTabId(next[next.length - 1]?.id ?? overviewId);
|
||||
return next.length ? next : [{ id: overviewId, titleFa: overviewTitle }];
|
||||
});
|
||||
};
|
||||
const exportReport = async (format: "excel" | "pdf" | "print") => {
|
||||
await requestAnalyticsExport(role, filters, format);
|
||||
onToast?.(format === "excel" ? "خروجی اکسل آماده شد." : format === "pdf" ? "خروجی پیدیاف آماده شد." : "چاپ گزارش آماده شد.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-900 dark:text-white" dir="rtl">
|
||||
<AnalyticsDashboardTabs tabs={tabs} activeId={activeTabId} onPick={setActiveTabId} onClose={closeTab} />
|
||||
<div className="space-y-2 p-3 pb-2">
|
||||
<AnalyticsToolbar
|
||||
role={role}
|
||||
filters={filters}
|
||||
onDateRange={(dateRange) => setFilters((current) => ({ ...current, dateRange }))}
|
||||
onFilter={() => setDrawerOpen(true)}
|
||||
onRefresh={() => setRefreshKey((value) => value + 1)}
|
||||
onExport={exportReport}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="جستجو در گزارشها..." className="h-9 w-full rounded-[14px] border border-white/18 bg-white/16 px-3 text-sm outline-none placeholder:text-current/45 focus:border-cyan-300/70" />
|
||||
<button onClick={() => { setQuery(""); setActiveTabId(overviewId); }} className="h-9 shrink-0 rounded-[14px] bg-white/14 px-3 text-xs font-bold hover:bg-white/24">خانه</button>
|
||||
</div>
|
||||
</div>
|
||||
<main key={refreshKey} className="mac-scrollbar min-h-0 flex-1 overflow-auto px-3 pb-4">
|
||||
{activeTabId === "explorer-home" ? (
|
||||
<AnalyticsDashboardExplorerGrid items={filteredItems} onOpen={openItem} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<AnalyticsDashboardExplorerGrid items={filteredItems} onOpen={openItem} />
|
||||
<AnalyticsDashboardTabContent role={role} tabId={activeTabId} items={items} filters={filters} onOpenTab={openTab} onToast={onToast} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<AnalyticsFilterDrawer
|
||||
open={drawerOpen}
|
||||
role={role}
|
||||
filters={filters}
|
||||
onChange={(patch) => setFilters((current) => ({ ...current, ...patch }))}
|
||||
onApply={() => { setDrawerOpen(false); onToast?.("گزارش آماده شد."); }}
|
||||
onReset={() => setFilters(defaultFilters)}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsExplorerItem } from "@/types/analyticsDashboard";
|
||||
import { AnalyticsDashboardExplorerIcon } from "./AnalyticsDashboardExplorerIcon";
|
||||
import { AnalyticsEmptyState } from "./components/AnalyticsEmptyState";
|
||||
|
||||
export function AnalyticsDashboardExplorerGrid({ items, onOpen }: { items: AnalyticsExplorerItem[]; onOpen: (item: AnalyticsExplorerItem) => void }) {
|
||||
if (!items.length) return <AnalyticsEmptyState />;
|
||||
return <div className="grid grid-cols-[repeat(auto-fill,minmax(104px,1fr))] gap-2">{items.map((item) => <AnalyticsDashboardExplorerIcon key={item.id} item={item} onOpen={onOpen} />)}</div>;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { AnalyticsExplorerItem } from "@/types/analyticsDashboard";
|
||||
import { GlassIconTile } from "@/components/shared/GlassIcon";
|
||||
|
||||
export function AnalyticsDashboardExplorerIcon({ item, onOpen }: { item: AnalyticsExplorerItem; onOpen: (item: AnalyticsExplorerItem) => void }) {
|
||||
return <GlassIconTile titleFa={item.titleFa} descriptionFa={item.descriptionFa} iconName={item.icon} seed={item.id} onOpen={() => onOpen(item)} size="sm" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsExplorerItem, AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
|
||||
import { getAnalyticsCharts, getAnalyticsRows } from "@/services/mock/analyticsDashboardMockService";
|
||||
import { AnalyticsChartCard } from "./components/AnalyticsChartCard";
|
||||
import { AnalyticsDataTable } from "./components/AnalyticsDataTable";
|
||||
import { ReadyReportsSection } from "./sections/ReadyReportsSection";
|
||||
import { SystemOverviewSection } from "./sections/SystemOverviewSection";
|
||||
import { OrganizationOverviewSection } from "./sections/OrganizationOverviewSection";
|
||||
import { TeamOverviewSection } from "./sections/TeamOverviewSection";
|
||||
import { PersonalOverviewSection } from "./sections/PersonalOverviewSection";
|
||||
|
||||
export function AnalyticsDashboardTabContent({
|
||||
role,
|
||||
tabId,
|
||||
items,
|
||||
filters,
|
||||
onOpenTab,
|
||||
onToast
|
||||
}: {
|
||||
role: AnalyticsRole;
|
||||
tabId: string;
|
||||
items: AnalyticsExplorerItem[];
|
||||
filters: AnalyticsFilterState;
|
||||
onOpenTab: (id: string, titleFa: string) => void;
|
||||
onToast?: (message: string, tone?: "success" | "error") => void;
|
||||
}) {
|
||||
if (tabId === "system-overview") return <SystemOverviewSection filters={filters} onOpenTab={onOpenTab} />;
|
||||
if (tabId === "organization-overview") return <OrganizationOverviewSection filters={filters} onOpenTab={onOpenTab} />;
|
||||
if (tabId === "team-overview") return <TeamOverviewSection filters={filters} onOpenTab={onOpenTab} />;
|
||||
if (tabId === "self-overview") return <PersonalOverviewSection filters={filters} onOpenTab={onOpenTab} />;
|
||||
if (tabId === "ready-reports") return <ReadyReportsSection role={role} onReport={(message) => onToast?.(message)} onOpenTab={onOpenTab} />;
|
||||
|
||||
const item = items.find((candidate) => candidate.id === tabId);
|
||||
const rows = getAnalyticsRows(role, tabId);
|
||||
const charts = getAnalyticsCharts(role, filters);
|
||||
const detailTitle = item?.titleFa ?? titleFromDetail(tabId);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="xl:col-span-2"><AnalyticsChartCard titleFa={`روند ${detailTitle}`} type="line" data={charts.line} /></div>
|
||||
<AnalyticsChartCard titleFa="وضعیت فعلی" type="progress_ring" data={[{ labelFa: detailTitle, value: role === "employee" ? 68 : 76 }]} />
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<AnalyticsChartCard titleFa="مقایسه جزئیات" type="bar" data={charts.bar} />
|
||||
<AnalyticsChartCard titleFa="هشدارها و موارد نیازمند پیگیری" type="list" data={charts.donut} />
|
||||
</div>
|
||||
<AnalyticsDataTable rows={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function titleFromDetail(tabId: string) {
|
||||
if (tabId.includes("users")) return "کاربران";
|
||||
if (tabId.includes("exam")) return "آزمونها";
|
||||
if (tabId.includes("course")) return "دورهها";
|
||||
if (tabId.includes("report")) return "گزارش";
|
||||
if (tabId.includes("trend")) return "روند";
|
||||
return "جزئیات";
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export type AnalyticsTab = { id: string; titleFa: string };
|
||||
|
||||
export function AnalyticsDashboardTabs({ tabs, activeId, onPick, onClose }: { tabs: AnalyticsTab[]; activeId: string; onPick: (id: string) => void; onClose: (id: string) => void }) {
|
||||
return (
|
||||
<div className="flex min-h-10 items-end gap-1 overflow-x-auto border-b border-white/12 px-3 pt-2 mac-scrollbar">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => onPick(tab.id)} className={`group flex max-w-56 shrink-0 items-center gap-2 rounded-t-[14px] px-3 py-2 text-xs font-bold transition ${activeId === tab.id ? "bg-white/24 text-slate-950 dark:text-white" : "bg-white/8 text-slate-700 hover:bg-white/16 dark:text-white/72"}`}>
|
||||
<span className="truncate">{tab.titleFa}</span>
|
||||
{tabs.length > 1 ? <span onClick={(event) => { event.stopPropagation(); onClose(tab.id); }} className="grid h-5 w-5 place-items-center rounded-full hover:bg-rose-500/18"><X className="h-3.5 w-3.5" /></span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { toPersianDigits } from "@/lib/persianFormat";
|
||||
import type { AnalyticsChartDatum, ChartType } from "@/types/analyticsDashboard";
|
||||
|
||||
export function AnalyticsChartCard({ titleFa, type, data, onOpen }: { titleFa: string; type: ChartType; data: AnalyticsChartDatum[]; onOpen?: () => void }) {
|
||||
return (
|
||||
<button onClick={onOpen} className="liquid-glass min-h-[210px] rounded-[20px] p-4 text-right transition hover:-translate-y-0.5">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-black">{titleFa}</h3>
|
||||
<span className="rounded-full bg-white/20 px-2 py-0.5 text-[11px] font-bold">{labelFor(type)}</span>
|
||||
</div>
|
||||
{type === "line" ? <LineChart data={data} /> : null}
|
||||
{type === "bar" || type === "stacked_bar" ? <BarChart data={data} stacked={type === "stacked_bar"} /> : null}
|
||||
{type === "donut" ? <DonutChart data={data} /> : null}
|
||||
{type === "progress_ring" ? <ProgressRing value={data[0]?.value ?? 0} labelFa={data[0]?.labelFa ?? "پیشرفت"} /> : null}
|
||||
{type === "heatmap" ? <Heatmap data={data} /> : null}
|
||||
{type === "timeline" ? <Timeline data={data} /> : null}
|
||||
{type === "list" ? <AlertList data={data} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function labelFor(type: ChartType) {
|
||||
return ({ line: "روند", bar: "میلهای", stacked_bar: "تجمیعی", donut: "دونات", progress_ring: "پیشرفت", heatmap: "فعالیت", timeline: "زمانی", list: "لیست", table: "جدول", kpi: "شاخص" } as Record<ChartType, string>)[type];
|
||||
}
|
||||
|
||||
function LineChart({ data }: { data: AnalyticsChartDatum[] }) {
|
||||
const max = Math.max(1, ...data.map((item) => item.value));
|
||||
const points = data.map((item, index) => `${index * (280 / Math.max(1, data.length - 1))},${120 - (item.value / max) * 96}`).join(" ");
|
||||
return <svg viewBox="0 0 280 140" className="h-36 w-full"><path d="M0 122H280" className="stroke-slate-900/10 dark:stroke-white/10" /><polyline points={points} fill="none" stroke="#06b6d4" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />{data.map((item, index) => <text key={item.labelFa} x={index * (280 / Math.max(1, data.length - 1))} y="138" textAnchor="middle" className="fill-slate-600 text-[10px] dark:fill-white/65">{item.labelFa}</text>)}</svg>;
|
||||
}
|
||||
|
||||
function BarChart({ data, stacked }: { data: AnalyticsChartDatum[]; stacked?: boolean }) {
|
||||
const max = Math.max(1, ...data.map((item) => item.value + (item.secondaryValue ?? 0)));
|
||||
return <div className="flex h-36 items-end gap-3">{data.map((item) => <div key={item.labelFa} className="flex min-w-0 flex-1 flex-col items-center gap-2"><div className="flex h-28 w-full items-end rounded-full bg-white/14 p-1"><div className="w-full rounded-full bg-cyan-500" style={{ height: `${Math.max(8, (item.value / max) * 100)}%` }} />{stacked ? <div className="w-full rounded-full bg-emerald-500" style={{ height: `${Math.max(6, ((item.secondaryValue ?? 0) / max) * 100)}%` }} /> : null}</div><span className="line-clamp-1 text-[11px] opacity-75">{item.labelFa}</span></div>)}</div>;
|
||||
}
|
||||
|
||||
function DonutChart({ data }: { data: AnalyticsChartDatum[] }) {
|
||||
const total = Math.max(1, data.reduce((sum, item) => sum + item.value, 0));
|
||||
let offset = 25;
|
||||
return <div className="grid grid-cols-[130px_1fr] items-center gap-4"><svg viewBox="0 0 42 42" className="h-32 w-32 -rotate-90">{data.map((item) => { const length = (item.value / total) * 100; const circle = <circle key={item.labelFa} cx="21" cy="21" r="15.915" fill="transparent" stroke={item.color ?? "#06b6d4"} strokeWidth="6" strokeDasharray={`${length} ${100 - length}`} strokeDashoffset={offset} />; offset -= length; return circle; })}</svg><div className="space-y-2">{data.map((item) => <div key={item.labelFa} className="flex items-center justify-between gap-2 text-xs"><span>{item.labelFa}</span><b>{toPersianDigits(item.value)}٪</b></div>)}</div></div>;
|
||||
}
|
||||
|
||||
function ProgressRing({ value, labelFa }: { value: number; labelFa: string }) {
|
||||
return <div className="flex h-36 items-center justify-center"><div className="grid h-28 w-28 place-items-center rounded-full" style={{ background: `conic-gradient(#10b981 ${value * 3.6}deg, rgba(255,255,255,.18) 0)` }}><div className="grid h-20 w-20 place-items-center rounded-full bg-white/75 text-center dark:bg-slate-900/80"><b>{toPersianDigits(value)}٪</b><span className="text-[10px] opacity-70">{labelFa}</span></div></div></div>;
|
||||
}
|
||||
|
||||
function Heatmap({ data }: { data: AnalyticsChartDatum[] }) {
|
||||
return <div className="grid grid-cols-7 gap-1.5">{data.map((item, index) => <span key={`${item.labelFa}-${index}`} title={`${item.labelFa} ${toPersianDigits(item.value)}`} className="aspect-square rounded-[8px]" style={{ backgroundColor: `rgba(16,185,129,${0.18 + item.value / 120})` }} />)}</div>;
|
||||
}
|
||||
|
||||
function Timeline({ data }: { data: AnalyticsChartDatum[] }) {
|
||||
return <div className="space-y-3 pt-2">{data.map((item) => <div key={item.labelFa} className="flex items-center gap-3"><span className="h-2.5 w-2.5 rounded-full bg-cyan-400" /><div className="min-w-0 flex-1 rounded-2xl bg-white/14 px-3 py-2 text-xs"><b>{item.labelFa}</b><span className="float-left">{toPersianDigits(item.value)} رخداد</span></div></div>)}</div>;
|
||||
}
|
||||
|
||||
function AlertList({ data }: { data: AnalyticsChartDatum[] }) {
|
||||
return <div className="space-y-2">{data.slice(0, 5).map((item, index) => <div key={item.labelFa} className="rounded-2xl bg-white/14 p-3 text-xs"><b>{item.labelFa}</b><p className="mt-1 opacity-70">{index % 2 ? "نیازمند پیگیری در همین هفته" : "وضعیت قابل کنترل است"}</p></div>)}</div>;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsTableRow } from "@/types/analyticsDashboard";
|
||||
|
||||
export function AnalyticsDataTable({ rows }: { rows: AnalyticsTableRow[] }) {
|
||||
return (
|
||||
<div className="liquid-glass overflow-auto rounded-[20px] mac-scrollbar">
|
||||
<table className="w-full min-w-[680px] text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-white/70 backdrop-blur-xl dark:bg-slate-900/78">
|
||||
<tr>{["عنوان", "مالک", "وضعیت", "شاخص", "روند"].map((header) => <th key={header} className="p-3 text-right text-xs font-black">{header}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-t border-white/12">
|
||||
<td className="p-3 font-bold">{row.titleFa}</td>
|
||||
<td className="p-3 text-slate-600 dark:text-white/70">{row.ownerFa}</td>
|
||||
<td className="p-3"><span className={`rounded-full px-2 py-1 text-xs font-bold ${row.statusFa.includes("پیگیری") ? "bg-amber-500/18 text-amber-700 dark:text-amber-200" : "bg-emerald-500/18 text-emerald-700 dark:text-emerald-200"}`}>{row.statusFa}</span></td>
|
||||
<td className="p-3 font-black">{row.valueFa}</td>
|
||||
<td className="p-3 text-slate-600 dark:text-white/70">{row.trendFa}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { SearchX } from "lucide-react";
|
||||
import { MacButton } from "@/components/mac/MacUI";
|
||||
|
||||
export function AnalyticsEmptyState({ onReset }: { onReset?: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-[260px] place-items-center rounded-[24px] border border-dashed border-white/28 bg-white/10 p-6 text-center">
|
||||
<div>
|
||||
<SearchX className="mx-auto h-9 w-9 opacity-65" />
|
||||
<p className="mt-3 font-bold">در این بازه زمانی دادهای برای نمایش وجود ندارد.</p>
|
||||
{onReset ? <MacButton intent="neutral" className="mt-4" onClick={onReset}>پاک کردن فیلتر</MacButton> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
|
||||
import { MacButton } from "@/components/mac/MacUI";
|
||||
|
||||
const choices = {
|
||||
organizationId: ["همه سازمانها", "وزارت نیرو", "هلدینگ پارس"],
|
||||
departmentId: ["همه دپارتمانها", "منابع انسانی", "فروش", "فناوری"],
|
||||
sectionId: ["همه بخشها", "آموزش", "عملیات", "پشتیبانی"],
|
||||
unitId: ["همه واحدها", "واحد شمال", "واحد مرکزی", "واحد جنوب"],
|
||||
teamId: ["همه تیمها", "تیم فروش", "تیم آموزش", "تیم فناوری"],
|
||||
userId: ["همه کاربران", "کاربر نمونه", "عضو نیازمند پیگیری"],
|
||||
courseId: ["همه دورهها", "امنیت اطلاعات", "تحلیل داده"],
|
||||
examId: ["همه آزمونها", "آزمون امنیت", "آزمون رفتار سازمانی"],
|
||||
classId: ["همه کلاسها", "کلاس امروز", "کلاس هفته"],
|
||||
learningPathId: ["همه مسیرها", "مسیر فروش", "مسیر رهبری"],
|
||||
status: ["همه وضعیتها", "در حال انجام", "تکمیلشده", "نیازمند پیگیری"]
|
||||
};
|
||||
|
||||
export function AnalyticsFilterDrawer({ open, role, filters, onChange, onApply, onReset, onClose }: { open: boolean; role: AnalyticsRole; filters: AnalyticsFilterState; onChange: (patch: Partial<AnalyticsFilterState>) => void; onApply: () => void; onReset: () => void; onClose: () => void }) {
|
||||
if (!open) return null;
|
||||
const fields = role === "super_admin"
|
||||
? ["organizationId", "status", "courseId", "examId", "classId", "learningPathId", "userId"]
|
||||
: role === "org_admin"
|
||||
? ["departmentId", "sectionId", "unitId", "teamId", "userId", "courseId", "examId", "classId", "learningPathId"]
|
||||
: role === "manager"
|
||||
? ["userId", "courseId", "examId", "classId", "learningPathId", "status"]
|
||||
: ["courseId", "examId", "learningPathId", "status"];
|
||||
return (
|
||||
<div className="fixed inset-0 z-[260] bg-black/25" onClick={onClose}>
|
||||
<aside className="liquid-glass absolute left-4 top-16 max-h-[calc(100vh-92px)] w-[min(360px,calc(100vw-32px))] overflow-auto rounded-[26px] p-4 text-slate-900 dark:text-white mac-scrollbar" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black">فیلترها</h3>
|
||||
<MacButton intent="negative" className="min-h-8 px-3 py-1 text-xs" onClick={onClose}>بستن</MacButton>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{fields.map((field) => <FilterSelect key={field} field={field as keyof typeof choices} value={String(filters[field as keyof AnalyticsFilterState] ?? "")} onChange={(value) => onChange({ [field]: value } as Partial<AnalyticsFilterState>)} />)}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<MacButton intent="positive" className="flex-1" onClick={onApply}>اعمال فیلتر</MacButton>
|
||||
<MacButton intent="negative" className="flex-1" onClick={onReset}>پاک کردن فیلتر</MacButton>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({ field, value, onChange }: { field: keyof typeof choices; value: string; onChange: (value: string) => void }) {
|
||||
const labels: Record<string, string> = { organizationId: "سازمان", departmentId: "دپارتمان", sectionId: "بخش", unitId: "واحد", teamId: "تیم", userId: "کاربر", courseId: "دوره", examId: "آزمون", classId: "کلاس آنلاین", learningPathId: "مسیر یادگیری", status: "وضعیت" };
|
||||
return (
|
||||
<label className="text-xs font-bold">
|
||||
{labels[field]}
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)} className="mt-1 w-full rounded-[14px] px-3 py-2 text-sm">
|
||||
{choices[field].map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
export function AnalyticsLoadingState() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{Array.from({ length: 8 }).map((_, index) => <div key={index} className="h-28 animate-pulse rounded-[20px] bg-white/18" />)}</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">{Array.from({ length: 4 }).map((_, index) => <div key={index} className="h-56 animate-pulse rounded-[22px] bg-white/14" />)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { Download, FileSpreadsheet, Filter, Printer, RefreshCw } from "lucide-react";
|
||||
import { MacButton, MacIconButton } from "@/components/mac/MacUI";
|
||||
import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
|
||||
import { analyticsDateRangeLabels } from "@/config/analyticsDashboardConfig";
|
||||
|
||||
export function AnalyticsToolbar({
|
||||
role,
|
||||
filters,
|
||||
onDateRange,
|
||||
onFilter,
|
||||
onRefresh,
|
||||
onExport
|
||||
}: {
|
||||
role: AnalyticsRole;
|
||||
filters: AnalyticsFilterState;
|
||||
onDateRange: (dateRange: AnalyticsFilterState["dateRange"]) => void;
|
||||
onFilter: () => void;
|
||||
onRefresh: () => void;
|
||||
onExport: (format: "excel" | "pdf" | "print") => void;
|
||||
}) {
|
||||
const scopeFa = role === "super_admin" ? "کل سامانه" : role === "org_admin" ? "سازمان من" : role === "manager" ? "تیم من" : "عملکرد من";
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-[18px] bg-white/12 p-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.24)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-white/20 px-3 py-1 text-xs font-bold">{scopeFa}</span>
|
||||
<select value={filters.dateRange} onChange={(event) => onDateRange(event.target.value as AnalyticsFilterState["dateRange"])} className="h-9 rounded-[14px] px-3 py-1 text-xs">
|
||||
{Object.entries(analyticsDateRangeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
<MacButton intent="neutral" className="min-h-9 px-3 py-1.5 text-xs" onClick={onFilter}><Filter className="h-3.5 w-3.5" />فیلتر</MacButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MacIconButton title="بهروزرسانی" onClick={onRefresh}><RefreshCw className="h-4 w-4" /></MacIconButton>
|
||||
<MacButton intent="positive" className="min-h-9 px-3 py-1.5 text-xs" onClick={() => onExport("excel")}><FileSpreadsheet className="h-3.5 w-3.5" />اکسل</MacButton>
|
||||
<MacButton intent="positive" className="min-h-9 px-3 py-1.5 text-xs" onClick={() => onExport("pdf")}><Download className="h-3.5 w-3.5" />پیدیاف</MacButton>
|
||||
<MacButton intent="positive" className="min-h-9 px-3 py-1.5 text-xs" onClick={() => onExport("print")}><Printer className="h-3.5 w-3.5" />چاپ</MacButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { toPersianDigits } from "@/lib/persianFormat";
|
||||
import type { AnalyticsKpiCard } from "@/types/analyticsDashboard";
|
||||
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
||||
|
||||
export function KpiCard({ item, onOpen }: { item: AnalyticsKpiCard; onOpen: (tabId: string, titleFa: string) => void }) {
|
||||
const tone = item.severity === "danger" ? "border-rose-400/35 bg-rose-500/12" : item.severity === "warning" ? "border-amber-400/35 bg-amber-500/12" : item.severity === "success" ? "border-emerald-400/35 bg-emerald-500/12" : "border-white/22 bg-white/16";
|
||||
const TrendIcon = item.trend === "up" ? ArrowUp : item.trend === "down" ? ArrowDown : Minus;
|
||||
return (
|
||||
<button onClick={() => item.targetTabId && onOpen(item.targetTabId, item.unitFa ?? item.titleFa)} className={`min-w-0 rounded-[18px] border p-3 text-right shadow-[0_12px_30px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:bg-white/24 dark:hover:bg-white/12 ${tone}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="line-clamp-2 min-h-9 text-xs font-bold leading-5 text-slate-600 dark:text-white/68">{item.titleFa}</span>
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/22"><TrendIcon className="h-3.5 w-3.5" /></span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<b className="truncate text-xl font-black text-slate-950 dark:text-white">{toPersianDigits(String(item.value))}</b>
|
||||
{item.changePercent !== undefined ? <span className={`text-[11px] font-bold ${item.changePercent < 0 ? "text-rose-600 dark:text-rose-300" : "text-emerald-700 dark:text-emerald-300"}`}>{toPersianDigits(Math.abs(item.changePercent))}٪</span> : null}
|
||||
</div>
|
||||
<svg viewBox="0 0 80 22" className="mt-2 h-6 w-full overflow-visible" aria-hidden>
|
||||
<polyline fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500/80" points={(item.chartPreview ?? []).map((value, index, arr) => `${index * (80 / Math.max(1, arr.length - 1))},${22 - (value / 70) * 18}`).join(" ")} />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsFilterState } from "@/types/analyticsDashboard";
|
||||
import { AnalyticsOverviewCanvas } from "../sections/shared";
|
||||
|
||||
export function OrganizationOverviewSection({ filters, onOpenTab }: { filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) {
|
||||
return <AnalyticsOverviewCanvas role="org_admin" filters={filters} onOpenTab={onOpenTab} />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsFilterState } from "@/types/analyticsDashboard";
|
||||
import { AnalyticsOverviewCanvas } from "../sections/shared";
|
||||
|
||||
export function PersonalOverviewSection({ filters, onOpenTab }: { filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) {
|
||||
return <AnalyticsOverviewCanvas role="employee" filters={filters} onOpenTab={onOpenTab} />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { FileText } from "lucide-react";
|
||||
import { MacButton } from "@/components/mac/MacUI";
|
||||
import { getReadyReports } from "@/services/mock/analyticsDashboardMockService";
|
||||
import type { AnalyticsRole } from "@/types/analyticsDashboard";
|
||||
|
||||
export function ReadyReportsSection({ role, onReport, onOpenTab }: { role: AnalyticsRole; onReport: (message: string) => void; onOpenTab: (id: string, titleFa: string) => void }) {
|
||||
const reports = getReadyReports(role);
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{reports.map((report) => (
|
||||
<article key={report.id} className="liquid-glass rounded-[20px] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-2xl bg-emerald-500 text-white"><FileText className="h-5 w-5" /></span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-black">{report.titleFa}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-6 text-slate-600 dark:text-white/68">{report.descriptionFa}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<MacButton intent="positive" className="min-h-8 px-3 py-1 text-xs" onClick={() => { onReport("گزارش آماده شد."); onOpenTab(`report-${report.id}`, report.titleFa); }}>مشاهده گزارش</MacButton>
|
||||
<MacButton intent="neutral" className="min-h-8 px-3 py-1 text-xs" onClick={() => onReport("خروجی اکسل آماده شد.")}>اکسل</MacButton>
|
||||
<MacButton intent="neutral" className="min-h-8 px-3 py-1 text-xs" onClick={() => onReport("خروجی پیدیاف آماده شد.")}>پیدیاف</MacButton>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsFilterState } from "@/types/analyticsDashboard";
|
||||
import { AnalyticsOverviewCanvas } from "../sections/shared";
|
||||
|
||||
export function SystemOverviewSection({ filters, onOpenTab }: { filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) {
|
||||
return <AnalyticsOverviewCanvas role="super_admin" filters={filters} onOpenTab={onOpenTab} />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsFilterState } from "@/types/analyticsDashboard";
|
||||
import { AnalyticsOverviewCanvas } from "../sections/shared";
|
||||
|
||||
export function TeamOverviewSection({ filters, onOpenTab }: { filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) {
|
||||
return <AnalyticsOverviewCanvas role="manager" filters={filters} onOpenTab={onOpenTab} />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import type { AnalyticsFilterState, AnalyticsRole } from "@/types/analyticsDashboard";
|
||||
import { getAnalyticsCharts, getAnalyticsKpis, getAnalyticsRows } from "@/services/mock/analyticsDashboardMockService";
|
||||
import { KpiCard } from "../components/KpiCard";
|
||||
import { AnalyticsChartCard } from "../components/AnalyticsChartCard";
|
||||
import { AnalyticsDataTable } from "../components/AnalyticsDataTable";
|
||||
|
||||
export function AnalyticsOverviewCanvas({ role, filters, onOpenTab }: { role: AnalyticsRole; filters: AnalyticsFilterState; onOpenTab: (id: string, titleFa: string) => void }) {
|
||||
const charts = getAnalyticsCharts(role, filters);
|
||||
const rows = getAnalyticsRows(role, "overview");
|
||||
const kpis = getAnalyticsKpis(role);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
|
||||
{kpis.map((item) => <KpiCard key={item.id} item={item} onOpen={onOpenTab} />)}
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<AnalyticsChartCard titleFa="روند فعالیت و تکمیل" type="line" data={charts.line} onOpen={() => onOpenTab("detail-trend", "جزئیات روند")} />
|
||||
<AnalyticsChartCard titleFa="مقایسه شاخصهای آموزشی" type="bar" data={charts.bar} onOpen={() => onOpenTab("detail-compare", "جزئیات مقایسه")} />
|
||||
<AnalyticsChartCard titleFa="وضعیت دورهها و آزمونها" type="stacked_bar" data={charts.stacked} onOpen={() => onOpenTab("detail-status", "جزئیات وضعیت")} />
|
||||
<AnalyticsChartCard titleFa="ترکیب وضعیت کاربران" type="donut" data={charts.donut} onOpen={() => onOpenTab("detail-users", "جزئیات کاربران")} />
|
||||
<AnalyticsChartCard titleFa="نقشه فعالیت" type="heatmap" data={charts.heatmap} onOpen={() => onOpenTab("detail-heatmap", "جزئیات فعالیت")} />
|
||||
<AnalyticsChartCard titleFa="روند رخدادهای مهم" type="timeline" data={charts.timeline} onOpen={() => onOpenTab("detail-timeline", "جزئیات رخدادها")} />
|
||||
</div>
|
||||
<AnalyticsDataTable rows={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, CalendarDays, ChevronLeft, ChevronRight, Download, Filter, Plus, Search, Settings, X } from "lucide-react";
|
||||
import type { UserRole } from "@/config/homeAppsConfig";
|
||||
import { formatJalaliDate, formatJalaliDateTime, toPersianDigits } from "@/lib/persianFormat";
|
||||
import type { CalendarConnectedItemType, CalendarEvent, CalendarEventStatus, CalendarEventType, CalendarSettings, CalendarViewMode } from "@/types/calendar";
|
||||
import { calendarEventTypeDefinitions } from "@/services/mock/calendar/calendarMockData";
|
||||
import { createEvent as storeCreateEvent, getEventById as storeGetEventById, getEvents, getSelectedDate, getSelectedView, getSettings, saveEvents, saveSelectedDate, saveSelectedView, saveSettings } from "@/services/calendar/calendarStorageService";
|
||||
import { MacToggle } from "@/components/mac/MacToggle";
|
||||
import { PersianDatePicker, PersianDateTimePicker, PersianTimePicker } from "./PersianDatePicker";
|
||||
|
||||
type CalendarRouteMode = "main" | "day" | "my" | "team" | "organization" | "system" | "events" | "create" | "detail" | "edit" | "attendance" | "participants" | "reminders" | "audit" | "reports" | "settings";
|
||||
|
||||
const weekDaysFa = ["شنبه", "یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه"];
|
||||
const viewLabels: Record<CalendarViewMode, string> = { day: "روزانه", week: "هفتگی", month: "ماهانه", list: "لیستی", timeline: "تایملاین", today: "امروز", upcoming: "رویدادهای آینده", overdue: "رویدادهای عقبافتاده" };
|
||||
const statusLabels: Record<CalendarEventStatus, string> = { draft: "پیشنویس", published: "منتشرشده", scheduled: "زمانبندیشده", live: "در حال برگزاری", completed: "برگزارشده", cancelled: "لغوشده", overdue: "عقبافتاده", needs_attention: "نیازمند توجه" };
|
||||
const roleLabels: Record<UserRole, string> = { super_admin: "مدیر کل سامانه", org_admin: "مدیر آموزش سازمان", manager: "مدیر واحد", employee: "کارکنان" };
|
||||
|
||||
export function EducationalCalendarPage({ role = "org_admin", mode = "main", eventId = "ev-2", initialDate, onToast }: { role?: UserRole; mode?: CalendarRouteMode; eventId?: string; initialDate?: string; onToast?: (message: string, tone?: "success" | "error") => void }) {
|
||||
const [view, setViewState] = useState<CalendarViewMode>(mode === "day" ? "day" : "month");
|
||||
const [anchorDate, setAnchorDateState] = useState(() => dateFromYmd(initialDate ?? "2026-06-22"));
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(mode === "create");
|
||||
const [settingsOpen, setSettingsOpen] = useState(mode === "settings");
|
||||
const [prefillDate, setPrefillDate] = useState(toYmd(anchorDate));
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const savedView = mode === "day" ? "day" : getSelectedView();
|
||||
const savedDate = initialDate ?? getSelectedDate();
|
||||
setViewState(savedView);
|
||||
setAnchorDateState(dateFromYmd(savedDate));
|
||||
setEvents(applyRoleScope(getEvents(), role, mode));
|
||||
if (["detail", "edit"].includes(mode)) setSelectedEvent(storeGetEventById(eventId) ?? null);
|
||||
}, [eventId, initialDate, mode, role]);
|
||||
|
||||
const showToast = (message: string) => {
|
||||
setToast(message);
|
||||
onToast?.(message, "success");
|
||||
window.setTimeout(() => setToast(""), 2400);
|
||||
};
|
||||
|
||||
const setView = (next: CalendarViewMode) => {
|
||||
setViewState(next);
|
||||
saveSelectedView(next);
|
||||
};
|
||||
|
||||
const setAnchorDate = (date: Date) => {
|
||||
setAnchorDateState(date);
|
||||
saveSelectedDate(toYmd(date));
|
||||
};
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const cleanQuery = query.trim();
|
||||
return events.filter((event) => {
|
||||
if (!cleanQuery) return true;
|
||||
return [event.title, event.description, event.instructorFa, event.organizationFa, event.connectedItem?.title].filter(Boolean).join(" ").includes(cleanQuery);
|
||||
});
|
||||
}, [events, query]);
|
||||
|
||||
const persistEvents = (next: CalendarEvent[]) => {
|
||||
setEvents(applyRoleScope(saveEvents(next), role, mode));
|
||||
};
|
||||
|
||||
const createNewEvent = (payload: Partial<CalendarEvent>) => {
|
||||
const created = storeCreateEvent(payload);
|
||||
const next = getEvents();
|
||||
setEvents(applyRoleScope(next, role, mode));
|
||||
setSelectedEvent(created);
|
||||
setCreateOpen(false);
|
||||
showToast("رویداد ایجاد شد");
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
return (
|
||||
<CalendarShell>
|
||||
<CalendarEventCreateForm role={role} initialDate={initialDate ?? toYmd(anchorDate)} onCancel={() => window.history.back()} onCreate={createNewEvent} />
|
||||
{toast && <Toast message={toast} />}
|
||||
</CalendarShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "settings") {
|
||||
return (
|
||||
<CalendarSettingsPage
|
||||
role={role}
|
||||
embedded
|
||||
onSaved={(settings) => {
|
||||
saveSettings(settings);
|
||||
showToast("تنظیمات ذخیره شد");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "detail") {
|
||||
const event = selectedEvent ?? storeGetEventById(eventId);
|
||||
return <CalendarShell>{event ? <CalendarEventDetail event={event} role={role} onClose={() => undefined} /> : <CalendarEmptyState />}</CalendarShell>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarShell>
|
||||
<CalendarToolbar
|
||||
role={role}
|
||||
mode={mode}
|
||||
anchorDate={anchorDate}
|
||||
view={view}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
onView={setView}
|
||||
onToday={() => setAnchorDate(new Date())}
|
||||
onNavigate={(direction) => setAnchorDate(addByView(anchorDate, view, direction))}
|
||||
onCreate={() => {
|
||||
setPrefillDate(toYmd(anchorDate));
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
onSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
<section className="min-h-0 flex-1 overflow-hidden rounded-[28px] border border-white/20 bg-white/20 p-3 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/30">
|
||||
<CalendarViewSwitcher view={view} onView={setView} />
|
||||
{view === "month" && (
|
||||
<CalendarMonthView
|
||||
events={visibleEvents}
|
||||
anchorDate={anchorDate}
|
||||
selectedDate={anchorDate}
|
||||
onSelectDay={(date) => {
|
||||
setAnchorDate(date);
|
||||
setPrefillDate(toYmd(date));
|
||||
setView("day");
|
||||
}}
|
||||
onQuickCreate={(date) => {
|
||||
setPrefillDate(toYmd(date));
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
onSelectEvent={setSelectedEvent}
|
||||
onEventsChange={persistEvents}
|
||||
/>
|
||||
)}
|
||||
{view === "week" && <CalendarWeekView events={visibleEvents} anchorDate={anchorDate} onSelectEvent={setSelectedEvent} />}
|
||||
{view === "day" && <CalendarDayView events={visibleEvents.filter((event) => isSameDay(event.startAt, anchorDate))} selectedDate={anchorDate} onCreate={() => { setPrefillDate(toYmd(anchorDate)); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />}
|
||||
{view === "list" && <CalendarListView events={visibleEvents} onSelectEvent={setSelectedEvent} />}
|
||||
{view === "timeline" && <CalendarTimelineView events={visibleEvents} onSelectEvent={setSelectedEvent} />}
|
||||
{view === "today" && <CalendarDayView events={visibleEvents.filter((event) => isSameDay(event.startAt, new Date()))} selectedDate={new Date()} onCreate={() => { setPrefillDate(toYmd(new Date())); setCreateOpen(true); }} onSelectEvent={setSelectedEvent} />}
|
||||
{view === "upcoming" && <CalendarListView events={visibleEvents.filter(isFutureEvent)} onSelectEvent={setSelectedEvent} />}
|
||||
{view === "overdue" && <CalendarListView events={visibleEvents.filter(isPastEvent)} onSelectEvent={setSelectedEvent} />}
|
||||
</section>
|
||||
{createOpen && <CalendarEventCreateSheet role={role} initialDate={prefillDate} onClose={() => setCreateOpen(false)} onCreate={createNewEvent} />}
|
||||
{settingsOpen && <CalendarSettingsSheet role={role} onClose={() => setSettingsOpen(false)} onSaved={(settings) => { saveSettings(settings); setSettingsOpen(false); showToast("تنظیمات ذخیره شد"); }} />}
|
||||
{selectedEvent && <CalendarEventDetail event={selectedEvent} role={role} onClose={() => setSelectedEvent(null)} />}
|
||||
{toast && <Toast message={toast} />}
|
||||
</CalendarShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarShell({ children }: { children: React.ReactNode }) {
|
||||
return <main className="persian-digits flex h-full min-h-[720px] flex-col overflow-hidden rounded-[32px] bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,.22),transparent_32%),linear-gradient(135deg,rgba(248,250,252,.88),rgba(226,232,240,.6))] p-4 text-slate-900 dark:bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,.12),transparent_34%),linear-gradient(135deg,rgba(2,6,23,.92),rgba(15,23,42,.82))] dark:text-white" dir="rtl">{children}</main>;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({ role, mode, anchorDate, view, query, onQuery, onView, onToday, onNavigate, onCreate, onSettings }: { role: UserRole; mode: CalendarRouteMode; anchorDate: Date; view: CalendarViewMode; query: string; onQuery: (value: string) => void; onView: (value: CalendarViewMode) => void; onToday: () => void; onNavigate: (direction: -1 | 1) => void; onCreate: () => void; onSettings: () => void }) {
|
||||
const title = mode === "my" ? "تقویم من" : mode === "team" ? "تقویم تیم" : mode === "organization" ? "تقویم سازمان" : mode === "system" ? "تقویم کل سامانه" : "تقویم آموزشی";
|
||||
return (
|
||||
<header className="mb-4 flex flex-wrap items-center gap-3 rounded-[28px] border border-white/30 bg-white/35 p-3 shadow-xl backdrop-blur-2xl dark:border-white/10 dark:bg-white/10">
|
||||
<div className="flex min-w-[220px] flex-1 items-center gap-3">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-sky-500 text-white shadow-lg"><CalendarDays size={24} /></div>
|
||||
<div>
|
||||
<h1 className="text-xl font-black">{title}</h1>
|
||||
<p className="text-xs text-slate-600 dark:text-white/65">{roleLabels[role]} · {formatJalaliDate(anchorDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-2xl bg-white/35 p-1 dark:bg-white/10" aria-label="ناوبری تقویم">
|
||||
<button aria-label="بعدی" onClick={() => onNavigate(1)} className="rounded-xl p-2 hover:bg-white/45"><ChevronRight size={18} /></button>
|
||||
<button onClick={onToday} className="rounded-xl px-3 py-2 text-sm font-bold hover:bg-white/45">امروز</button>
|
||||
<button aria-label="قبلی" onClick={() => onNavigate(-1)} className="rounded-xl p-2 hover:bg-white/45"><ChevronLeft size={18} /></button>
|
||||
</div>
|
||||
<label className="relative min-w-[220px] flex-1 md:max-w-xs">
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500" size={17} />
|
||||
<input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="جستجو در رویداد، دوره، آزمون، مدرس..." className="w-full rounded-2xl border border-white/25 bg-white/40 py-3 pl-3 pr-10 text-sm outline-none focus:ring-2 focus:ring-sky-400/40 dark:bg-white/10" />
|
||||
</label>
|
||||
<button className="rounded-2xl bg-white/35 p-3 hover:bg-white/55 dark:bg-white/10" aria-label="فیلترها"><Filter size={18} /></button>
|
||||
<button className="rounded-2xl bg-white/35 p-3 hover:bg-white/55 dark:bg-white/10" aria-label="خروجی گرفتن"><Download size={18} /></button>
|
||||
<button onClick={onSettings} className="rounded-2xl bg-white/35 p-3 hover:bg-white/55 dark:bg-white/10" aria-label="تنظیمات"><Settings size={18} /></button>
|
||||
{role !== "employee" && <button onClick={onCreate} className="inline-flex items-center gap-2 rounded-2xl bg-slate-950 px-4 py-3 text-sm font-black text-white shadow-lg dark:bg-white dark:text-slate-950"><Plus size={18} />ایجاد رویداد</button>}
|
||||
<select value={view} onChange={(event) => onView(event.target.value as CalendarViewMode)} className="rounded-2xl px-3 py-2 text-sm">
|
||||
{(["day", "week", "month", "list", "timeline", "today", "upcoming", "overdue"] as CalendarViewMode[]).map((item) => <option key={item} value={item}>{viewLabels[item]}</option>)}
|
||||
</select>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarViewSwitcher({ view, onView }: { view: CalendarViewMode; onView: (view: CalendarViewMode) => void }) {
|
||||
return <div className="mb-3 flex gap-2 overflow-auto pb-1 mac-scrollbar">{(["day", "week", "month", "list", "timeline"] as CalendarViewMode[]).map((item) => <button key={item} onClick={() => onView(item)} className={`shrink-0 rounded-2xl px-4 py-2 text-sm font-bold transition ${view === item ? "bg-slate-950 text-white dark:bg-white dark:text-slate-950" : "bg-white/35 hover:bg-white/55 dark:bg-white/10"}`}>{viewLabels[item]}</button>)}</div>;
|
||||
}
|
||||
|
||||
export function CalendarMonthView({ events, anchorDate, selectedDate, onSelectDay, onQuickCreate, onSelectEvent, onEventsChange }: { events: CalendarEvent[]; anchorDate: Date; selectedDate: Date; onSelectDay: (date: Date) => void; onQuickCreate: (date: Date) => void; onSelectEvent: (event: CalendarEvent) => void; onEventsChange: (events: CalendarEvent[]) => void }) {
|
||||
const days = buildMonthDays(anchorDate);
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="grid h-[calc(100%-52px)] min-h-[520px] grid-rows-[auto_1fr] overflow-hidden rounded-3xl border border-white/25 bg-white/25 dark:bg-white/5">
|
||||
<div className="grid grid-cols-7 border-b border-white/20">{weekDaysFa.map((day) => <div key={day} className="p-3 text-center text-xs font-black text-slate-600 dark:text-white/65">{day}</div>)}</div>
|
||||
<div className="grid min-h-0 grid-cols-7 grid-rows-6">
|
||||
{days.map((day) => {
|
||||
const dayEvents = events.filter((event) => isSameDay(event.startAt, day.date));
|
||||
return (
|
||||
<div
|
||||
key={day.date.toISOString()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectDay(day.date)}
|
||||
onKeyDown={(event) => event.key === "Enter" && onSelectDay(day.date)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(dropEvent) => {
|
||||
dropEvent.stopPropagation();
|
||||
if (!dragId) return;
|
||||
const next = getEvents().map((item) => item.id === dragId ? moveEventToDay(item, day.date) : item);
|
||||
onEventsChange(next);
|
||||
setDragId(null);
|
||||
}}
|
||||
className={`flex min-h-0 flex-col overflow-hidden border-b border-l border-white/15 p-2 text-right outline-none transition hover:bg-white/25 focus:ring-2 focus:ring-sky-400/40 ${day.currentMonth ? "" : "opacity-45"} ${isSameDay(day.date, selectedDate) ? "bg-sky-400/15 ring-1 ring-sky-400/40" : ""}`}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-white/45 px-2 text-sm font-black dark:bg-white/10">{toPersianDigits(day.date.getDate())}</span>
|
||||
<button onClick={(event) => { event.stopPropagation(); onQuickCreate(day.date); }} className="hidden rounded-xl bg-white/30 px-2 py-1 text-xs font-bold hover:bg-white/50 md:inline-flex">ایجاد</button>
|
||||
</div>
|
||||
<div className="mt-2 min-h-0 flex-1 space-y-1 overflow-y-auto rounded-2xl pr-1 mac-scrollbar">
|
||||
{dayEvents.map((event, index) => (
|
||||
<CalendarEventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
compact
|
||||
className={index > 1 ? "hidden md:block" : ""}
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
onSelectEvent(event);
|
||||
}}
|
||||
onDragStart={(dragEvent) => {
|
||||
dragEvent.stopPropagation();
|
||||
setDragId(event.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{dayEvents.length > 2 && <button onClick={(event) => { event.stopPropagation(); onSelectDay(day.date); }} className="rounded-xl bg-slate-950/80 px-2 py-1 text-xs font-bold text-white md:hidden">مشاهده همه</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarDayView({ events, selectedDate, onCreate, onSelectEvent }: { events: CalendarEvent[]; selectedDate: Date; onCreate: () => void; onSelectEvent: (event: CalendarEvent) => void }) {
|
||||
return (
|
||||
<div className="h-[calc(100%-52px)] overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5 mac-scrollbar">
|
||||
<div className="sticky top-0 z-10 -mx-4 -mt-4 mb-4 flex flex-wrap items-center justify-between gap-3 bg-white/70 p-4 backdrop-blur-xl dark:bg-slate-950/70">
|
||||
<div>
|
||||
<h2 className="text-xl font-black">{formatJalaliDate(selectedDate)}</h2>
|
||||
<p className="mt-1 text-sm opacity-70">{events.length ? `${toPersianDigits(events.length)} رویداد` : "رویدادی برای این روز وجود ندارد"}</p>
|
||||
</div>
|
||||
<button onClick={onCreate} className="rounded-2xl bg-slate-950 px-4 py-2 text-sm font-black text-white dark:bg-white dark:text-slate-950">ایجاد رویداد جدید</button>
|
||||
</div>
|
||||
{events.length ? <div className="space-y-3">{events.map((event) => <CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div> : <CalendarEmptyState />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarWeekView({ events, anchorDate, onSelectEvent }: { events: CalendarEvent[]; anchorDate: Date; onSelectEvent: (event: CalendarEvent) => void }) {
|
||||
const week = buildWeekDays(anchorDate);
|
||||
return <div className="grid h-[calc(100%-52px)] min-h-[520px] grid-cols-7 overflow-auto rounded-3xl border border-white/20 bg-white/20 dark:bg-white/5 mac-scrollbar">{week.map((day) => <div key={day.toISOString()} className="border-l border-white/15 p-3"><h3 className="sticky top-0 rounded-2xl bg-white/45 p-2 text-center text-xs font-black backdrop-blur-xl dark:bg-slate-950/45">{formatJalaliDate(day)}</h3><div className="mt-3 space-y-2">{events.filter((event) => isSameDay(event.startAt, day)).map((event) => <CalendarEventCard key={event.id} event={event} compact onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>)}</div>;
|
||||
}
|
||||
|
||||
export function CalendarListView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) {
|
||||
if (!events.length) return <CalendarEmptyState />;
|
||||
return <div className="h-[calc(100%-52px)] overflow-auto rounded-3xl border border-white/20 bg-white/20 p-3 dark:bg-white/5 mac-scrollbar"><div className="space-y-2">{events.map((event) => <CalendarEventCard key={event.id} event={event} detailed onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} />)}</div></div>;
|
||||
}
|
||||
|
||||
export function CalendarTimelineView({ events, onSelectEvent }: { events: CalendarEvent[]; onSelectEvent: (event: CalendarEvent) => void }) {
|
||||
return <div className="h-[calc(100%-52px)] overflow-auto rounded-3xl border border-white/20 bg-white/20 p-4 dark:bg-white/5 mac-scrollbar"><div className="relative space-y-4 before:absolute before:right-4 before:top-0 before:h-full before:w-px before:bg-slate-400/40">{events.map((event) => <button key={event.id} onClick={(clickEvent) => { clickEvent.stopPropagation(); onSelectEvent(event); }} className="relative block w-full pr-10 text-right"><span className="absolute right-2 top-4 h-4 w-4 rounded-full ring-4 ring-white/40" style={{ backgroundColor: event.color }} /><CalendarEventCard event={event} detailed /></button>)}</div></div>;
|
||||
}
|
||||
|
||||
export function CalendarEventCard({ event, compact, detailed, className = "", onClick, onDragStart }: { event: CalendarEvent; compact?: boolean; detailed?: boolean; className?: string; onClick?: (event: React.MouseEvent) => void; onDragStart?: (event: React.DragEvent) => void }) {
|
||||
const type = getType(event.type);
|
||||
return (
|
||||
<div draggable={Boolean(onDragStart)} onDragStart={onDragStart} role="button" tabIndex={0} onKeyDown={(keyboardEvent) => keyboardEvent.key === "Enter" && onClick?.(keyboardEvent as unknown as React.MouseEvent)} onClick={onClick} className={`group rounded-2xl border border-white/25 bg-white/45 text-right shadow-sm outline-none transition hover:-translate-y-0.5 hover:bg-white/65 focus:ring-2 focus:ring-sky-400/50 dark:bg-white/10 dark:hover:bg-white/15 ${compact ? "p-2 text-xs" : "p-3"} ${className}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: event.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`truncate font-black ${compact ? "text-xs" : "text-sm"}`}>{event.title}</p>
|
||||
{!compact && <p className="mt-1 text-xs text-slate-600 dark:text-white/65">{type.labelFa} · {statusLabels[event.status]}</p>}
|
||||
{detailed && <p className="mt-2 text-xs leading-6 text-slate-600 dark:text-white/65">{formatJalaliDateTime(event.startAt)} · {event.instructorFa ?? "مدرس ثبت نشده"} · {toPersianDigits(event.participantsCount)} نفر</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarEventDetail({ event, role, onClose }: { event: CalendarEvent; role: UserRole; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const handler = (keyboardEvent: KeyboardEvent) => keyboardEvent.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
const type = getType(event.type);
|
||||
const canEdit = role === "super_admin" || role === "org_admin" || (role === "manager" && event.type === "team_session");
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/25 p-4 backdrop-blur-sm" role="dialog" aria-label="جزئیات رویداد">
|
||||
<section className="liquid-glass max-h-[92vh] w-full max-w-2xl overflow-auto rounded-[30px] p-5 shadow-2xl mac-scrollbar">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><p className="text-sm font-bold" style={{ color: event.color }}>{type.labelFa}</p><h2 className="mt-1 text-2xl font-black">{event.title}</h2><p className="mt-2 text-sm opacity-70">{formatJalaliDateTime(event.startAt)} تا {formatJalaliDateTime(event.endAt)}</p></div>
|
||||
<button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button>
|
||||
</div>
|
||||
<p className="mt-4 rounded-2xl bg-white/20 p-3 text-sm leading-7">{event.description || "توضیحی برای این رویداد ثبت نشده است."}</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<Info label="عنوان رویداد" value={event.title} />
|
||||
<Info label="نوع رویداد" value={type.labelFa} />
|
||||
<Info label="وضعیت" value={statusLabels[event.status]} />
|
||||
<Info label="اتصال آموزشی" value={event.connectedItem?.title ?? "هیچکدام"} />
|
||||
<Info label="مخاطبان" value={`${event.organizationFa ?? "سازمان"} · ${toPersianDigits(event.participantsCount)} نفر`} />
|
||||
<Info label="یادآوریها" value={event.reminders.length ? `${toPersianDigits(event.reminders.length)} یادآوری` : "یادآوری ثبت نشده"} />
|
||||
{event.attendanceEnabled && <Info label="حضور و غیاب" value={`حاضر ${toPersianDigits(event.attendanceSummary.present)} · غایب ${toPersianDigits(event.attendanceSummary.absent)}`} />}
|
||||
</div>
|
||||
<CalendarEventActions event={event} />
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button className="rounded-2xl bg-white/25 px-4 py-2 text-sm font-bold">مشاهده جزئیات</button>
|
||||
{canEdit && <button className="rounded-2xl bg-white/25 px-4 py-2 text-sm font-bold">ویرایش</button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarEventActions({ event }: { event: CalendarEvent }) {
|
||||
const action = getEventPrimaryAction(event);
|
||||
const clickable = Boolean(action.href) && !action.disabled;
|
||||
return (
|
||||
<div className="mt-5 rounded-3xl bg-white/18 p-4">
|
||||
<h3 className="font-black">اقدام رویداد</h3>
|
||||
<button disabled={action.disabled} onClick={() => clickable && window.open(action.href, "_blank", "noopener,noreferrer")} className={`mt-3 rounded-2xl px-4 py-2 text-sm font-black ${action.disabled ? "bg-white/20 opacity-60" : "bg-slate-950 text-white dark:bg-white dark:text-slate-950"}`}>
|
||||
{action.label}
|
||||
</button>
|
||||
{action.description && <p className="mt-2 text-sm opacity-70">{action.description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarEventCreateSheet({ role, initialDate, onClose, onCreate }: { role: UserRole; initialDate: string; onClose: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) {
|
||||
return <div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/25 p-4 backdrop-blur-sm"><section className="liquid-glass max-h-[92vh] w-full max-w-3xl overflow-auto rounded-[30px] p-5 mac-scrollbar"><div className="mb-4 flex items-center justify-between"><h2 className="text-2xl font-black">ایجاد رویداد</h2><button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button></div><CalendarEventCreateForm role={role} initialDate={initialDate} onCancel={onClose} onCreate={onCreate} /></section></div>;
|
||||
}
|
||||
|
||||
export function CalendarEventCreateForm({ role, initialDate, onCancel, onCreate }: { role: UserRole; initialDate: string; onCancel: () => void; onCreate: (payload: Partial<CalendarEvent>) => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState<CalendarEventType>("online_class");
|
||||
const [description, setDescription] = useState("");
|
||||
const [startDate, setStartDate] = useState(initialDate);
|
||||
const [startTime, setStartTime] = useState("09:00");
|
||||
const [endDate, setEndDate] = useState(initialDate);
|
||||
const [endTime, setEndTime] = useState("10:00");
|
||||
const [connectedType, setConnectedType] = useState("none");
|
||||
const [onlineLink, setOnlineLink] = useState("");
|
||||
const [recordingLink, setRecordingLink] = useState("");
|
||||
const [audience, setAudience] = useState("کارکنان");
|
||||
const [reminder, setReminder] = useState("۶۰");
|
||||
const [status, setStatus] = useState<CalendarEventStatus>("scheduled");
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setStartDate(initialDate);
|
||||
setEndDate(initialDate);
|
||||
}, [initialDate]);
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const nextWarnings: string[] = [];
|
||||
if (!title.trim()) nextWarnings.push("عنوان رویداد الزامی است.");
|
||||
if (!startDate) nextWarnings.push("تاریخ شروع الزامی است.");
|
||||
if (!startTime) nextWarnings.push("ساعت شروع الزامی است.");
|
||||
const startAt = toIsoFromParts(startDate, startTime);
|
||||
const endAt = toIsoFromParts(endDate || startDate, endTime || startTime);
|
||||
if (new Date(endAt) < new Date(startAt)) nextWarnings.push("تاریخ پایان نباید قبل از تاریخ شروع باشد.");
|
||||
if (type === "online_class" && !onlineLink.trim()) nextWarnings.push("برای کلاس آنلاین، لینک ورود یا لینک جلسه را وارد کنید.");
|
||||
setWarnings(nextWarnings);
|
||||
if (nextWarnings.length) return;
|
||||
|
||||
const definition = getType(type);
|
||||
onCreate({
|
||||
title: title.trim(),
|
||||
description,
|
||||
type,
|
||||
status,
|
||||
startAt,
|
||||
endAt,
|
||||
allDay: false,
|
||||
color: definition.color,
|
||||
connectedItem: { type: connectedType as CalendarConnectedItemType, title: connectedType === "none" ? "هیچکدام" : connectionLabel(connectedType) },
|
||||
onlineLink: onlineLink || undefined,
|
||||
recordingLink: recordingLink || undefined,
|
||||
roleTargets: role === "manager" ? ["employee"] : ["manager", "employee"],
|
||||
organizationFa: audience,
|
||||
participantsCount: 1,
|
||||
reminders: [{ id: `rem-${Date.now()}`, titleFa: `${toPersianDigits(reminder)} دقیقه قبل`, minutesBefore: Number(reminder), channels: ["in_app"], state: "pending", recipientsCount: 1 }],
|
||||
attendanceEnabled: type === "online_class" || type === "webinar" || type === "in_person_class"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{!!warnings.length && <div className="rounded-2xl bg-rose-500/15 p-3 text-sm font-bold leading-7">{warnings.map((warning) => <p key={warning}>{warning}</p>)}</div>}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<TextField label="عنوان رویداد" value={title} onChange={setTitle} required />
|
||||
<label className="text-sm font-bold">نوع رویداد<select value={type} onChange={(event) => setType(event.target.value as CalendarEventType)} className="mt-2 w-full rounded-2xl p-3">{calendarEventTypeDefinitions.map((item) => <option key={item.type} value={item.type}>{item.labelFa}</option>)}</select></label>
|
||||
<label className="text-sm font-bold md:col-span-2">توضیحات<textarea value={description} onChange={(event) => setDescription(event.target.value)} className="mt-2 min-h-24 w-full rounded-2xl border border-white/20 bg-white/20 p-3 outline-none focus:ring-2 focus:ring-sky-400/40" /></label>
|
||||
</div>
|
||||
<PersianDateTimePicker dateLabel="تاریخ شروع" timeLabel="ساعت شروع" date={startDate} time={startTime} onDateChange={setStartDate} onTimeChange={setStartTime} />
|
||||
<div className="grid gap-3 md:grid-cols-2"><PersianDatePicker label="تاریخ پایان" value={endDate} onChange={setEndDate} /><PersianTimePicker label="ساعت پایان" value={endTime} onChange={setEndTime} /></div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm font-bold">اتصال آموزشی<select value={connectedType} onChange={(event) => setConnectedType(event.target.value)} className="mt-2 w-full rounded-2xl p-3">{["دوره", "میکروآموزش", "آزمون", "کلاس آنلاین", "بوتکمپ", "هیچکدام"].map((item) => <option key={item} value={connectionValue(item)}>{item}</option>)}</select></label>
|
||||
<TextField label="مخاطبان" value={audience} onChange={setAudience} />
|
||||
<TextField label="لینک ورود به رویداد" value={onlineLink} onChange={setOnlineLink} />
|
||||
<TextField label="لینک ضبط دوره" value={recordingLink} onChange={setRecordingLink} />
|
||||
<label className="text-sm font-bold">یادآوری<select value={reminder} onChange={(event) => setReminder(event.target.value)} className="mt-2 w-full rounded-2xl p-3"><option value="15">۱۵ دقیقه قبل</option><option value="60">۱ ساعت قبل</option><option value="1440">۱ روز قبل</option></select></label>
|
||||
<label className="text-sm font-bold">وضعیت انتشار<select value={status} onChange={(event) => setStatus(event.target.value as CalendarEventStatus)} className="mt-2 w-full rounded-2xl p-3"><option value="draft">پیشنویس</option><option value="scheduled">زمانبندیشده</option><option value="published">منتشرشده</option></select></label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2"><button type="button" onClick={onCancel} className="rounded-2xl bg-white/25 px-4 py-2 font-bold">انصراف</button><button className="rounded-2xl bg-slate-950 px-5 py-2 font-black text-white dark:bg-white dark:text-slate-950">ذخیره</button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarSettingsSheet({ role, onClose, onSaved }: { role: UserRole; onClose: () => void; onSaved: (settings: CalendarSettings) => void }) {
|
||||
return <div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/25 p-4 backdrop-blur-sm"><section className="liquid-glass max-h-[92vh] w-full max-w-4xl overflow-auto rounded-[30px] p-5 mac-scrollbar"><div className="mb-4 flex items-center justify-between"><h2 className="text-2xl font-black">تنظیمات</h2><button onClick={onClose} className="rounded-2xl bg-white/30 p-2" aria-label="بستن"><X size={18} /></button></div><CalendarSettingsForm role={role} onSaved={onSaved} /></section></div>;
|
||||
}
|
||||
|
||||
export function CalendarSettingsPage({ role, embedded, onSaved }: { role: UserRole; embedded?: boolean; onSaved?: (settings: CalendarSettings) => void }) {
|
||||
return <CalendarShell><div className="mx-auto w-full max-w-5xl overflow-auto mac-scrollbar"><h1 className="text-2xl font-black">تنظیمات تقویم</h1><CalendarSettingsForm role={role} embedded={embedded} onSaved={onSaved} /></div></CalendarShell>;
|
||||
}
|
||||
|
||||
function CalendarSettingsForm({ role, onSaved }: { role: UserRole; embedded?: boolean; onSaved?: (settings: CalendarSettings) => void }) {
|
||||
const [settings, setSettings] = useState<CalendarSettings>(() => getSettings(role));
|
||||
const patch = (payload: Partial<CalendarSettings>) => setSettings((current) => ({ ...current, ...payload }));
|
||||
return (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingSelect label="نمای پیشفرض تقویم" value={settings.defaultView} onChange={(value) => patch({ defaultView: value as CalendarViewMode })} options={["month", "week", "day", "list", "timeline"].map((value) => ({ value, label: viewLabels[value as CalendarViewMode] }))} />
|
||||
<SettingSelect label="زمان یادآوری پیشفرض" value={String(settings.defaultReminderMinutes)} onChange={(value) => patch({ defaultReminderMinutes: Number(value) })} options={[{ value: "15", label: "۱۵ دقیقه قبل" }, { value: "60", label: "۱ ساعت قبل" }, { value: "1440", label: "۱ روز قبل" }]} />
|
||||
<ToggleSetting label="نمایش/عدم نمایش رویدادهای تکمیلشده" checked={settings.showCompletedEvents} onChange={(checked) => patch({ showCompletedEvents: checked })} />
|
||||
<ToggleSetting label="نمایش/عدم نمایش روزهای تعطیل" checked={settings.showWeekends} onChange={(checked) => patch({ showWeekends: checked })} />
|
||||
<SettingSelect label="تراکم تقویم" value={settings.density} onChange={(value) => patch({ density: value as CalendarSettings["density"] })} options={[{ value: "comfortable", label: "راحت" }, { value: "compact", label: "فشرده" }, { value: "dense", label: "متراکم" }]} />
|
||||
<ToggleSetting label="صدای اعلانها" checked={settings.notificationSound} onChange={(checked) => patch({ notificationSound: checked })} />
|
||||
<TextField label="ساعت کاری شروع" value={settings.workingHours.start} onChange={(value) => patch({ workingHours: { ...settings.workingHours, start: value } })} />
|
||||
<TextField label="ساعت کاری پایان" value={settings.workingHours.end} onChange={(value) => patch({ workingHours: { ...settings.workingHours, end: value } })} />
|
||||
</div>
|
||||
<section className="liquid-glass rounded-[24px] p-4">
|
||||
<h3 className="font-black">رنگبندی رویدادها</h3>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-3">{calendarEventTypeDefinitions.slice(0, 9).map((item) => <label key={item.type} className="flex items-center justify-between gap-2 rounded-2xl bg-white/15 p-3 text-sm"><span>{item.labelFa}</span><input type="color" value={settings.eventColorPreferences[item.type] ?? item.color} onChange={(event) => patch({ eventColorPreferences: { ...settings.eventColorPreferences, [item.type]: event.target.value } })} /></label>)}</div>
|
||||
</section>
|
||||
<button onClick={() => onSaved?.(saveSettings(settings))} className="rounded-2xl bg-slate-950 px-5 py-3 font-black text-white dark:bg-white dark:text-slate-950">ذخیره</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarEmptyState() {
|
||||
return <div className="grid h-[360px] place-items-center rounded-3xl border border-dashed border-white/30 bg-white/15 text-center"><div><CalendarDays className="mx-auto opacity-60" size={42} /><h3 className="mt-4 font-black">رویدادی برای این روز وجود ندارد</h3></div></div>;
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, required }: { label: string; value: string; onChange: (value: string) => void; required?: boolean }) {
|
||||
return <label className="text-sm font-bold">{label}<input required={required} value={value} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl border border-white/20 bg-white/20 p-3 outline-none focus:ring-2 focus:ring-sky-400/40" /></label>;
|
||||
}
|
||||
|
||||
function SettingSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[] }) {
|
||||
return <label className="liquid-glass rounded-[24px] p-4 text-sm font-bold">{label}<select value={value} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl p-3">{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
||||
}
|
||||
|
||||
function ToggleSetting({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return <div className="liquid-glass flex items-center justify-between rounded-[24px] p-4 text-sm font-bold"><span>{label}</span><MacToggle checked={checked} onChange={onChange} /></div>;
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value: string }) {
|
||||
return <div className="rounded-2xl bg-white/18 p-3"><p className="text-xs opacity-65">{label}</p><p className="mt-1 text-sm font-black">{toPersianDigits(value)}</p></div>;
|
||||
}
|
||||
|
||||
function Toast({ message }: { message: string }) {
|
||||
return <div className="fixed bottom-6 right-6 z-[70] rounded-2xl bg-slate-950 px-4 py-3 text-sm font-black text-white shadow-2xl dark:bg-white dark:text-slate-950">{message}</div>;
|
||||
}
|
||||
|
||||
export function isPastEvent(event: CalendarEvent) {
|
||||
return new Date(event.endAt).getTime() < Date.now();
|
||||
}
|
||||
|
||||
export function isOngoingEvent(event: CalendarEvent) {
|
||||
const now = Date.now();
|
||||
return new Date(event.startAt).getTime() <= now && new Date(event.endAt).getTime() >= now;
|
||||
}
|
||||
|
||||
export function isFutureEvent(event: CalendarEvent) {
|
||||
return new Date(event.startAt).getTime() > Date.now();
|
||||
}
|
||||
|
||||
export function getEventPrimaryAction(event: CalendarEvent) {
|
||||
if (isPastEvent(event)) {
|
||||
if (event.recordingLink) return { label: "مشاهده ضبط دوره", href: event.recordingLink, disabled: false };
|
||||
return { label: "ضبط در دسترس نیست", disabled: true, description: "ضبط این رویداد هنوز در دسترس نیست" };
|
||||
}
|
||||
if (isOngoingEvent(event)) {
|
||||
if (event.onlineLink) return { label: "ورود به رویداد", href: event.onlineLink, disabled: false };
|
||||
return { label: "ورود در زمان شروع فعال میشود", disabled: true };
|
||||
}
|
||||
return { label: "افزودن یادآوری", disabled: false, description: "یادآوری تنظیم شد" };
|
||||
}
|
||||
|
||||
function getType(type: CalendarEventType) {
|
||||
return calendarEventTypeDefinitions.find((item) => item.type === type) ?? calendarEventTypeDefinitions[0];
|
||||
}
|
||||
|
||||
function buildMonthDays(anchor: Date) {
|
||||
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startOffset = (first.getDay() + 1) % 7;
|
||||
const start = new Date(first);
|
||||
start.setDate(first.getDate() - startOffset);
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return { date, currentMonth: date.getMonth() === anchor.getMonth() };
|
||||
});
|
||||
}
|
||||
|
||||
function buildWeekDays(anchor: Date) {
|
||||
const start = new Date(anchor);
|
||||
start.setDate(anchor.getDate() - ((anchor.getDay() + 1) % 7));
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
}
|
||||
|
||||
function addByView(date: Date, view: CalendarViewMode, direction: -1 | 1) {
|
||||
const next = new Date(date);
|
||||
if (view === "month") next.setMonth(next.getMonth() + direction);
|
||||
else if (view === "week") next.setDate(next.getDate() + direction * 7);
|
||||
else next.setDate(next.getDate() + direction);
|
||||
return next;
|
||||
}
|
||||
|
||||
function isSameDay(value: string | Date, date: string | Date) {
|
||||
const a = new Date(value);
|
||||
const b = new Date(date);
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
function moveEventToDay(event: CalendarEvent, day: Date) {
|
||||
const start = new Date(event.startAt);
|
||||
const end = new Date(event.endAt);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
start.setFullYear(day.getFullYear(), day.getMonth(), day.getDate());
|
||||
const nextEnd = new Date(start.getTime() + duration);
|
||||
return { ...event, startAt: start.toISOString(), endAt: nextEnd.toISOString() };
|
||||
}
|
||||
|
||||
function toYmd(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dateFromYmd(value: string) {
|
||||
return new Date(`${value}T09:00:00`);
|
||||
}
|
||||
|
||||
function toIsoFromParts(date: string, time: string) {
|
||||
return new Date(`${date}T${time}:00`).toISOString();
|
||||
}
|
||||
|
||||
function connectionLabel(value: string) {
|
||||
return ({ course: "دوره", micro_learning: "میکروآموزش", exam: "آزمون", online_class: "کلاس آنلاین", bootcamp: "بوتکمپ", none: "هیچکدام" } as Record<string, string>)[value] ?? "هیچکدام";
|
||||
}
|
||||
|
||||
function connectionValue(label: string) {
|
||||
return ({ "دوره": "course", "میکروآموزش": "micro_learning", "آزمون": "exam", "کلاس آنلاین": "online_class", "بوتکمپ": "bootcamp", "هیچکدام": "none" } as Record<string, string>)[label] ?? "none";
|
||||
}
|
||||
|
||||
function applyRoleScope(events: CalendarEvent[], role: UserRole, mode: CalendarRouteMode) {
|
||||
if (mode === "system" && role !== "super_admin") return [];
|
||||
if (role === "employee") return events.filter((event) => event.roleTargets?.includes("employee") || event.userIds?.length || event.id.startsWith("ev-local"));
|
||||
if (role === "manager") return events.filter((event) => event.roleTargets?.includes("manager") || event.teamIds?.length || event.roleTargets?.includes("employee") || event.id.startsWith("ev-local"));
|
||||
if (role === "org_admin") return events;
|
||||
return events;
|
||||
}
|
||||
|
||||
export const CalendarGridMonth = CalendarMonthView;
|
||||
export const CalendarEventDetailWindow = CalendarEventDetail;
|
||||
export const CalendarEventCreateWizard = CalendarEventCreateForm;
|
||||
export const CalendarSearchBar = Search;
|
||||
export const CalendarMobileView = CalendarDayView;
|
||||
export const CalendarBottomSheet = CalendarEventDetail;
|
||||
export const CalendarMacWindowIntegration = EducationalCalendarPage;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { formatJalaliDate, toPersianDigits } from "@/lib/persianFormat";
|
||||
|
||||
export function PersianDatePicker({ label = "انتخاب تاریخ", value, onChange, required }: { label?: string; value: string; onChange: (value: string) => void; required?: boolean }) {
|
||||
const today = () => {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
};
|
||||
return (
|
||||
<label className="block text-sm font-bold">
|
||||
{label}
|
||||
<input type="date" value={value} required={required} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl border border-white/20 bg-white/20 p-3 outline-none focus:ring-2 focus:ring-sky-400/40" />
|
||||
<span className="mt-2 block text-xs opacity-70">{value ? formatJalaliDate(`${value}T12:00:00`) : "انتخاب تاریخ"}</span>
|
||||
<span className="mt-2 flex gap-2">
|
||||
<button type="button" onClick={() => onChange(today())} className="rounded-xl bg-white/20 px-2 py-1 text-xs">امروز</button>
|
||||
<button type="button" onClick={() => onChange("")} className="rounded-xl bg-white/20 px-2 py-1 text-xs">پاک کردن</button>
|
||||
<button type="button" className="rounded-xl bg-white/20 px-2 py-1 text-xs">تأیید</button>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersianTimePicker({ label = "انتخاب ساعت", value, onChange, required }: { label?: string; value: string; onChange: (value: string) => void; required?: boolean }) {
|
||||
return (
|
||||
<label className="block text-sm font-bold">
|
||||
{label}
|
||||
<input type="time" value={value} required={required} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl border border-white/20 bg-white/20 p-3 outline-none focus:ring-2 focus:ring-sky-400/40" />
|
||||
<span className="mt-2 block text-xs opacity-70">{value ? toPersianDigits(value) : "انتخاب ساعت"}</span>
|
||||
<span className="mt-2 flex gap-2">
|
||||
<button type="button" onClick={() => onChange("")} className="rounded-xl bg-white/20 px-2 py-1 text-xs">پاک کردن</button>
|
||||
<button type="button" className="rounded-xl bg-white/20 px-2 py-1 text-xs">تأیید</button>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersianDateTimePicker({ dateLabel = "انتخاب تاریخ", timeLabel = "انتخاب ساعت", date, time, onDateChange, onTimeChange }: { dateLabel?: string; timeLabel?: string; date: string; time: string; onDateChange: (value: string) => void; onTimeChange: (value: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<PersianDatePicker label={dateLabel} value={date} onChange={onDateChange} required />
|
||||
<PersianTimePicker label={timeLabel} value={time} onChange={onTimeChange} required />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,538 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Archive, BadgeCheck, Copy, Eye, Lock, Send, UploadCloud } from "lucide-react";
|
||||
import type { UserRole } from "@/config/homeAppsConfig";
|
||||
import { courseBuilderExplorerSections } from "@/config/courseBuilderExplorerConfig";
|
||||
import { microBlockDefinitions, microLearningTemplatesFa } from "@/config/microLearningConfig";
|
||||
import { formatToman, toPersianDigits } from "@/lib/persianFormat";
|
||||
import { changeCourseBuilderCourseStatus, fetchCourseBuilderData } from "@/services/api/courseBuilderService";
|
||||
import { microLearningService } from "@/services/api/microLearningService";
|
||||
import type { Course, CourseBuilderRole, CourseWizardDraft } from "@/types/courseBuilder";
|
||||
import type { MicroLearning, MicroLearningStatus, MicroLearningStep, MicroBlockType } from "@/types/microLearning";
|
||||
import { CompactExplorerChrome } from "@/components/shared/CompactExplorerChrome";
|
||||
import { UploadDropzone } from "@/components/uploads/UploadDropzone";
|
||||
import { CourseBuilderExplorerGrid } from "./CourseBuilderExplorerGrid";
|
||||
import type { UploadedFile } from "@/types/upload";
|
||||
import { MicroPuzzleSettingsCard, defaultMicroPuzzleSettings } from "@/components/micro-learning/MicroPuzzleSettingsCard";
|
||||
import type { MicroPuzzleSettings } from "@/types/microLearning";
|
||||
|
||||
const CourseWizard = dynamic(() => import("./CourseWizard").then((module) => module.CourseWizard), {
|
||||
ssr: false,
|
||||
loading: () => <div className="p-5 text-sm font-bold">در حال آمادهسازی سازنده دوره...</div>
|
||||
});
|
||||
|
||||
type CourseBuilderData = {
|
||||
courses: Course[];
|
||||
draft: CourseWizardDraft;
|
||||
micros: MicroLearning[];
|
||||
};
|
||||
|
||||
const courseSectionIds = ["course-list", "new-course", "course-drafts", "course-published"];
|
||||
const microSectionIds = ["micro-list", "new-micro", "micro-drafts", "micro-published", "micro-templates"];
|
||||
const statusFa: Record<string, string> = { draft: "پیشنویس", ready_to_publish: "آماده انتشار", published: "منتشرشده", inactive: "غیرفعال", archived: "آرشیوشده", pending_approval: "در انتظار تأیید", needs_revision: "نیازمند اصلاح", approved: "تأییدشده" };
|
||||
const levelFa = { beginner: "مقدماتی", intermediate: "متوسط", advanced: "پیشرفته" } as const;
|
||||
|
||||
export function CourseBuilderExplorer({ role }: { role: UserRole }) {
|
||||
if (role !== "super_admin" && role !== "org_admin") return <UnauthorizedCourseBuilder />;
|
||||
return <AuthorizedCourseBuilderExplorer role={role} />;
|
||||
}
|
||||
|
||||
function AuthorizedCourseBuilderExplorer({ role }: { role: CourseBuilderRole }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [tabs, setTabs] = useState<string[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [data, setData] = useState<CourseBuilderData | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; tone: "success" | "error" } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchCourseBuilderData(role).then((next) => {
|
||||
if (alive) setData({ ...next, micros: microLearningService.list() });
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [role]);
|
||||
|
||||
const openTab = useCallback((id: string) => {
|
||||
setTabs((items) => items.includes(id) ? items : [...items, id]);
|
||||
setActiveTabId(id);
|
||||
}, []);
|
||||
const closeTab = useCallback((id: string) => {
|
||||
setTabs((items) => {
|
||||
const next = items.filter((item) => item !== id);
|
||||
setActiveTabId((current) => current === id ? next.at(-1) ?? null : current);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const showToast = useCallback((message: string, tone: "success" | "error" = "success") => {
|
||||
setToast({ message, tone });
|
||||
window.setTimeout(() => setToast(null), 2600);
|
||||
}, []);
|
||||
const updateMicros = useCallback((micros: MicroLearning[]) => setData((current) => current ? { ...current, micros } : current), []);
|
||||
const updateCourses = useCallback((courses: Course[]) => setData((current) => current ? { ...current, courses } : current), []);
|
||||
|
||||
const filteredSections = useMemo(() => courseBuilderExplorerSections.filter((section) => section.roleAccess.includes(role) && (!query.trim() || section.labelFa.includes(query.trim()))), [query, role]);
|
||||
const activeTitle = courseBuilderExplorerSections.find((item) => item.id === activeTabId)?.labelFa ?? "";
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden rounded-[28px] border border-white/15 bg-white/10 shadow-2xl backdrop-blur-2xl" dir="rtl">
|
||||
<CompactExplorerChrome titleFa="مدیریت دورهها" tabs={tabs.map((id) => ({ id, titleFa: courseBuilderExplorerSections.find((item) => item.id === id)?.labelFa ?? id }))} activeTabId={activeTabId} query={query} searchPlaceholder="جستجو در بخشها..." onQuery={setQuery} onHome={() => setActiveTabId(null)} onSelectTab={setActiveTabId} onCloseTab={closeTab}>
|
||||
{!data ? <LoadingGrid /> : null}
|
||||
{data && !activeTabId ? <ManagementHome sections={filteredSections} role={role} onOpen={openTab} /> : null}
|
||||
{data && activeTabId === "new-course" ? <CourseWizard initialDraft={data.draft} role={role} onSaved={(draft, message) => { setData((current) => current ? { ...current, draft, courses: [draft.course, ...current.courses.filter((course) => course.id !== draft.course.id)] } : current); showToast(message); }} /> : null}
|
||||
{data && ["course-list", "course-drafts", "course-published"].includes(activeTabId ?? "") ? <CourseManagementList titleFa={activeTitle} courses={data.courses} filter={activeTabId} role={role} onCourses={updateCourses} onToast={showToast} /> : null}
|
||||
{data && activeTabId === "new-micro" ? <MicroLearningBuilder role={role} micros={data.micros} onMicros={updateMicros} onToast={showToast} /> : null}
|
||||
{data && ["micro-list", "micro-drafts", "micro-published"].includes(activeTabId ?? "") ? <MicroLearningAdminList titleFa={activeTitle} micros={data.micros} filter={activeTabId} role={role} onMicros={updateMicros} onToast={showToast} /> : null}
|
||||
{data && activeTabId === "micro-templates" ? <MicroLearningTemplates onUse={() => { openTab("new-micro"); showToast("قالب انتخاب شد و قابل ویرایش است."); }} /> : null}
|
||||
</CompactExplorerChrome>
|
||||
{toast ? <div className={`fixed bottom-5 left-5 z-[80] rounded-2xl px-4 py-3 text-sm font-bold text-white shadow-2xl ${toast.tone === "success" ? "bg-emerald-500" : "bg-red-500"}`}>{toast.message}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagementHome({ sections, role, onOpen }: { sections: typeof courseBuilderExplorerSections; role: CourseBuilderRole; onOpen: (id: string) => void }) {
|
||||
const courses = sections.filter((item) => courseSectionIds.includes(item.id));
|
||||
const micros = sections.filter((item) => microSectionIds.includes(item.id));
|
||||
return (
|
||||
<div className="space-y-5 p-5">
|
||||
<section className="rounded-[24px] border border-white/15 bg-white/10 p-4">
|
||||
<p className="text-sm opacity-75">{role === "super_admin" ? "مدیریت دورهها و میکروآموزشهای عمومی سامانه و سازمانها" : "مدیریت دورهها و میکروآموزشهای سازمان شما"}</p>
|
||||
</section>
|
||||
<ExplorerGroup titleFa="دورهها" items={courses} onOpen={onOpen} />
|
||||
<ExplorerGroup titleFa="میکروآموزش" items={micros} onOpen={onOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplorerGroup({ titleFa, items, onOpen }: { titleFa: string; items: typeof courseBuilderExplorerSections; onOpen: (id: string) => void }) {
|
||||
return <section className="space-y-3"><div className="flex items-center gap-2"><span className="h-2 w-2 rounded-full bg-emerald-400" /><h2 className="font-black">{titleFa}</h2></div><CourseBuilderExplorerGrid sections={items} onOpen={onOpen} /></section>;
|
||||
}
|
||||
|
||||
const CourseManagementList = memo(function CourseManagementList({ titleFa, courses, filter, role, onCourses, onToast }: { titleFa: string; courses: Course[]; filter: string | null; role: CourseBuilderRole; onCourses: (courses: Course[]) => void; onToast: (message: string, tone?: "success" | "error") => void }) {
|
||||
const [view, setView] = useState<"card" | "list">("card");
|
||||
const [status, setStatus] = useState("همه");
|
||||
const rows = useMemo(() => courses.filter((course) => {
|
||||
const scope = role === "super_admin" || course.ownerType === "organization";
|
||||
const bySection = filter === "course-drafts" ? course.status === "draft" : filter === "course-published" ? course.status === "published" : true;
|
||||
const byStatus = status === "همه" || statusFa[course.status] === status;
|
||||
return scope && bySection && byStatus;
|
||||
}), [courses, filter, role, status]);
|
||||
const filters = ["همه", "پیشنویس", "منتشرشده", "آرشیوشده"];
|
||||
return (
|
||||
<div className="space-y-4 p-5">
|
||||
<ToolbarLine titleFa={titleFa || "لیست دورهها"} actions={<><button onClick={() => setView(view === "card" ? "list" : "card")} className="rounded-2xl bg-white/20 px-3 py-2 text-xs">{view === "card" ? "نمای فهرست" : "نمای کارت"}</button>{filters.map((item) => <button key={item} onClick={() => setStatus(item)} className={`rounded-2xl px-3 py-2 text-xs ${status === item ? "bg-white/35 font-black" : "bg-white/15"}`}>{item}</button>)}</>} />
|
||||
{view === "card" ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rows.map((course) => <CourseCard key={course.id} course={course} role={role} onCourses={onCourses} onToast={onToast} />)}</div> : <DataTable headers={["عنوان", "دسته", "سطح", "وضعیت", "نوع", "قیمت"]} rows={rows.map((course) => [course.titleFa, course.categoryFa ?? "عمومی", levelFa[course.level], statusFa[course.status], course.ownerType === "platform" ? "عمومی" : "سازمانی", course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"])} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CourseCard = memo(function CourseCard({ course, role, onCourses, onToast }: { course: Course; role: CourseBuilderRole; onCourses: (courses: Course[]) => void; onToast: (message: string, tone?: "success" | "error") => void }) {
|
||||
const publicLocked = role === "org_admin" && course.ownerType === "platform" && course.pricingType === "paid";
|
||||
const changeStatus = async (status: Course["status"], message: string) => {
|
||||
const next = await changeCourseBuilderCourseStatus(course.id, status);
|
||||
onCourses(role === "super_admin" ? next : next.filter((item) => item.ownerType === "organization"));
|
||||
onToast(message);
|
||||
};
|
||||
return (
|
||||
<article className="liquid-glass rounded-[22px] p-4">
|
||||
<div className="h-24 rounded-[18px] bg-gradient-to-l from-emerald-300/50 via-sky-300/45 to-fuchsia-400/35" />
|
||||
<h3 className="mt-3 font-black">{course.titleFa}</h3>
|
||||
<p className="mt-2 text-sm opacity-70">{course.categoryFa} • {levelFa[course.level]} • {statusFa[course.status]}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">{course.tags.map((tag) => <span key={tag} className="rounded-full bg-white/15 px-2 py-1 text-xs">{tag}</span>)}</div>
|
||||
<p className="mt-3 text-sm">{course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"} • {course.ownerType === "platform" ? "عمومی سامانه" : "اختصاصی سازمان"}</p>
|
||||
{publicLocked ? <p className="mt-3 rounded-2xl bg-amber-400/20 p-3 text-xs text-amber-700 dark:text-amber-200">برای کپی این دوره عمومی، پرداخت سازمان لازم است.</p> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Action onClick={() => onToast("جزئیات دوره آماده نمایش است.")} icon={<Eye className="h-3.5 w-3.5" />} label="جزئیات" />
|
||||
<Action onClick={() => onToast(publicLocked ? "پرداخت سازمان برای کپی این دوره لازم است." : "دوره کپی شد.", publicLocked ? "error" : "success")} disabled={publicLocked} icon={publicLocked ? <Lock className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} label="کپی" />
|
||||
<Action onClick={() => changeStatus("published", "دوره منتشر شد.")} icon={<UploadCloud className="h-3.5 w-3.5" />} label="انتشار" tone="green" />
|
||||
<Action onClick={() => changeStatus("archived", "دوره آرشیو شد.")} icon={<Archive className="h-3.5 w-3.5" />} label="آرشیو" tone="red" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs"><span className="rounded-xl bg-white/12 p-2">تکمیل ۷۶٪</span><span className="rounded-xl bg-white/12 p-2">نمره ۸۴٪</span><span className="rounded-xl bg-white/12 p-2">گزارش</span></div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
function MicroLearningAdminList({ titleFa, micros, filter, role, onMicros, onToast }: { titleFa: string; micros: MicroLearning[]; filter: string | null; role: CourseBuilderRole; onMicros: (items: MicroLearning[]) => void; onToast: (message: string, tone?: "success" | "error") => void }) {
|
||||
const rows = micros.filter((micro) => filter === "micro-drafts" ? micro.status === "draft" : filter === "micro-published" ? micro.status === "published" : true);
|
||||
const patchStatus = async (id: string, status: MicroLearningStatus, message: string) => {
|
||||
const next = await microLearningService.changeStatus(id, status);
|
||||
onMicros(next);
|
||||
onToast(message);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4 p-5">
|
||||
<ToolbarLine titleFa={titleFa || "لیست میکروها"} actions={<><button className="rounded-2xl bg-white/20 px-3 py-2 text-xs">فیلتر وضعیت</button><button className="rounded-2xl bg-white/20 px-3 py-2 text-xs">فیلتر قالب</button><button className="rounded-2xl bg-white/20 px-3 py-2 text-xs">{role === "super_admin" ? "فیلتر سازمان" : "مخاطبان سازمان"}</button></>} />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((micro) => (
|
||||
<article key={micro.id} className="liquid-glass rounded-[22px] p-4">
|
||||
<h3 className="font-black">{micro.titleFa}</h3>
|
||||
<p className="mt-2 text-xs leading-6 opacity-70">{micro.descriptionFa}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 text-[11px]"><span className="rounded-full bg-white/15 px-2.5 py-1">{statusFa[micro.status]}</span><span className="rounded-full bg-white/15 px-2.5 py-1">{toPersianDigits(micro.steps.length)} مرحله</span><span className="rounded-full bg-white/15 px-2.5 py-1">{toPersianDigits(micro.estimatedMinutes)} دقیقه</span><span className="rounded-full bg-white/15 px-2.5 py-1">{micro.isPaid ? formatToman(micro.priceToman ?? 0) : "رایگان"}</span></div>
|
||||
{micro.steps.length < micro.minSteps ? <p className="mt-3 rounded-2xl bg-red-500/15 p-3 text-xs text-red-600">حداقل ۵ مرحله برای ارسال یا انتشار لازم است.</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Action label="پیشنمایش" icon={<Eye className="h-3.5 w-3.5" />} onClick={() => onToast("پیشنمایش میکروآموزش آماده است.")} />
|
||||
<Action label="ارسال برای تأیید" icon={<Send className="h-3.5 w-3.5" />} tone="green" disabled={micro.steps.length < micro.minSteps} onClick={() => patchStatus(micro.id, "pending_approval", "میکروآموزش برای تأیید ارسال شد.")} />
|
||||
<Action label="تأیید" icon={<BadgeCheck className="h-3.5 w-3.5" />} tone="green" onClick={() => patchStatus(micro.id, "approved", "میکروآموزش تأیید شد.")} />
|
||||
<Action label="انتشار" icon={<UploadCloud className="h-3.5 w-3.5" />} tone="green" disabled={micro.status !== "approved"} onClick={() => patchStatus(micro.id, "published", "میکروآموزش منتشر شد.")} />
|
||||
<Action label="آرشیو" icon={<Archive className="h-3.5 w-3.5" />} tone="red" onClick={() => patchStatus(micro.id, "archived", "میکروآموزش آرشیو شد.")} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs"><span className="rounded-xl bg-white/12 p-2">تکمیل ۶۸٪</span><span className="rounded-xl bg-white/12 p-2">کوییز ۸۱٪</span><span className="rounded-xl bg-white/12 p-2">گزارش</span></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MicroLearningBuilder({ role, micros, onMicros, onToast }: { role: CourseBuilderRole; micros: MicroLearning[]; onMicros: (items: MicroLearning[]) => void; onToast: (message: string, tone?: "success" | "error") => void }) {
|
||||
const [titleFa, setTitleFa] = useState("میکروآموزش جدید");
|
||||
const [microId, setMicroId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [autosave, setAutosave] = useState("ذخیره شد");
|
||||
const [puzzle, setPuzzle] = useState<MicroPuzzleSettings>(defaultMicroPuzzleSettings);
|
||||
const autosaveTimerRef = useRef<number | null>(null);
|
||||
const [steps, setSteps] = useState<MicroLearningStep[]>([
|
||||
{ id: "step-1", order: 1, blockType: "heading", titleFa: "شروع یادگیری", payload: defaultMicroPayload("heading"), isValid: true, estimatedSeconds: 30 },
|
||||
{ id: "step-2", order: 2, blockType: "short_text", titleFa: "توضیح کوتاه", payload: { textFa: "متن آموزشی" }, isValid: true, estimatedSeconds: 35 },
|
||||
{ id: "step-3", order: 3, blockType: "short_text", titleFa: "نکته کاربردی", payload: { ...defaultMicroPayload("short_text"), textFa: "نکته مهم را کوتاه و قابل اجرا بنویسید.", styleFa: "نکته" }, isValid: true, estimatedSeconds: 25 },
|
||||
{ id: "step-4", order: 4, blockType: "single_choice", titleFa: "سؤال کوتاه", payload: defaultMicroPayload("single_choice"), isValid: true, estimatedSeconds: 45 },
|
||||
{ id: "step-5", order: 5, blockType: "summary", titleFa: "جمعبندی", payload: { textFa: "مرور نهایی" }, isValid: true, estimatedSeconds: 25 }
|
||||
]);
|
||||
const selected = useMemo(() => steps.find((item) => item.id === selectedId) ?? null, [selectedId, steps]);
|
||||
const puzzleReady = !puzzle.enabled || Boolean(puzzle.image?.objectUrl);
|
||||
const canPublish = useMemo(() => steps.length >= 5 && steps.every((item) => item.isValid) && puzzleReady, [puzzleReady, steps]);
|
||||
const scheduleAutosave = useCallback(() => {
|
||||
setAutosave("در حال ذخیره...");
|
||||
if (autosaveTimerRef.current) window.clearTimeout(autosaveTimerRef.current);
|
||||
autosaveTimerRef.current = window.setTimeout(() => {
|
||||
setAutosave("ذخیره شد");
|
||||
autosaveTimerRef.current = null;
|
||||
}, 1000);
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
if (autosaveTimerRef.current) window.clearTimeout(autosaveTimerRef.current);
|
||||
}, []);
|
||||
const addBlock = useCallback((type: MicroBlockType) => {
|
||||
if (steps.length >= 20) {
|
||||
onToast("حداکثر ۲۰ مرحله مجاز است.", "error");
|
||||
return;
|
||||
}
|
||||
const definition = microBlockDefinitions.find((item) => item.type === type);
|
||||
const payload = defaultMicroPayload(type);
|
||||
const next: MicroLearningStep = { id: `step-${Date.now()}`, order: steps.length + 1, blockType: type, titleFa: definition?.titleFa ?? "بلاک جدید", payload, isValid: validateMicroPayload(type, payload).length === 0, estimatedSeconds: 30 };
|
||||
setSteps((items) => [...items, next]);
|
||||
setSelectedId(next.id);
|
||||
scheduleAutosave();
|
||||
}, [onToast, scheduleAutosave, steps.length]);
|
||||
const patchStep = useCallback((id: string, patch: Partial<MicroLearningStep>) => {
|
||||
setSteps((items) => items.map((item) => {
|
||||
const next = item.id === id ? { ...item, ...patch } : item;
|
||||
return item.id === id ? { ...next, isValid: validateMicroStep(next).length === 0 } : item;
|
||||
}));
|
||||
scheduleAutosave();
|
||||
}, [scheduleAutosave]);
|
||||
const move = (id: string, direction: -1 | 1) => {
|
||||
const index = steps.findIndex((item) => item.id === id);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= steps.length) return;
|
||||
const next = [...steps];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setSteps(next.map((item, order) => ({ ...item, order: order + 1 })));
|
||||
};
|
||||
const saveMicro = (status: MicroLearningStatus, message: string) => {
|
||||
const id = microId ?? `micro-${Date.now()}`;
|
||||
const normalizedPuzzle = puzzle.enabled ? { ...puzzle, completionMessage: puzzle.completionMessage?.trim() || "تبریک! پازل یادگیری شما کامل شد.", revealMessage: puzzle.revealMessage?.trim() || "یک تکه از پازل باز شد" } : { ...puzzle, enabled: false };
|
||||
const micro: MicroLearning = { id, titleFa: titleFa.trim() || "میکروآموزش جدید", descriptionFa: "ساختهشده با میکرو استودیو", status, estimatedMinutes: Math.max(1, Math.round(steps.reduce((sum, item) => sum + (item.estimatedSeconds ?? 30), 0) / 60)), minSteps: 5, maxSteps: 20, isPaid: false, createdBy: role === "super_admin" ? "مدیر کل سامانه" : "مدیر آموزش سازمان", createdByRole: role, organizationId: role === "org_admin" ? "org-1" : undefined, audience: [{ id: "aud-new", type: "organization", targetId: "org-1", targetTitleFa: "کل سازمان" }], steps, puzzle: normalizedPuzzle };
|
||||
microLearningService.create(micro).then((next) => {
|
||||
setMicroId(id);
|
||||
onMicros(next);
|
||||
onToast(message);
|
||||
});
|
||||
};
|
||||
const saveDraft = () => saveMicro("draft", "میکروآموزش ذخیره شد و در لیست میکروها قرار گرفت.");
|
||||
return (
|
||||
<div className="grid h-full min-h-[640px] grid-cols-[260px_minmax(360px,1fr)_300px] gap-4 p-5 max-xl:grid-cols-1">
|
||||
<aside className="liquid-glass min-h-0 overflow-auto rounded-[24px] p-4">
|
||||
<h3 className="font-black">بلاکها</h3>
|
||||
<div className="mt-3 space-y-4">{["content", "media", "interactive", "quiz", "feedback"].map((category) => <div key={category}><p className="mb-2 text-xs font-bold opacity-65">{categoryLabel(category)}</p><div className="grid gap-2">{microBlockDefinitions.filter((item) => item.category === category).map((block) => <button key={block.type} onClick={() => addBlock(block.type)} className="rounded-2xl bg-white/12 p-3 text-right transition hover:bg-white/20"><span className={`inline-block h-3 w-3 rounded-full bg-gradient-to-br ${block.colorClass}`} /> <b className="text-sm">{block.titleFa}</b><p className="mt-1 text-[11px] opacity-65">{block.descriptionFa}</p></button>)}</div></div>)}</div>
|
||||
</aside>
|
||||
<main className="liquid-glass min-h-0 overflow-auto rounded-[24px] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<input value={titleFa} onChange={(event) => setTitleFa(event.target.value)} className="min-w-52 rounded-2xl bg-white/15 px-4 py-2 font-black outline-none" />
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs"><span className="rounded-full bg-white/15 px-3 py-1">{autosave}</span><span className="rounded-full bg-white/15 px-3 py-1">{toPersianDigits(steps.length)} از ۲۰ مرحله</span><button onClick={() => setPreview(true)} className="rounded-2xl bg-white/20 px-3 py-2">اجرای آزمایشی</button><button onClick={saveDraft} className="rounded-2xl bg-emerald-500 px-3 py-2 font-black text-white">ذخیره پیشنویس</button><button disabled={!canPublish} onClick={() => saveMicro("pending_approval", "میکروآموزش ذخیره شد و برای تأیید ارسال شد.")} className="rounded-2xl bg-emerald-500 px-3 py-2 font-black text-white disabled:opacity-45">ارسال برای تأیید</button></div>
|
||||
</div>
|
||||
{steps.length < 5 ? <p className="mt-3 rounded-2xl bg-red-500/15 p-3 text-xs text-red-600">برای انتشار یا ارسال تأیید، حداقل ۵ مرحله لازم است.</p> : null}
|
||||
{!puzzleReady ? <p className="mt-3 rounded-2xl bg-red-500/15 p-3 text-xs text-red-600">برای فعالسازی پازل یادگیری، آپلود تصویر پازل الزامی است.</p> : null}
|
||||
<div className="mt-4 space-y-3">{steps.map((step, index) => <MicroStepCard key={step.id} step={step} index={index} selected={selectedId === step.id} total={steps.length} onSelect={setSelectedId} onMove={move} onDuplicate={(copy) => { setSteps((items) => [...items, copy]); setSelectedId(copy.id); scheduleAutosave(); }} onDelete={(id) => { setSteps((items) => items.filter((item) => item.id !== id)); scheduleAutosave(); }} />)}</div>
|
||||
</main>
|
||||
<aside className="liquid-glass min-h-0 overflow-auto rounded-[24px] p-4">
|
||||
<h3 className="font-black">تنظیمات بلاک</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
<ConnectedExamMiniCard />
|
||||
<MicroPuzzleSettingsCard puzzle={puzzle} stepsCount={steps.length} onChange={(next) => { setPuzzle(next); scheduleAutosave(); }} />
|
||||
{selected ? <MicroBlockSettings step={selected} onPatch={(patch) => patchStep(selected.id, patch)} onDelete={() => setSteps((items) => items.filter((item) => item.id !== selected.id))} onDuplicate={() => { const copy = { ...selected, id: `step-${Date.now()}`, titleFa: `کپی ${selected.titleFa}`, order: steps.length + 1 }; setSteps((items) => [...items, copy]); setSelectedId(copy.id); }} /> : <p className="rounded-2xl bg-white/10 p-4 text-sm leading-7 opacity-75">یک بلاک را انتخاب کنید تا تنظیمات آن نمایش داده شود.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
{preview ? <MicroPreview steps={steps} onClose={() => setPreview(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MicroStepCard = memo(function MicroStepCard({ step, index, selected, total, onSelect, onMove, onDuplicate, onDelete }: { step: MicroLearningStep; index: number; selected: boolean; total: number; onSelect: (id: string) => void; onMove: (id: string, direction: -1 | 1) => void; onDuplicate: (copy: MicroLearningStep) => void; onDelete: (id: string) => void }) {
|
||||
const errors = useMemo(() => validateMicroStep(step), [step]);
|
||||
const preview = useMemo(() => microStepPreview(step), [step]);
|
||||
return <article onClick={() => onSelect(step.id)} className={`rounded-[20px] border p-4 transition ${selected ? "border-emerald-400 bg-emerald-400/10" : "border-white/15 bg-white/10"}`}><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="text-xs opacity-65">مرحله {toPersianDigits(index + 1)} • {toPersianDigits(step.estimatedSeconds ?? 30)} ثانیه</p><h4 className="mt-1 font-black">{step.titleFa}</h4><p className={`mt-1 text-xs ${errors.length ? "text-red-500" : "opacity-65"}`}>{errors[0] ?? "کامل و معتبر"}</p><p className="mt-2 text-xs opacity-70">{preview}</p></div><div className="flex gap-2"><button onClick={(event) => { event.stopPropagation(); onMove(step.id, -1); }} className="rounded-xl bg-white/15 px-3 py-1 text-xs">بالا</button><button onClick={(event) => { event.stopPropagation(); onMove(step.id, 1); }} className="rounded-xl bg-white/15 px-3 py-1 text-xs">پایین</button><button onClick={(event) => { event.stopPropagation(); onDuplicate({ ...step, id: `step-${Date.now()}`, titleFa: `کپی ${step.titleFa}`, order: total + 1 }); }} className="rounded-xl bg-white/15 px-3 py-1 text-xs">کپی</button><button onClick={(event) => { event.stopPropagation(); onDelete(step.id); }} className="rounded-xl bg-red-500 px-3 py-1 text-xs font-bold text-white">حذف</button></div></div></article>;
|
||||
});
|
||||
|
||||
const MicroPreview = memo(function MicroPreview({ steps, onClose }: { steps: MicroLearningStep[]; onClose: () => void }) {
|
||||
const visibleSteps = useMemo(() => steps.filter((step) => step.payload.showInPreview !== false), [steps]);
|
||||
return <div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/50 p-5"><section className="w-full max-w-lg rounded-[28px] bg-white/90 p-5 text-slate-900 shadow-2xl dark:bg-slate-950/90 dark:text-white"><div className="flex items-center justify-between"><h3 className="font-black">پیشنمایش زنده</h3><button onClick={onClose} className="rounded-2xl bg-red-500 px-4 py-2 text-sm font-bold text-white">بستن</button></div><div className="mt-4 space-y-2">{visibleSteps.map((step, index) => <div key={step.id} className="rounded-2xl bg-white/15 p-3 text-sm">{toPersianDigits(index + 1)}. {step.titleFa}</div>)}</div></section></div>;
|
||||
});
|
||||
|
||||
function MicroLearningTemplates({ onUse }: { onUse: () => void }) {
|
||||
return <div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4 p-5">{microLearningTemplatesFa.map((template) => <article key={template} className="liquid-glass rounded-[22px] p-4"><h3 className="font-black">{template}</h3><p className="mt-2 text-xs leading-6 opacity-70">قالب آماده با ۵ مرحله اولیه و قابل ویرایش پس از انتخاب.</p><div className="mt-4 flex gap-2"><button onClick={onUse} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-black text-white">استفاده از قالب</button><button className="rounded-2xl bg-white/20 px-3 py-2 text-xs">پیشنمایش</button></div></article>)}</div>;
|
||||
}
|
||||
|
||||
function ToolbarLine({ titleFa, actions }: { titleFa: string; actions: React.ReactNode }) {
|
||||
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-[22px] bg-white/10 p-3"><h2 className="font-black">{titleFa}</h2><div className="flex flex-wrap gap-2">{actions}</div></div>;
|
||||
}
|
||||
|
||||
function Action({ label, icon, onClick, tone = "neutral", disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone?: "neutral" | "green" | "red"; disabled?: boolean }) {
|
||||
const cls = tone === "green" ? "bg-emerald-500 text-white" : tone === "red" ? "bg-red-500 text-white" : "bg-white/15";
|
||||
return <button disabled={disabled} onClick={onClick} className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-bold disabled:opacity-45 ${cls}`}>{icon}{label}</button>;
|
||||
}
|
||||
|
||||
function DataTable({ headers, rows }: { headers: string[]; rows: string[][] }) {
|
||||
return <div className="liquid-glass overflow-auto rounded-[22px]"><table className="w-full min-w-[720px] text-sm"><thead><tr className="bg-white/15">{headers.map((item) => <th key={item} className="p-3 text-right">{item}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={index} className="border-t border-white/10">{row.map((cell) => <td key={cell} className="p-3">{cell}</td>)}</tr>)}</tbody></table></div>;
|
||||
}
|
||||
|
||||
function LoadingGrid() {
|
||||
return <div className="grid gap-4 p-5 md:grid-cols-3">{Array.from({ length: 6 }).map((_, index) => <div key={index} className="liquid-glass h-28 animate-pulse rounded-[22px]" />)}</div>;
|
||||
}
|
||||
|
||||
function UnauthorizedCourseBuilder() {
|
||||
return <div className="grid h-full place-items-center p-6" dir="rtl"><div className="liquid-glass max-w-md rounded-[28px] p-6 text-center"><h2 className="text-xl font-black">دسترسی غیرمجاز</h2><p className="mt-3 text-sm opacity-75">شما به این بخش دسترسی ندارید.</p></div></div>;
|
||||
}
|
||||
|
||||
function categoryLabel(category: string) {
|
||||
const labels: Record<string, string> = { content: "محتوا", media: "رسانه", interactive: "تعاملی", quiz: "کوییز", feedback: "بازخورد" };
|
||||
return labels[category] ?? category;
|
||||
}
|
||||
|
||||
function ConnectedExamMiniCard() {
|
||||
return <section className="rounded-2xl border border-white/10 bg-white/10 p-3"><div className="flex items-center justify-between gap-2"><h4 className="text-sm font-black">آزمون متصل</h4><span className="rounded-full bg-amber-500/20 px-2 py-1 text-[11px] text-amber-600">بدون اتصال</span></div><p className="mt-2 text-xs leading-6 opacity-70">برای این میکروآموزش میتوانید آزمون موجود انتخاب کنید یا آزمون جدید بسازید.</p><div className="mt-3 grid grid-cols-2 gap-2 text-[11px]"><button className="rounded-xl bg-white/15 px-2 py-2">انتخاب آزمون</button><button className="rounded-xl bg-emerald-500 px-2 py-2 font-bold text-white">ساخت آزمون</button><button className="rounded-xl bg-white/15 px-2 py-2">تغییر آزمون</button><button className="rounded-xl bg-red-500 px-2 py-2 font-bold text-white">حذف اتصال</button></div><button className="mt-2 w-full rounded-xl bg-white/15 px-2 py-2 text-[11px]">مشاهده وضعیت آزمون</button></section>;
|
||||
}
|
||||
|
||||
function defaultMicroPayload(type: MicroBlockType): Record<string, unknown> {
|
||||
const base = { isRequired: true, completionRuleFa: "مشاهده" };
|
||||
const map: Record<MicroBlockType, Record<string, unknown>> = {
|
||||
heading: { ...base, headingTextFa: "عنوان آموزشی", sizeFa: "متوسط", alignFa: "راست", subtitleFa: "" },
|
||||
short_text: { ...base, textFa: "متن کوتاه آموزشی", styleFa: "ساده", bulletList: false, emphasisFa: "معمولی", textSizeFa: "متوسط" },
|
||||
image: { ...base, imageUrlFa: "", altFa: "", captionFa: "", displaySizeFa: "متوسط", fitModeFa: "پوشش" },
|
||||
video: { ...base, videoUrlFa: "", posterFa: "", durationFa: "", completionRuleFa: "مشاهده کامل", captionFa: "" },
|
||||
audio: { ...base, audioUrlFa: "", audioTitleFa: "", durationFa: "", completionRuleFa: "شنیدن کامل", transcriptFa: "" },
|
||||
file: { ...base, fileNameFa: "", fileTypeFa: "پیدیاف", fileTitleFa: "", descriptionFa: "", completionRuleFa: "دانلود" },
|
||||
quote: { ...base, quoteFa: "نقلقول آموزشی", speakerFa: "", speakerRoleFa: "", visualStyleFa: "ساده" },
|
||||
key_point: { ...base, keyPointFa: "نکته کلیدی", importanceFa: "مهم", iconStyleFa: "چراغ", explanationFa: "" },
|
||||
flashcard: { ...base, frontFa: "روی کارت", backFa: "پشت کارت", imageFa: "", requireFlip: true },
|
||||
single_choice: { ...base, questionFa: "پرسش نمونه", optionsFa: "گزینه اول\nگزینه دوم", correctFa: "گزینه اول", scoreFa: "۱۰", correctFeedbackFa: "درست است.", wrongFeedbackFa: "دوباره تلاش کنید.", shuffle: true },
|
||||
true_false: { ...base, questionFa: "عبارت نمونه درست است؟", correctFa: "درست", scoreFa: "۵", correctFeedbackFa: "درست است.", wrongFeedbackFa: "اشتباه است." },
|
||||
multiple_choice: { ...base, questionFa: "چند گزینه درست را انتخاب کنید", optionsFa: "گزینه اول\nگزینه دوم\nگزینه سوم", correctFa: "گزینه اول\nگزینه دوم", minSelectionsFa: "۱", scoreFa: "۱۰", correctFeedbackFa: "درست است.", wrongFeedbackFa: "بازبینی کنید.", shuffle: true },
|
||||
fill_blank: { ...base, textFa: "مدیریت ____ یعنی انتخاب آگاهانه.", answersFa: "زمان", caseSensitive: false, scoreFa: "۱۰", feedbackFa: "پاسخ بررسی شد." },
|
||||
matching: { ...base, leftFa: "اولویت\nتمرکز", rightFa: "کار مهم\nبازه زمانی", pairsFa: "اولویت=کار مهم\nتمرکز=بازه زمانی", scoreFa: "۱۰", shuffle: true, feedbackFa: "" },
|
||||
drag_drop: { ...base, itemsFa: "کار مهم\nکار کماثر", zonesFa: "اول\nبعد", mappingFa: "کار مهم=اول\nکار کماثر=بعد", scoreFa: "۱۰", feedbackFa: "" },
|
||||
hotspot: { ...base, imageFa: "", hotspotsFa: "نقطه اول: توضیح کوتاه", requireAll: true },
|
||||
ordering: { ...base, itemsFa: "برنامهریزی\nاجرا\nمرور", correctOrderFa: "برنامهریزی\nاجرا\nمرور", scoreFa: "۱۰", feedbackFa: "" },
|
||||
timeline: { ...base, itemsFa: "گام اول: شروع\nگام دوم: تمرین", descriptionFa: "" },
|
||||
checklist: { ...base, itemsFa: "کار اول\nکار دوم", requireAll: true, explanationFa: "", completionMessageFa: "چکلیست کامل شد." },
|
||||
scenario: { ...base, scenarioFa: "یک موقعیت کاری کوتاه", optionsFa: "تصمیم اول\nتصمیم دوم", outcomesFa: "نتیجه اول\nنتیجه دوم", recommendedFa: "", feedbackFa: "" },
|
||||
summary: { ...base, summaryFa: "جمعبندی کوتاه", takeawaysFa: "نکته اول\nنکته دوم", nextActionFa: "اقدام بعدی", completionMessageFa: "آفرین، پایان یافت." },
|
||||
rating: { ...base, questionFa: "این آموزش چقدر مفید بود؟", scaleFa: "پنجتایی", minLabelFa: "کم", maxLabelFa: "زیاد", responseRequired: true },
|
||||
feedback: { ...base, questionFa: "نظر شما چیست؟", responseTypeFa: "متن بلند", responseRequired: true, placeholderFa: "نظر خود را بنویسید" }
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
|
||||
function validateMicroStep(step: MicroLearningStep) {
|
||||
return validateMicroPayload(step.blockType, step.payload);
|
||||
}
|
||||
|
||||
function validateMicroPayload(type: MicroBlockType, payload: Record<string, unknown>) {
|
||||
const text = (key: string) => String(payload[key] ?? "").trim();
|
||||
const lines = (key: string) => text(key).split("\n").map((item) => item.trim()).filter(Boolean);
|
||||
const errors: string[] = [];
|
||||
if (type === "heading" && !text("headingTextFa")) errors.push("متن تیتر الزامی است.");
|
||||
if (type === "short_text" && !text("textFa")) errors.push("متن محتوا الزامی است.");
|
||||
if (type === "image") { if (!text("imageUrlFa") && !payload.imageUpload) errors.push("منبع تصویر الزامی است."); if (!text("altFa")) errors.push("متن جایگزین پیشنهاد میشود."); }
|
||||
if (type === "video") { if (!text("videoUrlFa") && !payload.videoUpload) errors.push("منبع ویدیو الزامی است."); if (text("completionRuleFa") === "مشاهده کامل" && !text("durationFa")) errors.push("برای مشاهده کامل، زمان ویدیو لازم است."); }
|
||||
if (type === "audio" && !text("audioUrlFa") && !payload.audioUpload) errors.push("منبع صوت الزامی است.");
|
||||
if (type === "file") { if (!text("fileNameFa") && !payload.fileUpload) errors.push("فایل الزامی است."); if (!text("fileTitleFa")) errors.push("عنوان فایل الزامی است."); }
|
||||
if (type === "quote" && !text("quoteFa")) errors.push("متن نقلقول الزامی است.");
|
||||
if (type === "key_point" && !text("keyPointFa")) errors.push("متن نکته کلیدی الزامی است.");
|
||||
if (type === "flashcard") { if (!text("frontFa")) errors.push("متن روی کارت الزامی است."); if (!text("backFa")) errors.push("متن پشت کارت الزامی است."); }
|
||||
if (type === "single_choice") { if (!text("questionFa")) errors.push("متن سؤال الزامی است."); if (lines("optionsFa").length < 2) errors.push("حداقل دو گزینه لازم است."); if (!text("correctFa")) errors.push("پاسخ درست الزامی است."); if (text("correctFa") && !lines("optionsFa").includes(text("correctFa"))) errors.push("پاسخ درست باید یکی از گزینهها باشد."); }
|
||||
if (type === "true_false") { if (!text("questionFa")) errors.push("متن سؤال الزامی است."); if (!text("correctFa")) errors.push("پاسخ درست الزامی است."); }
|
||||
if (type === "multiple_choice") { const options = lines("optionsFa"); const correct = lines("correctFa"); if (!text("questionFa")) errors.push("متن سؤال الزامی است."); if (options.length < 2) errors.push("حداقل دو گزینه لازم است."); if (!correct.length) errors.push("حداقل یک پاسخ درست لازم است."); if (correct.some((item) => !options.includes(item))) errors.push("همه پاسخهای درست باید داخل گزینهها باشند."); }
|
||||
if (type === "fill_blank") { if (!text("textFa").includes("____")) errors.push("متن باید حداقل یک جای خالی با ____ داشته باشد."); if (!lines("answersFa").length) errors.push("حداقل یک پاسخ پذیرفتهشده لازم است."); }
|
||||
if (type === "matching") { if (lines("pairsFa").length < 2) errors.push("حداقل دو جفت تطبیق لازم است."); }
|
||||
if (type === "drag_drop") { if (!lines("itemsFa").length) errors.push("حداقل یک آیتم کشیدنی لازم است."); if (!lines("zonesFa").length) errors.push("حداقل یک ناحیه رهاسازی لازم است."); if (!lines("mappingFa").length) errors.push("نگاشت درست الزامی است."); }
|
||||
if (type === "hotspot") { if (!text("imageFa") && !payload.hotspotImageUpload) errors.push("تصویر پسزمینه الزامی است."); if (!lines("hotspotsFa").length) errors.push("حداقل یک نقطه هاتاسپات لازم است."); }
|
||||
if (type === "ordering") { if (lines("itemsFa").length < 2) errors.push("حداقل دو آیتم لازم است."); if (lines("correctOrderFa").length < 2) errors.push("ترتیب درست الزامی است."); }
|
||||
if (type === "timeline" && lines("itemsFa").length < 2) errors.push("حداقل دو آیتم زمانی لازم است.");
|
||||
if (type === "checklist" && !lines("itemsFa").length) errors.push("حداقل یک مورد چکلیست لازم است.");
|
||||
if (type === "scenario") { if (!text("scenarioFa")) errors.push("متن سناریو الزامی است."); if (lines("optionsFa").length < 2) errors.push("حداقل دو تصمیم لازم است."); }
|
||||
if (type === "summary" && !text("summaryFa")) errors.push("متن جمعبندی الزامی است.");
|
||||
if (type === "rating" && !text("questionFa")) errors.push("پرسش امتیازدهی الزامی است.");
|
||||
if (type === "feedback" && !text("questionFa")) errors.push("پرسش بازخورد الزامی است.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
const MicroBlockSettings = memo(function MicroBlockSettings({ step, onPatch, onDelete, onDuplicate }: { step: MicroLearningStep; onPatch: (patch: Partial<MicroLearningStep>) => void; onDelete: () => void; onDuplicate: () => void }) {
|
||||
const errors = validateMicroStep(step);
|
||||
const patchPayload = (patch: Record<string, unknown>) => onPatch({ payload: { ...step.payload, ...patch } });
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<CommonSettings step={step} onPatch={onPatch} onPayload={patchPayload} onDelete={onDelete} onDuplicate={onDuplicate} />
|
||||
{errors.length ? (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/15 p-3 text-xs leading-6 text-red-600">
|
||||
{errors.map((error) => <p key={error}>{error}</p>)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-emerald-500/20 bg-emerald-500/15 p-3 text-xs font-bold text-emerald-600">آماده استفاده است.</p>
|
||||
)}
|
||||
<TypeSpecificSettings step={step} onPayload={patchPayload} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function CommonSettings({ step, onPatch, onPayload, onDelete, onDuplicate }: { step: MicroLearningStep; onPatch: (patch: Partial<MicroLearningStep>) => void; onPayload: (patch: Record<string, unknown>) => void; onDelete: () => void; onDuplicate: () => void }) {
|
||||
return (
|
||||
<PanelMini titleFa="تنظیمات پایه">
|
||||
<InputMini label="عنوان داخلی" value={step.titleFa} onChange={(value) => onPatch({ titleFa: value })} />
|
||||
<NumberMini label="زمان تخمینی" suffixFa="ثانیه" value={step.estimatedSeconds ?? 30} min={5} step={5} onChange={(value) => onPatch({ estimatedSeconds: value })} />
|
||||
<div className="grid gap-2">
|
||||
<ToggleRow label="برای تکمیل مسیر الزامی باشد" checked={step.payload.isRequired !== false} onChange={(value) => onPayload({ isRequired: value })} />
|
||||
<SelectMini label="قانون تکمیل" value={String(step.payload.completionRuleFa ?? "مشاهده")} options={completionRulesFor(step.blockType)} onChange={(value) => onPayload({ completionRuleFa: value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={onDuplicate} className="rounded-2xl bg-white/20 px-3 py-2 text-xs font-bold transition hover:bg-white/30">کپی بلاک</button>
|
||||
<button onClick={onDelete} className="rounded-2xl bg-red-500 px-3 py-2 text-xs font-bold text-white transition hover:bg-red-600">حذف بلاک</button>
|
||||
</div>
|
||||
</PanelMini>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeSpecificSettings({ step, onPayload }: { step: MicroLearningStep; onPayload: (patch: Record<string, unknown>) => void }) {
|
||||
const p = step.payload;
|
||||
const field = (key: string, label: string, placeholder?: string) => <InputMini label={label} value={String(p[key] ?? "")} placeholder={placeholder} onChange={(value) => onPayload({ [key]: value })} />;
|
||||
const area = (key: string, label: string, placeholder?: string) => <TextAreaMini label={label} value={String(p[key] ?? "")} placeholder={placeholder} onChange={(value) => onPayload({ [key]: value })} />;
|
||||
const score = <NumberMini label="امتیاز" suffixFa="نمره" value={Number(p.scoreFa) || 0} min={0} step={1} onChange={(value) => onPayload({ scoreFa: String(value) })} />;
|
||||
if (step.blockType === "heading") return <PanelMini titleFa="محتوا">{field("headingTextFa", "متن تیتر")}{field("subtitleFa", "زیرعنوان")}<SegmentMini label="اندازه" value={String(p.sizeFa ?? "متوسط")} options={["کوچک", "متوسط", "بزرگ"]} onChange={(value) => onPayload({ sizeFa: value })} /><SegmentMini label="چینش" value={String(p.alignFa ?? "راست")} options={["راست", "وسط", "چپ"]} onChange={(value) => onPayload({ alignFa: value })} /></PanelMini>;
|
||||
if (step.blockType === "short_text") return <PanelMini titleFa="محتوا">{area("textFa", "متن آموزشی")}<SegmentMini label="نوع نمایش" value={String(p.styleFa ?? "ساده")} options={["ساده", "نکته", "هشدار"]} onChange={(value) => onPayload({ styleFa: value })} /><SegmentMini label="اندازه متن" value={String(p.textSizeFa ?? "متوسط")} options={["کوچک", "متوسط", "بزرگ"]} onChange={(value) => onPayload({ textSizeFa: value })} /><ToggleRow label="نمایش به صورت فهرست" checked={Boolean(p.bulletList)} onChange={(value) => onPayload({ bulletList: value })} /></PanelMini>;
|
||||
if (step.blockType === "image") return <PanelMini titleFa="تصویر"><UploadSetting labelFa="آپلود تصویر" accept="image/*" onPayload={onPayload} payloadKey="imageUpload" />{field("imageUrlFa", "لینک تصویر", "https://...")}{field("altFa", "متن جایگزین")}{field("captionFa", "کپشن")}<SegmentMini label="اندازه نمایش" value={String(p.displaySizeFa ?? "متوسط")} options={["کوچک", "متوسط", "تمام عرض"]} onChange={(value) => onPayload({ displaySizeFa: value })} /></PanelMini>;
|
||||
if (step.blockType === "video") return <PanelMini titleFa="ویدیو"><UploadSetting labelFa="آپلود ویدیو" accept="video/*" onPayload={onPayload} payloadKey="videoUpload" />{field("videoUrlFa", "لینک ویدیو", "https://...")}{field("durationFa", "مدت", "مثلا ۲ دقیقه")}{field("captionFa", "توضیح کوتاه")}<AdvancedMini>{field("posterFa", "تصویر پوستر")}{area("transcriptFa", "رونوشت ویدیو")}</AdvancedMini></PanelMini>;
|
||||
if (step.blockType === "audio") return <PanelMini titleFa="صوت"><UploadSetting labelFa="آپلود صوت" accept="audio/*" onPayload={onPayload} payloadKey="audioUpload" />{field("audioTitleFa", "عنوان صوت")}{field("audioUrlFa", "لینک صوت", "https://...")}{field("durationFa", "مدت", "مثلا ۱ دقیقه")}<AdvancedMini>{area("transcriptFa", "رونوشت")}</AdvancedMini></PanelMini>;
|
||||
if (step.blockType === "file") return <PanelMini titleFa="فایل"><UploadSetting labelFa="آپلود فایل" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip" onPayload={onPayload} payloadKey="fileUpload" />{field("fileTitleFa", "عنوان فایل")}{area("descriptionFa", "توضیح کوتاه")}<SelectMini label="نوع فایل" value={String(p.fileTypeFa ?? "پیدیاف")} options={["پیدیاف", "ورد", "اکسل", "پاورپوینت", "زیپ", "سایر"]} onChange={(value) => onPayload({ fileTypeFa: value })} /></PanelMini>;
|
||||
if (step.blockType === "flashcard") return <PanelMini titleFa="فلشکارت">{area("frontFa", "روی کارت")}{area("backFa", "پشت کارت")}{field("imageFa", "تصویر اختیاری")}<ToggleRow label="برای تکمیل، کارت باید برگردانده شود" checked={Boolean(p.requireFlip)} onChange={(value) => onPayload({ requireFlip: value })} /></PanelMini>;
|
||||
if (step.blockType === "single_choice" || step.blockType === "multiple_choice") return <PanelMini titleFa="سؤال">{area("questionFa", "متن سؤال")}<ChoiceOptionsEditor multiple={step.blockType === "multiple_choice"} optionsText={String(p.optionsFa ?? "")} correctText={String(p.correctFa ?? "")} shuffle={Boolean(p.shuffle)} onChange={(patch) => onPayload(patch)} />{score}<FeedbackSettings payload={p} onPayload={onPayload} /></PanelMini>;
|
||||
if (step.blockType === "true_false") return <PanelMini titleFa="درست / نادرست">{area("questionFa", "متن سؤال")}<SegmentMini label="پاسخ درست" value={String(p.correctFa ?? "درست")} options={["درست", "نادرست"]} onChange={(value) => onPayload({ correctFa: value })} />{score}<FeedbackSettings payload={p} onPayload={onPayload} /></PanelMini>;
|
||||
if (step.blockType === "fill_blank") return <PanelMini titleFa="جای خالی">{area("textFa", "متن دارای جای خالی", "برای جای خالی از ____ استفاده کنید.")}<LineListEditor label="پاسخهای پذیرفتهشده" value={String(p.answersFa ?? "")} onChange={(value) => onPayload({ answersFa: value })} />{score}<AdvancedMini><ToggleRow label="حساس به حروف" checked={Boolean(p.caseSensitive)} onChange={(value) => onPayload({ caseSensitive: value })} />{field("feedbackFa", "بازخورد")}</AdvancedMini></PanelMini>;
|
||||
if (step.blockType === "ordering") return <PanelMini titleFa="ترتیبگذاری"><LineListEditor label="آیتمها به ترتیب درست" value={String(p.correctOrderFa || p.itemsFa || "")} onChange={(value) => onPayload({ itemsFa: value, correctOrderFa: value })} />{score}{field("feedbackFa", "بازخورد")}</PanelMini>;
|
||||
if (step.blockType === "checklist") return <PanelMini titleFa="چکلیست"><LineListEditor label="موارد چکلیست" value={String(p.itemsFa ?? "")} onChange={(value) => onPayload({ itemsFa: value })} /><ToggleRow label="تکمیل همه موارد الزامی باشد" checked={Boolean(p.requireAll)} onChange={(value) => onPayload({ requireAll: value })} />{field("completionMessageFa", "پیام تکمیل")}</PanelMini>;
|
||||
if (step.blockType === "scenario") return <PanelMini titleFa="سناریو">{area("scenarioFa", "شرح موقعیت")}<LineListEditor label="گزینههای تصمیم" value={String(p.optionsFa ?? "")} onChange={(value) => onPayload({ optionsFa: value })} />{field("recommendedFa", "گزینه پیشنهادی")}<AdvancedMini>{area("outcomesFa", "نتیجه تصمیمها")}{field("feedbackFa", "بازخورد")}</AdvancedMini></PanelMini>;
|
||||
if (step.blockType === "summary") return <PanelMini titleFa="جمعبندی">{area("summaryFa", "متن جمعبندی")}<LineListEditor label="نکات کلیدی" value={String(p.takeawaysFa ?? "")} onChange={(value) => onPayload({ takeawaysFa: value })} />{field("nextActionFa", "اقدام بعدی")}</PanelMini>;
|
||||
if (step.blockType === "rating") return <PanelMini titleFa="امتیازدهی">{field("questionFa", "پرسش")}<SegmentMini label="مقیاس" value={String(p.scaleFa ?? "پنجتایی")} options={["پنجتایی", "دهتایی"]} onChange={(value) => onPayload({ scaleFa: value })} /><ToggleRow label="پاسخ الزامی باشد" checked={Boolean(p.responseRequired)} onChange={(value) => onPayload({ responseRequired: value })} /><AdvancedMini>{field("minLabelFa", "برچسب کمینه")}{field("maxLabelFa", "برچسب بیشینه")}</AdvancedMini></PanelMini>;
|
||||
if (step.blockType === "quote") return <PanelMini titleFa="نقلقول">{area("quoteFa", "متن نقلقول")}{field("speakerFa", "نام گوینده")}<AdvancedMini>{field("speakerRoleFa", "نقش گوینده")}</AdvancedMini></PanelMini>;
|
||||
if (step.blockType === "key_point") return <PanelMini titleFa="نکته کلیدی">{area("keyPointFa", "متن نکته")}<SegmentMini label="اهمیت" value={String(p.importanceFa ?? "مهم")} options={["معمولی", "مهم", "حیاتی"]} onChange={(value) => onPayload({ importanceFa: value })} /></PanelMini>;
|
||||
return <PanelMini titleFa="بازخورد">{field("questionFa", "پرسش بازخورد")}<SelectMini label="نوع پاسخ" value={String(p.responseTypeFa ?? "متن بلند")} options={["متن کوتاه", "متن بلند"]} onChange={(value) => onPayload({ responseTypeFa: value })} /><ToggleRow label="پاسخ الزامی باشد" checked={Boolean(p.responseRequired)} onChange={(value) => onPayload({ responseRequired: value })} />{field("placeholderFa", "متن راهنما")}</PanelMini>;
|
||||
}
|
||||
|
||||
function microStepPreview(step: MicroLearningStep) {
|
||||
const p = step.payload;
|
||||
return String(p.headingTextFa ?? p.textFa ?? p.questionFa ?? p.summaryFa ?? p.keyPointFa ?? p.quoteFa ?? p.scenarioFa ?? p.fileTitleFa ?? "پیشنمایش آماده است.").slice(0, 90);
|
||||
}
|
||||
|
||||
function PanelMini({ titleFa, children }: { titleFa?: string; children: React.ReactNode }) {
|
||||
return <div className="space-y-3 rounded-2xl border border-white/10 bg-white/10 p-3">{titleFa ? <h4 className="text-xs font-black opacity-75">{titleFa}</h4> : null}{children}</div>;
|
||||
}
|
||||
|
||||
function InputMini({ label, value, placeholder, onChange }: { label: string; value: string; placeholder?: string; onChange: (value: string) => void }) {
|
||||
return <label className="block text-xs font-bold">{label}<input value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl bg-white/15 p-3 text-sm font-normal outline-none transition focus:bg-white/20 focus:ring-2 focus:ring-emerald-400/40" /></label>;
|
||||
}
|
||||
|
||||
function TextAreaMini({ label, value, placeholder, onChange }: { label: string; value: string; placeholder?: string; onChange: (value: string) => void }) {
|
||||
return <label className="block text-xs font-bold">{label}<textarea value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} rows={4} className="mt-2 w-full rounded-2xl bg-white/15 p-3 text-sm font-normal leading-7 outline-none transition focus:bg-white/20 focus:ring-2 focus:ring-emerald-400/40" /></label>;
|
||||
}
|
||||
|
||||
function NumberMini({ label, suffixFa, value, min = 0, step = 1, onChange }: { label: string; suffixFa?: string; value: number; min?: number; step?: number; onChange: (value: number) => void }) {
|
||||
const patch = (next: number) => onChange(Math.max(min, next));
|
||||
return <div className="text-xs font-bold"><p>{label}</p><div className="mt-2 flex items-center gap-2 rounded-2xl bg-white/15 p-2"><button onClick={() => patch(value - step)} className="grid h-8 w-8 place-items-center rounded-xl bg-white/15 text-sm">-</button><input value={String(value)} onChange={(event) => patch(Number(event.target.value) || min)} className="min-w-0 flex-1 bg-transparent text-center text-sm font-black outline-none" /><button onClick={() => patch(value + step)} className="grid h-8 w-8 place-items-center rounded-xl bg-white/15 text-sm">+</button>{suffixFa ? <span className="text-[11px] opacity-65">{suffixFa}</span> : null}</div></div>;
|
||||
}
|
||||
|
||||
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
|
||||
return <button onClick={() => onChange(!checked)} className="flex items-center justify-between gap-3 rounded-2xl bg-white/12 px-3 py-2 text-right text-xs font-bold transition hover:bg-white/18"><span>{label}</span><span className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-emerald-500" : "bg-white/25"}`}><span className={`absolute top-1 h-4 w-4 rounded-full bg-white transition ${checked ? "right-6" : "right-1"}`} /></span></button>;
|
||||
}
|
||||
|
||||
function SegmentMini({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return <div className="text-xs font-bold"><p>{label}</p><div className="mt-2 grid rounded-2xl bg-white/10 p-1" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>{options.map((option) => <button key={option} onClick={() => onChange(option)} className={`rounded-xl px-2 py-2 text-[11px] transition ${value === option ? "bg-emerald-500 text-white shadow-lg" : "hover:bg-white/15"}`}>{option}</button>)}</div></div>;
|
||||
}
|
||||
|
||||
function SelectMini({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return <label className="block text-xs font-bold">{label}<select value={value} onChange={(event) => onChange(event.target.value)} className="mt-2 w-full rounded-2xl bg-white/15 p-3 text-sm font-normal outline-none">{options.map((option) => <option key={option}>{option}</option>)}</select></label>;
|
||||
}
|
||||
|
||||
function AdvancedMini({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <div className="rounded-2xl bg-white/8"><button onClick={() => setOpen((value) => !value)} className="w-full rounded-2xl bg-white/10 px-3 py-2 text-right text-xs font-bold">{open ? "بستن تنظیمات تکمیلی" : "تنظیمات تکمیلی"}</button>{open ? <div className="space-y-3 p-3">{children}</div> : null}</div>;
|
||||
}
|
||||
|
||||
function LineListEditor({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
const items = toLines(value);
|
||||
const setItem = (index: number, nextValue: string) => onChange(items.map((item, itemIndex) => itemIndex === index ? nextValue : item).join("\n"));
|
||||
const removeItem = (index: number) => onChange(items.filter((_, itemIndex) => itemIndex !== index).join("\n"));
|
||||
return <div className="space-y-2 text-xs font-bold"><p>{label}</p>{items.map((item, index) => <div key={`${item}-${index}`} className="flex gap-2"><input value={item} onChange={(event) => setItem(index, event.target.value)} className="min-w-0 flex-1 rounded-2xl bg-white/15 p-3 text-sm font-normal outline-none" /><button onClick={() => removeItem(index)} className="rounded-2xl bg-red-500 px-3 text-xs font-bold text-white">حذف</button></div>)}<button onClick={() => onChange([...items, ""].join("\n"))} className="w-full rounded-2xl border border-dashed border-white/25 px-3 py-2 text-xs font-bold">افزودن مورد</button></div>;
|
||||
}
|
||||
|
||||
function ChoiceOptionsEditor({ multiple, optionsText, correctText, shuffle, onChange }: { multiple: boolean; optionsText: string; correctText: string; shuffle: boolean; onChange: (patch: Record<string, unknown>) => void }) {
|
||||
const options = toLines(optionsText);
|
||||
const correct = toLines(correctText);
|
||||
const updateOptions = (nextOptions: string[]) => {
|
||||
const nextCorrect = correct.filter((item) => nextOptions.includes(item));
|
||||
onChange({ optionsFa: nextOptions.join("\n"), correctFa: nextCorrect.join("\n") });
|
||||
};
|
||||
const setOption = (index: number, value: string) => {
|
||||
const previous = options[index];
|
||||
const nextOptions = options.map((item, itemIndex) => itemIndex === index ? value : item);
|
||||
const nextCorrect = correct.map((item) => item === previous ? value : item).filter((item) => nextOptions.includes(item));
|
||||
onChange({ optionsFa: nextOptions.join("\n"), correctFa: nextCorrect.join("\n") });
|
||||
};
|
||||
const toggleCorrect = (option: string) => {
|
||||
if (multiple) onChange({ correctFa: (correct.includes(option) ? correct.filter((item) => item !== option) : [...correct, option]).join("\n") });
|
||||
else onChange({ correctFa: option });
|
||||
};
|
||||
return <div className="space-y-2 text-xs font-bold"><p>گزینهها و پاسخ درست</p>{options.map((option, index) => <div key={`${option}-${index}`} className="grid grid-cols-[1fr_auto_auto] gap-2"><input value={option} onChange={(event) => setOption(index, event.target.value)} className="min-w-0 rounded-2xl bg-white/15 p-3 text-sm font-normal outline-none" /><button onClick={() => toggleCorrect(option)} className={`rounded-2xl px-3 text-[11px] font-bold ${correct.includes(option) ? "bg-emerald-500 text-white" : "bg-white/15"}`}>{multiple ? "درست" : "انتخاب"}</button><button onClick={() => updateOptions(options.filter((_, itemIndex) => itemIndex !== index))} className="rounded-2xl bg-red-500 px-3 text-[11px] font-bold text-white">حذف</button></div>)}<button onClick={() => updateOptions([...options, `گزینه ${toPersianDigits(options.length + 1)}`])} className="w-full rounded-2xl border border-dashed border-white/25 px-3 py-2 text-xs font-bold">افزودن گزینه</button><ToggleRow label="نمایش تصادفی گزینهها" checked={shuffle} onChange={(value) => onChange({ shuffle: value })} /></div>;
|
||||
}
|
||||
|
||||
function FeedbackSettings({ payload, onPayload }: { payload: Record<string, unknown>; onPayload: (patch: Record<string, unknown>) => void }) {
|
||||
return <AdvancedMini><InputMini label="بازخورد پاسخ درست" value={String(payload.correctFeedbackFa ?? "")} onChange={(value) => onPayload({ correctFeedbackFa: value })} /><InputMini label="بازخورد پاسخ نادرست" value={String(payload.wrongFeedbackFa ?? "")} onChange={(value) => onPayload({ wrongFeedbackFa: value })} /></AdvancedMini>;
|
||||
}
|
||||
|
||||
function completionRulesFor(type: MicroBlockType) {
|
||||
if (["single_choice", "true_false", "multiple_choice", "fill_blank", "ordering", "checklist", "scenario", "rating", "feedback"].includes(type)) return ["پاسخ دادن"];
|
||||
if (type === "video") return ["مشاهده", "مشاهده کامل"];
|
||||
if (type === "audio") return ["مشاهده", "شنیدن کامل"];
|
||||
if (type === "file") return ["مشاهده", "دانلود"];
|
||||
if (type === "flashcard") return ["مشاهده", "برگرداندن کارت"];
|
||||
return ["مشاهده"];
|
||||
}
|
||||
|
||||
function toLines(value: string) {
|
||||
return value.split("\n").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function UploadSetting({ labelFa, accept, payloadKey, onPayload }: { labelFa: string; accept: string; payloadKey: string; onPayload: (patch: Record<string, unknown>) => void }) {
|
||||
const [file, setFile] = useState<UploadedFile | null>(null);
|
||||
return <UploadDropzone labelFa={labelFa} accept={accept} value={file} onChange={(next) => { setFile(next); onPayload({ [payloadKey]: next ? { id: next.id, name: next.name, mimeType: next.mimeType, objectUrl: next.objectUrl } : null }); }} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { CourseBuilderExplorerSection } from "@/types/courseBuilder";
|
||||
import { CourseBuilderExplorerIcon } from "./CourseBuilderExplorerIcon";
|
||||
|
||||
export function CourseBuilderExplorerGrid({ sections, onOpen }: { sections: CourseBuilderExplorerSection[]; onOpen: (id: string) => void }) {
|
||||
return <div className="grid grid-cols-[repeat(auto-fill,minmax(116px,1fr))] gap-4">{sections.map((section) => <CourseBuilderExplorerIcon key={section.id} section={section} onOpen={onOpen} />)}</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { CourseBuilderExplorerSection } from "@/types/courseBuilder";
|
||||
import { GlassIconTile } from "@/components/shared/GlassIcon";
|
||||
|
||||
export function CourseBuilderExplorerIcon({ section, onOpen }: { section: CourseBuilderExplorerSection; onOpen: (id: string) => void }) {
|
||||
return <GlassIconTile titleFa={section.labelFa} iconName={section.icon} seed={section.id} onOpen={() => onOpen(section.id)} size="lg" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, Home, Search } from "lucide-react";
|
||||
|
||||
export function CourseBuilderExplorerToolbar({ title, query, onQuery, onBack, onHome }: { title?: string; query: string; onQuery: (v: string) => void; onBack: () => void; onHome: () => void }) {
|
||||
return <div className="border-b border-white/15 p-4"><div className="flex flex-wrap items-center gap-2"><button onClick={onBack} className="rounded-2xl bg-white/15 p-2" title="بازگشت"><ArrowRight className="h-5 w-5" /></button><button onClick={onHome} className="rounded-2xl bg-white/15 p-2" title="خانه"><Home className="h-5 w-5" /></button><div className="min-w-[220px] flex-1 rounded-2xl bg-white/15 px-3 py-2 text-sm">{title ? `ساخت دوره / ${title}` : "ساخت دوره"}</div><label className="flex w-full items-center gap-2 rounded-2xl bg-white/15 px-3 py-2 sm:w-72"><Search className="h-4 w-4 opacity-70" /><input value={query} onChange={(e) => onQuery(e.target.value)} placeholder="جستجو در ساخت دوره..." className="w-full bg-transparent text-sm outline-none placeholder:text-current/45" /></label></div></div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { courseWizardSteps } from "@/config/courseBuilderExplorerConfig";
|
||||
import { saveCourseBuilderCourse } from "@/services/api/courseBuilderService";
|
||||
import type { CourseBuilderRole, CourseWizardDraft, CourseWizardStep } from "@/types/courseBuilder";
|
||||
import { CoursePublishConfirmModal } from "./modals/CoursePublishConfirmModal";
|
||||
import { CourseWizardFooter } from "./CourseWizardFooter";
|
||||
import { CourseWizardStepContent } from "./CourseWizardSteps";
|
||||
|
||||
export function CourseWizard({ initialDraft, role, onSaved }: { initialDraft: CourseWizardDraft; role: CourseBuilderRole; onSaved?: (draft: CourseWizardDraft, message: string) => void }) {
|
||||
const [draft, setDraft] = useState(initialDraft);
|
||||
const [step, setStep] = useState<CourseWizardStep>("basic_info");
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const index = courseWizardSteps.findIndex((item) => item.id === step);
|
||||
const complete = Boolean(draft.course.titleFa && draft.course.categoryId && draft.course.level && draft.nodes.length);
|
||||
const go = (nextIndex: number) => setStep(courseWizardSteps[Math.max(0, Math.min(courseWizardSteps.length - 1, nextIndex))].id);
|
||||
const save = async (status: "draft" | "published") => {
|
||||
setSaving(true);
|
||||
const result = await saveCourseBuilderCourse(draft, status);
|
||||
setDraft(result.draft);
|
||||
onSaved?.(result.draft, status === "published" ? "دوره منتشر شد و در لیست دورهها قرار گرفت." : "دوره ذخیره شد و در پیشنویسها قرار گرفت.");
|
||||
setSaving(false);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="liquid-glass flex gap-2 overflow-x-auto rounded-[22px] p-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{courseWizardSteps.map((item, i) => <button key={item.id} onClick={() => setStep(item.id)} className={`min-w-36 rounded-2xl px-3 py-2 text-sm ${item.id === step ? "bg-white/30 font-black" : "bg-white/10"}`}>{(i + 1).toLocaleString("fa-IR")}، {item.labelFa}</button>)}
|
||||
</div>
|
||||
<div className="liquid-glass rounded-[24px] p-5"><CourseWizardStepContent step={step} draft={draft} role={role} onDraft={setDraft} /></div>
|
||||
{saving ? <p className="rounded-2xl bg-emerald-500/15 p-3 text-sm text-emerald-700 dark:text-emerald-200">در حال ذخیره دوره...</p> : null}
|
||||
<CourseWizardFooter canBack={index > 0} isLast={index === courseWizardSteps.length - 1} onBack={() => go(index - 1)} onNext={() => go(index + 1)} onSaveDraft={() => save("draft")} onPreview={() => setStep("preview_publish")} onPublish={() => complete && setPublishOpen(true)} />
|
||||
{!complete && step === "preview_publish" ? <p className="rounded-2xl bg-rose-500/20 p-3 text-sm">اطلاعات ضروری کامل نیست و انتشار انجام نمیشود.</p> : null}
|
||||
<CoursePublishConfirmModal open={publishOpen} onCancel={() => setPublishOpen(false)} onConfirm={() => { setPublishOpen(false); save("published"); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
export function CourseWizardFooter({ canBack, isLast, onBack, onNext, onSaveDraft, onPreview, onPublish }: { canBack: boolean; isLast: boolean; onBack: () => void; onNext: () => void; onSaveDraft: () => void; onPreview: () => void; onPublish: () => void }) {
|
||||
return <div className="flex flex-wrap justify-between gap-2 border-t border-white/10 pt-4"><button onClick={onSaveDraft} className="rounded-2xl bg-white/15 px-4 py-2">ذخیره پیشنویس</button><div className="flex flex-wrap gap-2"><button disabled={!canBack} onClick={onBack} className="rounded-2xl bg-white/15 px-4 py-2 disabled:opacity-40">مرحله قبل</button><button onClick={onPreview} className="rounded-2xl bg-white/15 px-4 py-2">پیشنمایش</button>{isLast ? <button onClick={onPublish} className="rounded-2xl bg-emerald-500 px-4 py-2 text-white">انتشار دوره</button> : <button onClick={onNext} className="rounded-2xl bg-emerald-500 px-4 py-2 text-white">مرحله بعد</button>}</div></div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { UploadDropzone } from "@/components/uploads/UploadDropzone";
|
||||
import type { CourseBuilderRole, CourseContentItem, CourseContentType, CourseFormat, CourseNode, CourseUploadItem, CourseWizardDraft, CourseWizardStep } from "@/types/courseBuilder";
|
||||
import type { UploadedFile } from "@/types/upload";
|
||||
import { CourseUploadBox } from "./upload/CourseUploadBox";
|
||||
|
||||
export function CourseWizardStepContent({ step, draft, role, onDraft }: { step: CourseWizardStep; draft: CourseWizardDraft; role: CourseBuilderRole; onDraft: (next: CourseWizardDraft) => void }) {
|
||||
const patchCourse = (patch: Partial<CourseWizardDraft["course"]>) => onDraft({ ...draft, course: { ...draft.course, ...patch } });
|
||||
if (step === "basic_info") return <BasicInfo draft={draft} role={role} patchCourse={patchCourse} />;
|
||||
if (step === "studio_content") return <Structure draft={draft} onDraft={onDraft} />;
|
||||
if (step === "assessment_access") return <AssessmentAccess role={role} />;
|
||||
return <Preview draft={draft} role={role} patchCourse={patchCourse} />;
|
||||
}
|
||||
|
||||
function BasicInfo({ draft, role, patchCourse }: { draft: CourseWizardDraft; role: CourseBuilderRole; patchCourse: (patch: Partial<CourseWizardDraft["course"]>) => void }) {
|
||||
const [cover, setCover] = useState<UploadedFile | null>(null);
|
||||
const [provider, setProvider] = useState("");
|
||||
const [skills, setSkills] = useState("");
|
||||
const [objectives, setObjectives] = useState("");
|
||||
const [audience, setAudience] = useState("");
|
||||
const levelValue = draft.course.level === "advanced" ? "پیشرفته" : draft.course.level === "intermediate" ? "متوسط" : "مقدماتی";
|
||||
const pricingValue = draft.course.pricingType === "paid" ? "پولی" : "رایگان";
|
||||
const ownerValue = draft.course.ownerType === "platform" ? "عمومی سامانه" : role === "super_admin" ? "اختصاصی سازمان" : "اختصاصی سازمان من";
|
||||
return <div className="grid gap-3 md:grid-cols-2"><Input label="عنوان دوره" value={draft.course.titleFa} onChange={(v) => patchCourse({ titleFa: v })} /><Input label="کد دوره" value={draft.course.code} onChange={(v) => patchCourse({ code: v })} /><Input label="توضیح کوتاه" value={draft.course.shortDescriptionFa} onChange={(v) => patchCourse({ shortDescriptionFa: v })} /><Select label="دستهبندی" value={draft.course.categoryFa ?? ""} options={["امنیت", "فروش", "منابع انسانی", "فناوری"]} onChange={(v) => patchCourse({ categoryId: v, categoryFa: v })} /><Select label="سطح دوره" value={levelValue} options={["مقدماتی", "متوسط", "پیشرفته"]} onChange={(v) => patchCourse({ level: v === "متوسط" ? "intermediate" : v === "پیشرفته" ? "advanced" : "beginner" })} /><Select label="نوع دوره" value={pricingValue} options={["رایگان", "پولی"]} onChange={(v) => patchCourse({ pricingType: v === "پولی" ? "paid" : "free" })} /><Select label="نوع مالکیت دوره" value={ownerValue} options={role === "super_admin" ? ["عمومی سامانه", "اختصاصی سازمان"] : ["اختصاصی سازمان من"]} onChange={(v) => patchCourse({ ownerType: v === "عمومی سامانه" ? "platform" : "organization" })} /><Input label="قیمت، در صورت پولی بودن" value={String(draft.course.priceToman ?? 0)} onChange={(v) => patchCourse({ priceToman: Number(v) || 0 })} /><div className="md:col-span-2"><UploadDropzone labelFa="آپلود تصویر کاور" accept="image/*" maxSizeMb={5} value={cover} onChange={(file) => { setCover(file); patchCourse({ coverImageUrl: file?.objectUrl }); }} /></div><Input label="مدرس یا ارائهدهنده" value={provider} onChange={setProvider} /><Input label="مدت زمان تقریبی" value={skills} onChange={setSkills} /><textarea placeholder="توضیح کامل" className="rounded-2xl bg-white/15 p-3 outline-none md:col-span-2" rows={4} value={draft.course.fullDescriptionFa} onChange={(e) => patchCourse({ fullDescriptionFa: e.target.value })} /><Input label="زیردستهبندی" value={objectives} onChange={setObjectives} /><Input label="زبان دوره" value="فارسی" onChange={() => undefined} /><Input label="برچسبها / تگها" value={draft.course.tags.join("، ")} onChange={(v) => patchCourse({ tags: v.split("،").map((tag) => tag.trim()).filter(Boolean) })} /><Input label="مخاطبان پیشنهادی" value={audience} onChange={setAudience} /><div className="rounded-2xl bg-white/10 p-3 text-sm">وضعیت اولیه: پیشنویس</div></div>;
|
||||
}
|
||||
|
||||
function Structure({ draft, onDraft }: { draft: CourseWizardDraft; onDraft: (next: CourseWizardDraft) => void }) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(draft.nodes[0]?.id ?? null);
|
||||
const [pickerFor, setPickerFor] = useState<string | null>(null);
|
||||
const selected = useMemo(() => draft.nodes.find((node) => node.id === selectedId) ?? null, [draft.nodes, selectedId]);
|
||||
const sections = useMemo(() => draft.nodes.filter((node) => node.type === "section").sort((a, b) => a.order - b.order), [draft.nodes]);
|
||||
const nodesByParent = useMemo(() => {
|
||||
const groups = new Map<string, CourseNode[]>();
|
||||
for (const node of draft.nodes) {
|
||||
if (!node.parentId) continue;
|
||||
const items = groups.get(node.parentId) ?? [];
|
||||
items.push(node);
|
||||
groups.set(node.parentId, items);
|
||||
}
|
||||
for (const items of groups.values()) items.sort((a, b) => a.order - b.order);
|
||||
return groups;
|
||||
}, [draft.nodes]);
|
||||
const subsectionsOf = useCallback((id: string) => (nodesByParent.get(id) ?? []).filter((node) => node.type === "subsection"), [nodesByParent]);
|
||||
const lessonsOf = useCallback((id: string) => (nodesByParent.get(id) ?? []).filter((node) => node.type === "lesson"), [nodesByParent]);
|
||||
|
||||
const patchNode = (nodeId: string, patch: Partial<CourseNode>) => onDraft({ ...draft, nodes: draft.nodes.map((node) => node.id === nodeId ? { ...node, ...patch } : node) });
|
||||
const addNode = (type: CourseNode["type"], parentId: string | null) => {
|
||||
const parent = draft.nodes.find((node) => node.id === parentId);
|
||||
if (type === "subsection" && parent?.type !== "section") return window.alert("زیربخش فقط زیر یک بخش ساخته میشود.");
|
||||
if (type === "lesson" && !["section", "subsection"].includes(parent?.type ?? "")) return window.alert("درس باید زیر بخش یا زیربخش ساخته شود.");
|
||||
const next: CourseNode = { id: `${type}-${Date.now()}`, courseId: draft.course.id, parentId, type, titleFa: type === "section" ? "بخش جدید" : type === "subsection" ? "زیربخش جدید" : "درس جدید", order: draft.nodes.length + 1, estimatedMinutes: type === "lesson" ? 10 : 30, isRequired: true, isActive: true, contentFormat: type === "lesson" ? "video" : undefined, contents: [] };
|
||||
onDraft({ ...draft, nodes: [...draft.nodes, next] });
|
||||
setSelectedId(next.id);
|
||||
};
|
||||
const duplicateNode = (nodeId: string) => {
|
||||
const source = draft.nodes.find((node) => node.id === nodeId);
|
||||
if (!source) return;
|
||||
const next = { ...source, id: `${source.type}-${Date.now()}`, titleFa: `کپی ${source.titleFa}`, order: draft.nodes.length + 1, contents: (source.contents ?? []).map((item) => ({ ...item, id: `content-${Date.now()}-${item.id}` })) };
|
||||
onDraft({ ...draft, nodes: [...draft.nodes, next] });
|
||||
setSelectedId(next.id);
|
||||
};
|
||||
const removeNode = (nodeId: string) => {
|
||||
const directChildren = draft.nodes.filter((node) => node.parentId === nodeId);
|
||||
const nestedIds = new Set(directChildren.flatMap((child) => [child.id, ...draft.nodes.filter((node) => node.parentId === child.id).map((node) => node.id)]));
|
||||
const contentCount = (draft.nodes.find((node) => node.id === nodeId)?.contents ?? []).length;
|
||||
if ((directChildren.length || contentCount) && !window.confirm("این حذف شامل زیرمجموعه یا محتوا است. آیا مطمئن هستید؟")) return;
|
||||
if (!directChildren.length && !contentCount && !window.confirm("آیا از حذف این مورد مطمئن هستید؟")) return;
|
||||
onDraft({ ...draft, nodes: draft.nodes.filter((node) => node.id !== nodeId && node.parentId !== nodeId && !nestedIds.has(node.id)) });
|
||||
if (selectedId === nodeId) setSelectedId(null);
|
||||
};
|
||||
const moveNode = (nodeId: string, direction: -1 | 1) => {
|
||||
const index = draft.nodes.findIndex((node) => node.id === nodeId);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= draft.nodes.length) return;
|
||||
const next = [...draft.nodes];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onDraft({ ...draft, nodes: next.map((node, order) => ({ ...node, order: order + 1 })) });
|
||||
};
|
||||
const addContent = (nodeId: string, content: { type: CourseContentType; titleFa: string; descriptionFa: string; estimatedMinutes: number; isRequired: boolean }) => {
|
||||
const next: CourseContentItem = { id: `content-${Date.now()}`, type: content.type, titleFa: content.titleFa, descriptionFa: content.descriptionFa, payload: {}, isRequired: content.isRequired, estimatedMinutes: content.estimatedMinutes, order: ((draft.nodes.find((node) => node.id === nodeId)?.contents ?? []).length + 1) };
|
||||
patchNode(nodeId, { contents: [...(draft.nodes.find((node) => node.id === nodeId)?.contents ?? []), next] });
|
||||
setPickerFor(null);
|
||||
};
|
||||
const patchContent = (nodeId: string, contentId: string, patch: Partial<CourseContentItem>) => patchNode(nodeId, { contents: (draft.nodes.find((node) => node.id === nodeId)?.contents ?? []).map((item) => item.id === contentId ? { ...item, ...patch } : item) });
|
||||
const removeContent = (nodeId: string, contentId: string) => {
|
||||
if (!window.confirm("آیا از حذف این محتوا مطمئن هستید؟")) return;
|
||||
patchNode(nodeId, { contents: (draft.nodes.find((node) => node.id === nodeId)?.contents ?? []).filter((item) => item.id !== contentId) });
|
||||
};
|
||||
const moveContent = (nodeId: string, contentId: string, direction: -1 | 1) => {
|
||||
const contents = [...(draft.nodes.find((node) => node.id === nodeId)?.contents ?? [])];
|
||||
const index = contents.findIndex((item) => item.id === contentId);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= contents.length) return;
|
||||
[contents[index], contents[target]] = [contents[target], contents[index]];
|
||||
patchNode(nodeId, { contents: contents.map((item, order) => ({ ...item, order: order + 1 })) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-1">
|
||||
<div className="grid min-h-[520px] gap-4 lg:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<aside className="liquid-glass max-h-[620px] overflow-auto rounded-[24px] p-4">
|
||||
<div className="flex items-center justify-between gap-3"><h3 className="font-black">ساختار دوره</h3><button onClick={() => addNode("section", null)} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-bold text-white">افزودن بخش</button></div>
|
||||
<div className="mt-4 space-y-2">{sections.map((section) => <TreeNode key={section.id} node={section} selectedId={selectedId} onSelect={setSelectedId} onMove={moveNode} onDuplicate={duplicateNode} onDelete={removeNode}>{subsectionsOf(section.id).map((subsection) => <TreeNode key={subsection.id} node={subsection} selectedId={selectedId} onSelect={setSelectedId} onMove={moveNode} onDuplicate={duplicateNode} onDelete={removeNode}>{lessonsOf(subsection.id).map((lesson) => <TreeNode key={lesson.id} node={lesson} selectedId={selectedId} onSelect={setSelectedId} onMove={moveNode} onDuplicate={duplicateNode} onDelete={removeNode} />)}</TreeNode>)}{lessonsOf(section.id).map((lesson) => <TreeNode key={lesson.id} node={lesson} selectedId={selectedId} onSelect={setSelectedId} onMove={moveNode} onDuplicate={duplicateNode} onDelete={removeNode} />)}</TreeNode>)}</div>
|
||||
</aside>
|
||||
<main className="liquid-glass max-h-[620px] overflow-auto rounded-[24px] p-4">
|
||||
{!selected ? <div className="grid h-full min-h-80 place-items-center rounded-[22px] border border-dashed border-white/25 bg-white/10 p-6 text-center text-sm opacity-75">یک بخش، زیربخش یا درس را انتخاب کنید.</div> : <SelectedNodePanel node={selected} subsections={selected.type === "section" ? subsectionsOf(selected.id) : []} lessons={selected.type !== "lesson" ? lessonsOf(selected.id) : []} pickerOpen={pickerFor === selected.id} onPicker={() => setPickerFor(pickerFor === selected.id ? null : selected.id)} onClosePicker={() => setPickerFor(null)} onAddNode={addNode} onAddContent={(content) => addContent(selected.id, content)} onPatchNode={(patch) => patchNode(selected.id, patch)} onPatchContent={(contentId, patch) => patchContent(selected.id, contentId, patch)} onRemoveContent={(contentId) => removeContent(selected.id, contentId)} onMoveContent={(contentId, direction) => moveContent(selected.id, contentId, direction)} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectedNodePanel = memo(function SelectedNodePanel({ node, subsections, lessons, pickerOpen, onPicker, onClosePicker, onAddNode, onAddContent, onPatchNode, onPatchContent, onRemoveContent, onMoveContent }: { node: CourseNode; subsections: CourseNode[]; lessons: CourseNode[]; pickerOpen: boolean; onPicker: () => void; onClosePicker: () => void; onAddNode: (type: CourseNode["type"], parentId: string | null) => void; onAddContent: (content: { type: CourseContentType; titleFa: string; descriptionFa: string; estimatedMinutes: number; isRequired: boolean }) => void; onPatchNode: (patch: Partial<CourseNode>) => void; onPatchContent: (contentId: string, patch: Partial<CourseContentItem>) => void; onRemoveContent: (contentId: string) => void; onMoveContent: (contentId: string, direction: -1 | 1) => void }) {
|
||||
const label = node.type === "section" ? "بخش" : node.type === "subsection" ? "زیربخش" : "درس";
|
||||
const orderedContents = useMemo(() => [...(node.contents ?? [])].sort((a, b) => a.order - b.order), [node.contents]);
|
||||
const subsectionTitles = useMemo(() => subsections.map((item) => item.titleFa), [subsections]);
|
||||
const lessonTitles = useMemo(() => lessons.map((item) => item.titleFa), [lessons]);
|
||||
return <div className="space-y-4"><section className="rounded-[22px] bg-white/10 p-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><p className="text-xs opacity-65">{label}</p><input value={node.titleFa} onChange={(event) => onPatchNode({ titleFa: event.target.value })} className="mt-2 w-full min-w-64 rounded-2xl bg-white/15 px-4 py-2 font-black outline-none" /></div><div className="flex flex-wrap gap-2"><button onClick={onPicker} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-bold text-white">افزودن محتوا</button>{node.type === "section" ? <button onClick={() => onAddNode("subsection", node.id)} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-bold text-white">افزودن زیربخش</button> : null}{node.type !== "lesson" ? <button onClick={() => onAddNode("lesson", node.id)} className="rounded-2xl bg-emerald-500 px-3 py-2 text-xs font-bold text-white">افزودن درس</button> : null}</div></div><textarea value={node.descriptionFa ?? ""} onChange={(event) => onPatchNode({ descriptionFa: event.target.value })} placeholder="توضیح کوتاه" className="mt-3 w-full rounded-2xl bg-white/15 p-3 text-sm outline-none" rows={3} />{node.type === "lesson" ? <div className="mt-3 grid gap-3 sm:grid-cols-2"><Input label="زمان تخمینی دقیقه" value={String(node.estimatedMinutes ?? 10)} onChange={(value) => onPatchNode({ estimatedMinutes: Number(value) || 0 })} /><button onClick={() => onPatchNode({ isRequired: !node.isRequired })} className={`mt-7 rounded-2xl px-4 py-2 text-sm font-bold ${node.isRequired ? "bg-emerald-500 text-white" : "bg-white/15"}`}>{node.isRequired ? "اجباری" : "اختیاری"}</button></div> : null}</section>{pickerOpen ? <ContentTypePicker onPick={onAddContent} onClose={onClosePicker} /> : null}<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-black">محتواهای این {label}</h3><span className="text-xs opacity-65">{toFaNumber((node.contents ?? []).length)} مورد</span></div>{orderedContents.length ? <div className="grid gap-2">{orderedContents.map((content) => <CourseContentCard key={content.id} content={content} onPatch={(patch) => onPatchContent(content.id, patch)} onDelete={() => onRemoveContent(content.id)} onMove={(direction) => onMoveContent(content.id, direction)} />)}</div> : <div className="rounded-[20px] border border-dashed border-white/25 bg-white/10 p-5 text-center text-sm opacity-75">هنوز محتوایی برای این مورد ثبت نشده است.</div>}</section>{node.type !== "lesson" ? <section className="grid gap-3 md:grid-cols-2"><ChildSummary titleFa="زیربخشها" items={subsectionTitles} /><ChildSummary titleFa="درسها" items={lessonTitles} /></section> : null}</div>;
|
||||
});
|
||||
|
||||
const contentTypeFa: Record<CourseContentType, string> = { video: "ویدیو", pdf: "پیدیاف", image: "تصویر", text: "متن آموزشی", word: "فایل ورد", excel: "فایل اکسل", powerpoint: "فایل پاورپوینت", link: "لینک خارجی", scorm: "اسکورم", html5: "اچتیامال۵", online_class: "کلاس آنلاین", assignment: "تکلیف", micro_learning: "میکروآموزش" };
|
||||
|
||||
const TreeNode = memo(function TreeNode({ node, selectedId, children, onSelect, onMove, onDuplicate, onDelete }: { node: CourseNode; selectedId: string | null; children?: React.ReactNode; onSelect: (id: string) => void; onMove: (id: string, direction: -1 | 1) => void; onDuplicate: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const typeFa = node.type === "section" ? "بخش" : node.type === "subsection" ? "زیربخش" : "درس";
|
||||
return <div className="rounded-2xl bg-white/10 p-2"><div className={`flex items-center justify-between gap-2 rounded-xl px-2 py-2 ${selectedId === node.id ? "bg-emerald-500/20" : ""}`}><button onClick={() => onSelect(node.id)} className="min-w-0 flex-1 text-right text-xs"><b>{node.titleFa}</b><span className="mt-1 block opacity-60">{typeFa} • {toFaNumber(node.contents?.length ?? 0)} محتوا</span></button><button onClick={() => setOpen((value) => !value)} className="rounded-lg bg-white/15 px-2 py-1 text-xs">{open ? "بستن" : "باز"}</button></div><div className="mt-2 flex flex-wrap gap-1 px-1"><button onClick={() => onMove(node.id, -1)} className="rounded-lg bg-white/15 px-2 py-1 text-[11px]">بالا</button><button onClick={() => onMove(node.id, 1)} className="rounded-lg bg-white/15 px-2 py-1 text-[11px]">پایین</button><button onClick={() => onDuplicate(node.id)} className="rounded-lg bg-white/15 px-2 py-1 text-[11px]">کپی</button><button onClick={() => onDelete(node.id)} className="rounded-lg bg-red-500 px-2 py-1 text-[11px] font-bold text-white">حذف</button></div>{open && children ? <div className="mt-2 space-y-2 border-r border-white/15 pr-3">{children}</div> : null}</div>;
|
||||
});
|
||||
|
||||
function ContentTypePicker({ onPick, onClose }: { onPick: (content: { type: CourseContentType; titleFa: string; descriptionFa: string; estimatedMinutes: number; isRequired: boolean }) => void; onClose: () => void }) {
|
||||
const [type, setType] = useState<CourseContentType>("text");
|
||||
const [titleFa, setTitleFa] = useState(contentTypeFa.text);
|
||||
const [descriptionFa, setDescriptionFa] = useState("");
|
||||
const [estimatedMinutes, setEstimatedMinutes] = useState(5);
|
||||
const [isRequired, setIsRequired] = useState(true);
|
||||
const [uploaded, setUploaded] = useState<UploadedFile | null>(null);
|
||||
const needsUpload = ["video", "pdf", "image", "word", "excel", "powerpoint", "scorm", "html5"].includes(type);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/35" onClick={onClose}>
|
||||
<aside onClick={(event) => event.stopPropagation()} className="absolute left-4 top-4 h-[calc(100%-2rem)] w-full max-w-sm overflow-auto rounded-[28px] border border-white/20 bg-white/90 p-5 text-slate-900 shadow-2xl backdrop-blur-2xl dark:bg-slate-950/90 dark:text-white">
|
||||
<div className="flex items-center justify-between gap-3"><h3 className="font-black">افزودن محتوا</h3><button onClick={onClose} className="rounded-2xl bg-red-500 px-3 py-2 text-xs font-bold text-white">بستن</button></div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block text-sm">نوع محتوا<select value={type} onChange={(event) => { const next = event.target.value as CourseContentType; setType(next); setTitleFa(contentTypeFa[next]); }} className="mt-2 w-full rounded-2xl bg-white/30 p-3 outline-none dark:bg-white/15">{(Object.keys(contentTypeFa) as CourseContentType[]).map((item) => <option key={item} value={item}>{contentTypeFa[item]}</option>)}</select></label>
|
||||
{needsUpload ? <UploadDropzone labelFa={`آپلود ${contentTypeFa[type]}`} accept={acceptForContent(type)} value={uploaded} onChange={setUploaded} /> : null}
|
||||
<Input label="عنوان محتوا" value={titleFa} onChange={setTitleFa} />
|
||||
<label className="block text-sm">توضیح<textarea value={descriptionFa} onChange={(event) => setDescriptionFa(event.target.value)} rows={3} className="mt-2 w-full rounded-2xl bg-white/30 p-3 outline-none dark:bg-white/15" /></label>
|
||||
<Input label="زمان تخمینی دقیقه" value={String(estimatedMinutes)} onChange={(value) => setEstimatedMinutes(Number(value) || 1)} />
|
||||
<button onClick={() => setIsRequired((value) => !value)} className={`rounded-2xl px-4 py-2 text-sm font-bold ${isRequired ? "bg-emerald-500 text-white" : "bg-white/20"}`}>{isRequired ? "اجباری" : "اختیاری"}</button>
|
||||
<button onClick={() => onPick({ type, titleFa: titleFa.trim() || contentTypeFa[type], descriptionFa: uploaded ? `${descriptionFa} ${uploaded.name}`.trim() : descriptionFa, estimatedMinutes, isRequired })} className="w-full rounded-2xl bg-emerald-500 px-4 py-3 font-black text-white">ذخیره محتوا</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function acceptForContent(type: CourseContentType) {
|
||||
const map: Partial<Record<CourseContentType, string>> = {
|
||||
video: "video/*",
|
||||
pdf: "application/pdf,.pdf",
|
||||
image: "image/*",
|
||||
word: ".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
excel: ".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
powerpoint: ".ppt,.pptx,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
scorm: ".zip,application/zip",
|
||||
html5: ".zip,.html,.htm,application/zip,text/html"
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
|
||||
const CourseContentCard = memo(function CourseContentCard({ content, onPatch, onDelete, onMove }: { content: CourseContentItem; onPatch: (patch: Partial<CourseContentItem>) => void; onDelete: () => void; onMove: (direction: -1 | 1) => void }) {
|
||||
return <article draggable className="rounded-[18px] bg-white/12 p-3"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0 flex-1"><p className="text-[11px] opacity-65">☰ {contentTypeFa[content.type]} • {content.isRequired ? "اجباری" : "اختیاری"} • {toFaNumber(content.estimatedMinutes ?? 0)} دقیقه</p><input value={content.titleFa} onChange={(event) => onPatch({ titleFa: event.target.value })} className="mt-2 w-full rounded-xl bg-white/15 px-3 py-2 text-sm font-bold outline-none" /><input value={content.descriptionFa ?? ""} onChange={(event) => onPatch({ descriptionFa: event.target.value })} placeholder="توضیح محتوا" className="mt-2 w-full rounded-xl bg-white/15 px-3 py-2 text-xs outline-none" /></div><div className="flex flex-wrap gap-1"><button onClick={() => onMove(-1)} className="rounded-xl bg-white/15 px-2 py-1 text-[11px]">بالا</button><button onClick={() => onMove(1)} className="rounded-xl bg-white/15 px-2 py-1 text-[11px]">پایین</button><button onClick={() => onPatch({ isRequired: !content.isRequired })} className="rounded-xl bg-white/15 px-2 py-1 text-[11px]">{content.isRequired ? "اختیاری" : "اجباری"}</button><button onClick={() => onPatch({ titleFa: `کپی ${content.titleFa}` })} className="rounded-xl bg-white/15 px-2 py-1 text-[11px]">کپی</button><button className="rounded-xl bg-white/15 px-2 py-1 text-[11px]">پیشنمایش</button><button onClick={onDelete} className="rounded-xl bg-red-500 px-2 py-1 text-[11px] font-bold text-white">حذف</button></div></div></article>;
|
||||
});
|
||||
|
||||
function ChildSummary({ titleFa, items }: { titleFa: string; items: string[] }) {
|
||||
return <div className="rounded-[20px] bg-white/10 p-4"><h4 className="font-bold">{titleFa}</h4>{items.length ? <div className="mt-3 space-y-2">{items.map((item) => <p key={item} className="rounded-xl bg-white/12 px-3 py-2 text-xs">{item}</p>)}</div> : <p className="mt-3 text-xs opacity-60">موردی ثبت نشده است.</p>}</div>;
|
||||
}
|
||||
|
||||
function Content({ draft, onDraft }: { draft: CourseWizardDraft; onDraft: (next: CourseWizardDraft) => void }) {
|
||||
const addFiles = (files: File[]) => {
|
||||
const uploads: CourseUploadItem[] = files.map((file, index) => ({ id: `upload-${Date.now()}-${index}`, titleFa: file.name.replace(/\.[^.]+$/, "") || "فایل آموزشی", fileName: file.name, format: guessFormat(file.name), sizeLabelFa: `${Math.max(1, Math.round(file.size / 1024)).toLocaleString("fa-IR")} کیلوبایت`, progress: index % 5 === 0 ? 55 : 100, status: index % 5 === 0 ? "error" : "ready", lessonId: draft.nodes.find((node) => node.type === "lesson")?.id, createdAt: new Date().toISOString() }));
|
||||
const formats = Array.from(new Set([...draft.course.formats, ...uploads.map((item) => item.format)]));
|
||||
onDraft({ ...draft, course: { ...draft.course, formats }, uploads: [...draft.uploads, ...uploads], uploadProgress: 100 });
|
||||
};
|
||||
return <div className="space-y-4"><CourseUploadBox progress={draft.uploadProgress} uploads={draft.uploads} onFiles={addFiles} onRemove={(id) => onDraft({ ...draft, uploads: draft.uploads.filter((item) => item.id !== id) })} onRetry={(id) => onDraft({ ...draft, uploads: draft.uploads.map((item) => item.id === id ? { ...item, status: "ready", progress: 100 } : item) })} /><Panel title="فرمتهای محتوای دوره" items={["ویدیو", "متن", "پیدیاف", "تصویر", "ورد", "اکسل", "پاورپوینت", "لینک خارجی", "اسکورم", "اچتیامال۵", "کلاس آنلاین", "تکلیف", "میکروآموزش"]} /></div>;
|
||||
}
|
||||
|
||||
function Pricing({ draft, role, patchCourse }: { draft: CourseWizardDraft; role: CourseBuilderRole; patchCourse: (patch: Partial<CourseWizardDraft["course"]>) => void }) {
|
||||
const finalPrice = Math.round((draft.course.priceToman ?? 0) * (1 - (draft.course.discountPercent ?? 0) / 100));
|
||||
return <div className="grid gap-3 md:grid-cols-3"><Select label="نوع قیمتگذاری" value={draft.course.pricingType === "paid" ? "پولی" : "رایگان"} options={["رایگان", "پولی"]} onChange={(v) => patchCourse({ pricingType: v === "پولی" ? "paid" : "free" })} /><Input label="قیمت به تومان" value={String(draft.course.priceToman ?? 0)} onChange={(v) => patchCourse({ priceToman: Number(v) || 0 })} /><Input label="تخفیف درصدی" value={String(draft.course.discountPercent ?? 0)} onChange={(v) => patchCourse({ discountPercent: Number(v) || 0 })} /><div className="liquid-glass rounded-[18px] p-4">قیمت نهایی: {finalPrice.toLocaleString("fa-IR")} تومان</div><div className="liquid-glass rounded-[18px] p-4">ظرفیت ثبتنام: نامحدود</div><div className="liquid-glass rounded-[18px] p-4">{role === "super_admin" ? "قابل انتشار عمومی" : "انتشار سازمانی بدون تأیید مدیر کل"}</div></div>;
|
||||
}
|
||||
|
||||
function AssessmentAccess({ role }: { role: CourseBuilderRole }) {
|
||||
return <div className="grid gap-4 lg:grid-cols-2"><ConnectedExamPanel /><Panel title="ارزیابی دوره" items={["حداقل نمره قبولی", "تعداد تلاش مجاز", "زمان آزمون", "نمایش پاسخ صحیح", "شرط تکمیل با قبولی آزمون"]} /><Panel title="گواهی پایان دوره" items={["فعالسازی گواهی", "انتخاب قالب گواهی", "متن گواهی", "پیشنمایش گواهی"]} /><Panel title="مخاطبان" items={role === "super_admin" ? ["کل سازمان", "دپارتمان", "بخش", "واحد", "تیم", "نقش", "کاربر خاص", "سازمانهای انتخابی"] : ["کل سازمان", "دپارتمان", "بخش", "واحد", "تیم", "نقش", "کاربر خاص"]} /><Panel title="زمانبندی و دسترسی" items={["اجباری یا پیشنهادی", "تاریخ شروع", "تاریخ پایان", "مهلت تکمیل", "پیشنیاز اختیاری", "ارسال اعلان"]} /></div>;
|
||||
}
|
||||
|
||||
function ConnectedExamPanel() {
|
||||
return <div className="rounded-[20px] bg-white/10 p-4"><div className="flex flex-wrap items-center justify-between gap-2"><div><h4 className="font-bold">آزمون متصل</h4><p className="mt-1 text-xs opacity-65">برای انتشار دوره میتوانید آزمون موجود را وصل کنید یا آزمون جدید بسازید.</p></div><span className="rounded-full bg-amber-500/20 px-3 py-1 text-xs text-amber-600">بدون اتصال</span></div><div className="mt-3 flex flex-wrap gap-2 text-xs"><button className="rounded-2xl bg-white/15 px-3 py-2">انتخاب آزمون موجود</button><button className="rounded-2xl bg-emerald-500 px-3 py-2 font-bold text-white">ساخت آزمون جدید</button><button className="rounded-2xl bg-white/15 px-3 py-2">تغییر آزمون</button><button className="rounded-2xl bg-red-500 px-3 py-2 font-bold text-white">حذف اتصال آزمون</button><button className="rounded-2xl bg-white/15 px-3 py-2">مشاهده وضعیت آزمون</button></div></div>;
|
||||
}
|
||||
|
||||
function Preview({ draft, role, patchCourse }: { draft: CourseWizardDraft; role: CourseBuilderRole; patchCourse: (patch: Partial<CourseWizardDraft["course"]>) => void }) {
|
||||
const complete = Boolean(draft.course.titleFa && draft.course.categoryId && draft.course.level && draft.nodes.length);
|
||||
const contentCount = draft.nodes.reduce((sum, node) => sum + (node.contents?.length ?? 0), 0);
|
||||
return <div className="space-y-4"><Pricing draft={draft} role={role} patchCourse={patchCourse} /><div className={`rounded-[22px] p-4 ${complete ? "bg-emerald-500/20" : "bg-rose-500/20"}`}>{complete ? "دوره آماده انتشار است." : "برای انتشار، عنوان، دستهبندی، سطح و حداقل یک بخش لازم است."}</div><Panel title="خلاصه پیشنمایش" items={[draft.course.titleFa || "بدون عنوان", draft.course.ownerType === "platform" ? "عمومی سامانه" : "اختصاصی سازمان", draft.course.pricingType === "paid" ? "پولی" : "رایگان", `تعداد ساختارها: ${draft.nodes.length.toLocaleString("fa-IR")}`, `تعداد محتواها: ${contentCount.toLocaleString("fa-IR")}`, "پس از تخصیص، دوره در دورههای من مخاطبان نمایش داده میشود."]} /></div>;
|
||||
}
|
||||
|
||||
function guessFormat(fileName: string): CourseFormat {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
if (["mp4", "mov", "mkv", "webm"].includes(ext ?? "")) return "video";
|
||||
if (["mp3", "wav", "ogg"].includes(ext ?? "")) return "audio";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext ?? "")) return "image";
|
||||
if (["html", "htm"].includes(ext ?? "")) return "html5";
|
||||
if (["zip", "scorm"].includes(ext ?? "")) return "scorm";
|
||||
if (["txt", "md", "doc", "docx"].includes(ext ?? "")) return "text";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function Panel({ title, items }: { title: string; items: string[] }) {
|
||||
const [localItems, setLocalItems] = useState(() => items.map((label, index) => ({ id: `${title}-${index}`, label, enabled: true })));
|
||||
const addItem = () => {
|
||||
const label = window.prompt("عنوان آیتم جدید را وارد کنید", "آیتم جدید");
|
||||
if (!label?.trim()) return;
|
||||
setLocalItems((current) => [...current, { id: `${title}-${Date.now()}`, label: label.trim(), enabled: true }]);
|
||||
};
|
||||
return <section><div className="flex flex-wrap items-center justify-between gap-3"><h3 className="font-black">{title}</h3><button onClick={addItem} className="rounded-2xl bg-emerald-500 px-4 py-2 text-sm text-white">افزودن آیتم</button></div><div className="mt-3 grid gap-3 md:grid-cols-3">{localItems.map((item) => <div key={item.id} className="liquid-glass rounded-[18px] p-4 text-sm"><div className="flex items-start justify-between gap-2"><span className={item.enabled ? "" : "opacity-45"}>{item.label}</span><button onClick={() => setLocalItems((current) => current.map((row) => row.id === item.id ? { ...row, enabled: !row.enabled } : row))} className={`rounded-full px-2 py-1 text-xs ${item.enabled ? "bg-emerald-500/80 text-white" : "bg-white/15"}`}>{item.enabled ? "فعال" : "غیرفعال"}</button></div><div className="mt-3 flex gap-2"><button onClick={() => setLocalItems((current) => current.map((row) => row.id === item.id ? { ...row, enabled: !row.enabled } : row))} className="rounded-xl bg-white/15 px-3 py-1 text-xs">تغییر وضعیت</button><button onClick={() => window.confirm("آیا از حذف این آیتم مطمئن هستید؟") && setLocalItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl bg-rose-500/70 px-3 py-1 text-xs text-white">حذف</button></div></div>)}</div></section>;
|
||||
}
|
||||
|
||||
function Input({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return <label className="text-sm">{label}<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className="mt-2 w-full rounded-2xl bg-white/15 p-3 outline-none" /></label>;
|
||||
}
|
||||
|
||||
function Select({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
|
||||
return <label className="text-sm">{label}<select value={value} onChange={(e) => onChange(e.target.value)} className="mt-2 w-full rounded-2xl bg-white/15 p-3 outline-none">{options.map((item) => <option key={item}>{item}</option>)}</select></label>;
|
||||
}
|
||||
|
||||
function toFaNumber(value: number) {
|
||||
return value.toLocaleString("fa-IR");
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function CourseCopyModal({ open, sourceTitleFa, onCancel, onConfirm }: { open: boolean; sourceTitleFa?: string; onCancel: () => void; onConfirm: (titleFa: string) => void }) {
|
||||
const [titleFa, setTitleFa] = useState(sourceTitleFa ? `کپی ${sourceTitleFa}` : "کپی دوره");
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9998] grid place-items-center bg-black/35 p-4" dir="rtl">
|
||||
<div className="liquid-glass w-full max-w-lg rounded-[28px] p-5">
|
||||
<h3 className="text-lg font-black">کپی دوره</h3>
|
||||
<p className="mt-2 text-sm opacity-75">ساختار، محتوا، آزمونها، گواهی و تنظیمات کپی میشوند و ثبتنامها، گزارشها و پرداختها منتقل نمیشوند.</p>
|
||||
<label className="mt-4 block text-sm font-bold">عنوان دوره جدید</label>
|
||||
<input value={titleFa} onChange={(event) => setTitleFa(event.target.value)} className="mt-2 w-full rounded-2xl border border-white/15 bg-white/15 px-3 py-2 outline-none" />
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="rounded-2xl bg-white/15 px-4 py-2 text-sm">انصراف</button>
|
||||
<button onClick={() => onConfirm(titleFa)} className="rounded-2xl bg-emerald-500 px-4 py-2 text-sm text-white">ساخت کپی</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
export function CourseDeleteConfirmModal({ open, titleFa, onCancel, onConfirm }: { open: boolean; titleFa?: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9998] grid place-items-center bg-black/35 p-4" dir="rtl">
|
||||
<div className="liquid-glass w-full max-w-md rounded-[28px] p-5">
|
||||
<h3 className="text-lg font-black">حذف دوره</h3>
|
||||
<p className="mt-3 text-sm opacity-75">آیا از حذف {titleFa ? `«${titleFa}»` : "این مورد"} مطمئن هستید؟</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="rounded-2xl bg-white/15 px-4 py-2 text-sm">انصراف</button>
|
||||
<button onClick={onConfirm} className="rounded-2xl bg-rose-500 px-4 py-2 text-sm text-white">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
|
||||
export function CoursePublishConfirmModal({ open, onCancel, onConfirm }: { open: boolean; onCancel: () => void; onConfirm: () => void }) {
|
||||
if (!open) return null;
|
||||
return <div className="fixed inset-0 z-[320] flex items-center justify-center bg-black/35 p-5" dir="rtl"><div className="liquid-glass max-w-sm rounded-[24px] p-5 text-center text-slate-900 dark:text-white"><h3 className="font-black">انتشار دوره</h3><p className="mt-3 leading-7">آیا از انتشار این دوره مطمئن هستید؟</p><div className="mt-5 flex justify-center gap-2"><button onClick={onCancel} className="rounded-2xl bg-white/20 px-4 py-2">انصراف</button><button onClick={onConfirm} className="rounded-2xl bg-emerald-500 px-4 py-2 text-white">انتشار دوره</button></div></div></div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { formatToman, toPersianDigits } from "@/lib/persianFormat";
|
||||
import type { Course, CourseBuilderRole } from "@/types/courseBuilder";
|
||||
|
||||
export function CourseReportsSection({ courses, role }: { courses: Course[]; role: CourseBuilderRole }) {
|
||||
const publishedCount = courses.filter((course) => course.status === "published").length;
|
||||
const draftCount = courses.filter((course) => course.status === "draft").length;
|
||||
const paidRevenue = courses.reduce((sum, course) => sum + (course.pricingType === "paid" ? course.priceToman ?? 0 : 0), 0);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-black">گزارش دورهها</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ReportCard label="دورههای منتشرشده" value={publishedCount.toLocaleString("fa-IR")} />
|
||||
<ReportCard label="دورههای پیشنویس" value={draftCount.toLocaleString("fa-IR")} />
|
||||
<ReportCard label="میانگین تکمیل" value="۷۲٪" />
|
||||
<ReportCard label="میانگین امتیاز" value="۴٫۶ از ۵" />
|
||||
</div>
|
||||
<div className="liquid-glass rounded-[24px] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-black">نمای کلی عملکرد</h3>
|
||||
<p className="mt-1 text-sm opacity-70">ثبتنام، تکمیل، نمره، ریزش، گواهی و بازخوردها به صورت آماده اتصال به سرویس گزارش نمایش داده میشوند.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<button className="rounded-xl bg-white/15 px-3 py-2">خروجی اکسل</button>
|
||||
<button className="rounded-xl bg-white/15 px-3 py-2">خروجی پیدیاف</button>
|
||||
<button className="rounded-xl bg-white/15 px-3 py-2">چاپ</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1.35fr_1fr]">
|
||||
<div className="h-56 rounded-[22px] bg-gradient-to-l from-emerald-300/35 via-cyan-300/30 to-fuchsia-300/25 p-4">
|
||||
<p className="text-sm font-bold">نمودار روند تکمیل دورهها</p>
|
||||
<div className="mt-10 flex h-28 items-end gap-3">
|
||||
{[42, 64, 58, 78, 71, 88, 82].map((height, index) => (
|
||||
<span key={index} className="w-full rounded-t-2xl bg-white/55" style={{ height: `${height}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-[22px] bg-white/10 p-4">
|
||||
<p className="font-bold">شاخصهای مالی و آموزشی</p>
|
||||
<Metric label="تعداد ثبتنام" value="۱٬۲۴۸" />
|
||||
<Metric label="کاربران عقبافتاده" value="۸۳" />
|
||||
<Metric label="نرخ ریزش" value="۹٪" />
|
||||
<Metric label={role === "super_admin" ? "درآمد دورههای پولی" : "درآمد قابل مشاهده"} value={role === "super_admin" ? formatToman(paidRevenue) : "محدود به سازمان"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{courses.slice(0, 4).map((course, index) => (
|
||||
<article key={course.id} className="liquid-glass rounded-[20px] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-black">{course.titleFa}</h3>
|
||||
<p className="mt-1 text-sm opacity-70">{course.categoryFa} • {course.ownerType === "platform" ? "عمومی سامانه" : "اختصاصی سازمان"}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white/15 px-3 py-1 text-xs">{toPersianDigits(index + 1)} گزارش</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div className="rounded-2xl bg-white/10 p-3"><b>{toPersianDigits(120 + index * 37)}</b><span className="mt-1 block opacity-65">ثبتنام</span></div>
|
||||
<div className="rounded-2xl bg-white/10 p-3"><b>{toPersianDigits(70 + index * 5)}٪</b><span className="mt-1 block opacity-65">تکمیل</span></div>
|
||||
<div className="rounded-2xl bg-white/10 p-3"><b>۴٫{toPersianDigits(3 + index)}</b><span className="mt-1 block opacity-65">امتیاز</span></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportCard({ label, value }: { label: string; value: string }) {
|
||||
return <div className="liquid-glass rounded-[20px] p-4"><p className="text-sm opacity-70">{label}</p><p className="mt-3 text-2xl font-black">{value}</p></div>;
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex items-center justify-between rounded-2xl bg-white/10 px-3 py-2 text-sm"><span>{label}</span><b>{value}</b></div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { formatToman } from "@/lib/persianFormat";
|
||||
import type { Course, CourseBuilderRole } from "@/types/courseBuilder";
|
||||
|
||||
const statusFa = { draft: "پیشنویس", ready_to_publish: "آماده انتشار", published: "منتشرشده", inactive: "غیرفعال", archived: "آرشیوشده" } as const;
|
||||
const levelFa = { beginner: "مقدماتی", intermediate: "متوسط", advanced: "پیشرفته" } as const;
|
||||
|
||||
export function MyCoursesSection({ courses, filter, role }: { courses: Course[]; filter?: string; role: CourseBuilderRole }) {
|
||||
const rows = courses.filter((course) => !filter || course.status === filter || course.pricingType === filter || (filter === "platform" && course.ownerType === "platform") || (filter === "organization" && course.ownerType === "organization"));
|
||||
return <section className="space-y-4"><h2 className="text-xl font-black">دورهها</h2><div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rows.map((course) => <article key={course.id} className="liquid-glass rounded-[22px] p-4"><div className="h-24 rounded-[18px] bg-gradient-to-l from-emerald-300/50 to-cyan-500/45" /><h3 className="mt-3 font-black">{course.titleFa}</h3><p className="mt-2 text-sm opacity-70">{course.categoryFa} • {levelFa[course.level]} • {statusFa[course.status]}</p><div className="mt-2 flex flex-wrap gap-1">{course.tags.map((tag) => <span key={tag} className="rounded-full bg-white/15 px-2 py-1 text-xs">{tag}</span>)}</div><p className="mt-3 text-sm">{course.pricingType === "paid" ? formatToman(course.priceToman ?? 0) : "رایگان"}</p><p className="mt-1 text-xs opacity-70">{course.ownerType === "platform" ? "عمومی سامانه" : "اختصاصی سازمان"}{role === "org_admin" ? " • کاتالوگ سازمان من" : ""}</p><div className="mt-3 flex flex-wrap gap-2"><button className="rounded-xl bg-white/15 px-3 py-1 text-sm">ویرایش</button><button className="rounded-xl bg-white/15 px-3 py-1 text-sm">کپی دوره</button><button className="rounded-xl bg-white/15 px-3 py-1 text-sm">گزارش</button></div></article>)}</div></section>;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { CourseWizardStepContent } from "../CourseWizardSteps";
|
||||
|
||||
export const CourseAssignmentsStep = CourseWizardStepContent;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { CourseWizardStepContent } from "../CourseWizardSteps";
|
||||
|
||||
export const CourseAudienceStep = CourseWizardStepContent;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { CourseWizardStepContent } from "../CourseWizardSteps";
|
||||
|
||||
export const CourseBasicInfoStep = CourseWizardStepContent;
|
||||
برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر
بارگذاری…
مرجع در شماره جدید