feat: initialize project with Symfony, React, and Tailwind CSS setup

- Add package.json with development and production dependencies
- Create postcss.config.js for Tailwind CSS integration
- Implement AdminController for handling admin routes
- Add admin index template with React root element
- Create base template for consistent layout
- Configure TypeScript with tsconfig.json
- Set up Webpack configuration for asset management
This commit is contained in:
hamed
2026-06-09 22:27:16 +03:30
parent de1a78a235
commit c98d7b7968
33 changed files with 10399 additions and 116 deletions
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import AdminLayout from './components/layout/AdminLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/admin/login" replace />;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route
path="/admin/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/admin/*"
element={
<PrivateRoute>
<AdminLayout />
</PrivateRoute>
}
>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
</Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes>
);
}
@@ -0,0 +1,18 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import Topbar from './Topbar';
export default function AdminLayout() {
return (
<div className="flex h-screen overflow-hidden bg-[#f1f5f9]">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import React from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import {
ChartBarIcon,
UserGroupIcon,
HeartIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
CreditCardIcon,
BanknotesIcon,
UsersIcon,
ChatBubbleLeftEllipsisIcon,
StarIcon,
DevicePhoneMobileIcon,
TagIcon,
DocumentTextIcon,
KeyIcon,
ChevronRightIcon,
ChevronLeftIcon,
MagnifyingGlassIcon,
ArrowLeftOnRectangleIcon,
} from '@heroicons/react/24/outline';
import { useUiStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
const sections = [
{
label: 'عمومی',
items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' },
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیک‌ها' },
],
},
{
label: 'مدیریت',
items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' },
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداخت‌ها' },
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویه‌حساب' },
],
},
{
label: 'محتوا',
items: [
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' },
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' },
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' },
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' },
],
},
{
label: 'سیستم',
items: [
{ to: '/admin/categories', icon: TagIcon, label: 'دسته‌بندی‌ها' },
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' },
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشی‌ها' },
],
},
];
export default function Sidebar() {
const open = useUiStore((s) => s.sidebarOpen);
const toggle = useUiStore((s) => s.toggleSidebar);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/admin/login');
};
return (
<aside
style={{ transition: 'width 300ms cubic-bezier(0.4,0,0.2,1)' }}
className={`${open ? 'w-[260px]' : 'w-[72px]'} shrink-0 bg-[#0f172a] flex flex-col h-screen overflow-hidden`}
>
{/* Logo */}
<div className="flex items-center justify-between px-4 py-5 border-b border-slate-700/50">
{open && (
<span className="text-white font-bold text-lg tracking-tight">ClinicPro</span>
)}
<button
onClick={toggle}
className="text-slate-400 hover:text-white p-1 rounded-md hover:bg-slate-700/50 transition-colors"
>
{open ? <ChevronRightIcon className="w-5 h-5" /> : <ChevronLeftIcon className="w-5 h-5" />}
</button>
</div>
{/* Search */}
{open && (
<div className="px-3 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-3 py-2">
<MagnifyingGlassIcon className="w-4 h-4 text-slate-400 shrink-0" />
<input
type="text"
placeholder="جستجوی سریع..."
className="bg-transparent text-sm text-slate-300 placeholder-slate-500 outline-none w-full"
/>
</div>
</div>
)}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-3 space-y-1 scrollbar-thin">
{sections.map((section) => (
<div key={section.label} className="mb-2">
{open && (
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider px-4 py-1">
{section.label}
</p>
)}
{section.items.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 mx-2 px-3 py-2.5 rounded-lg text-sm transition-colors group ${
isActive
? 'bg-primary-500/15 text-primary-400 font-semibold border-r-4 border-primary-500'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
}`
}
title={!open ? label : undefined}
>
<Icon className="w-5 h-5 shrink-0" />
{open && <span>{label}</span>}
</NavLink>
))}
</div>
))}
</nav>
{/* User card */}
<div className="border-t border-slate-700/50 p-3">
{open ? (
<div className="flex items-center gap-3 bg-slate-800 rounded-xl p-3">
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-bold shrink-0">
A
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">Admin</p>
<p className="text-slate-400 text-xs truncate">مدیر سیستم</p>
</div>
<button
onClick={handleLogout}
className="text-slate-400 hover:text-red-400 transition-colors"
title="خروج"
>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
</button>
</div>
) : (
<button
onClick={handleLogout}
className="w-full flex justify-center py-2 text-slate-400 hover:text-red-400 transition-colors"
title="خروج"
>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
</button>
)}
</div>
</aside>
);
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Bars3Icon, BellIcon } from '@heroicons/react/24/outline';
import { useUiStore } from '../../stores/uiStore';
interface TopbarProps {
pageTitle?: string;
breadcrumbs?: { label: string; to?: string }[];
}
export default function Topbar({ pageTitle, breadcrumbs }: TopbarProps) {
const toggle = useUiStore((s) => s.toggleSidebar);
return (
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0">
<div className="flex items-center gap-4">
<button
onClick={toggle}
className="text-gray-500 hover:text-gray-700 p-1 rounded-md hover:bg-gray-100 transition-colors md:hidden"
>
<Bars3Icon className="w-5 h-5" />
</button>
<div>
{pageTitle && <h2 className="text-base font-semibold text-gray-800">{pageTitle}</h2>}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav className="flex items-center gap-1 text-xs text-gray-500">
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
{i > 0 && <span>/</span>}
<span className={i === breadcrumbs.length - 1 ? 'text-gray-700 font-medium' : ''}>
{crumb.label}
</span>
</React.Fragment>
))}
</nav>
)}
</div>
</div>
<div className="flex items-center gap-3">
<button className="relative text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100 transition-colors">
<BellIcon className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</button>
<div className="flex items-center gap-2 cursor-pointer">
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-bold">
A
</div>
<span className="text-sm font-medium text-gray-700 hidden sm:block">Admin</span>
</div>
</div>
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
import App from './App';
import './styles.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 30_000 },
},
});
const root = document.getElementById('admin-root')!;
createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster position="top-left" richColors />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
+129
View File
@@ -0,0 +1,129 @@
import React from 'react';
import {
UserGroupIcon,
HeartIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
CreditCardIcon,
ChatBubbleLeftEllipsisIcon,
BanknotesIcon,
ArrowTrendingUpIcon,
} from '@heroicons/react/24/outline';
interface StatCardProps {
label: string;
value: string | number;
trend?: string;
trendUp?: boolean;
icon: React.ElementType;
iconBg: string;
iconColor: string;
}
function StatCard({ label, value, trend, trendUp, icon: Icon, iconBg, iconColor }: StatCardProps) {
return (
<div className="bg-white rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,.08)] p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">{label}</p>
<p className="text-3xl font-bold text-gray-900">{value}</p>
{trend && (
<div className={`flex items-center gap-1 mt-2 text-xs font-medium ${trendUp ? 'text-emerald-600' : 'text-red-500'}`}>
<ArrowTrendingUpIcon className={`w-3.5 h-3.5 ${!trendUp && 'rotate-180'}`} />
<span>{trend}</span>
</div>
)}
</div>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${iconBg}`}>
<Icon className={`w-6 h-6 ${iconColor}`} />
</div>
</div>
</div>
);
}
const stats: StatCardProps[] = [
{
label: 'کل کاربران',
value: '۱۲,۴۸۴',
trend: '۸٪ نسبت به ماه قبل',
trendUp: true,
icon: UserGroupIcon,
iconBg: 'bg-violet-100',
iconColor: 'text-violet-600',
},
{
label: 'پزشکان فعال',
value: '۱,۲۸۴',
trend: '۱۲٪ نسبت به ماه قبل',
trendUp: true,
icon: HeartIcon,
iconBg: 'bg-emerald-100',
iconColor: 'text-emerald-600',
},
{
label: 'کلینیک‌ها',
value: '۳۲۱',
trend: '۴٪ نسبت به ماه قبل',
trendUp: false,
icon: BuildingOffice2Icon,
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
},
{
label: 'نوبت‌های امروز',
value: '۱۴۸',
trend: '۲۳٪ نسبت به دیروز',
trendUp: true,
icon: CalendarDaysIcon,
iconBg: 'bg-orange-100',
iconColor: 'text-orange-600',
},
{
label: 'پرداخت‌های امروز',
value: '۴۸,۲۰۰,۰۰۰',
trend: '۱۵٪ نسبت به دیروز',
trendUp: true,
icon: CreditCardIcon,
iconBg: 'bg-pink-100',
iconColor: 'text-pink-600',
},
{
label: 'نظرات در انتظار',
value: '۱۲',
icon: ChatBubbleLeftEllipsisIcon,
iconBg: 'bg-yellow-100',
iconColor: 'text-yellow-600',
},
{
label: 'درخواست تسویه',
value: '۵',
icon: BanknotesIcon,
iconBg: 'bg-teal-100',
iconColor: 'text-teal-600',
},
];
export default function DashboardPage() {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">داشبورد</h1>
<p className="text-sm text-gray-500 mt-1">خلاصه وضعیت سیستم</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{stats.map((stat) => (
<StatCard key={stat.label} {...stat} />
))}
</div>
<div className="mt-6 bg-white rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,.08)] p-6">
<h3 className="text-base font-semibold text-gray-800 mb-4">فعالیتهای اخیر</h3>
<div className="text-center py-10 text-gray-400 text-sm">
در حال توسعه...
</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { useAuthStore } from '../stores/authStore';
const schema = z.object({
mobile: z.string().min(10, 'شماره موبایل معتبر نیست'),
password: z.string().min(6, 'رمز عبور باید حداقل ۶ کاراکتر باشد'),
});
type FormData = z.infer<typeof schema>;
export default function LoginPage() {
const login = useAuthStore((s) => s.login);
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
try {
const res = await fetch('/api/v1/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: data.mobile, password: data.password }),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.message || 'خطا در ورود');
return;
}
const json = await res.json();
login(json.access_token, json.refresh_token ?? '');
toast.success('ورود موفق');
} catch {
toast.error('خطا در اتصال به سرور');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#f1f5f9]">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">ClinicPro</h1>
<p className="text-gray-500 text-sm mt-1">پنل مدیریت</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
شماره موبایل
</label>
<input
{...register('mobile')}
type="tel"
dir="ltr"
placeholder="09xxxxxxxxx"
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-right focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
{errors.mobile && (
<p className="text-red-500 text-xs mt-1">{errors.mobile.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
رمز عبور
</label>
<input
{...register('password')}
type="password"
placeholder="••••••••"
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
{errors.password && (
<p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full h-11 bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white font-medium rounded-[10px] transition-colors"
>
{isSubmitting ? 'در حال ورود...' : 'ورود'}
</button>
</form>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (token: string, refreshToken: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
refreshToken: null,
isAuthenticated: false,
login: (token, refreshToken) =>
set({ token, refreshToken, isAuthenticated: true }),
logout: () =>
set({ token: null, refreshToken: null, isAuthenticated: false }),
}),
{ name: 'clinicpro-auth' }
)
);
+13
View File
@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface UiState {
sidebarOpen: boolean;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
}
export const useUiStore = create<UiState>((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
}));
+30
View File
@@ -0,0 +1,30 @@
@import "tailwindcss";
@theme {
--color-primary-50: #f5f3ff;
--color-primary-100: #ede9fe;
--color-primary-200: #ddd6fe;
--color-primary-300: #c4b5fd;
--color-primary-400: #a78bfa;
--color-primary-500: #8b5cf6;
--color-primary-600: #7c3aed;
--color-primary-700: #6d28d9;
--color-primary-800: #5b21b6;
--color-primary-900: #4c1d95;
--color-bg-body: #f1f5f9;
--color-bg-card: #ffffff;
--color-bg-sidebar: #0f172a;
--color-bg-sidebar-active: rgba(139, 92, 246, 0.15);
--font-sans: "Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif;
}
* {
font-family: var(--font-sans);
}
body {
background-color: var(--color-bg-body);
direction: rtl;
}