From bb30ac6f30e0e17170c892e205f8b8e64cce2142 Mon Sep 17 00:00:00 2001 From: Toornaa Date: Mon, 31 Aug 2026 14:30:21 +0330 Subject: [PATCH] feat-project-workflow-updates --- .gitignore | 1 + .../Controllers/Api/MeetingController.php | 53 +++ .../Controllers/Api/SettingController.php | 74 ++++ backend/app/Models/Meeting.php | 5 + .../app/Models/MeetingEffectivenessReview.php | 32 ++ backend/app/Models/User.php | 5 + ...te_meeting_effectiveness_reviews_table.php | 32 ++ backend/php.ini | 13 + backend/routes/api.php | 5 + .../tests/Feature/BacklogLifecycleTest.php | 8 +- backend/tests/Feature/SettingTest.php | 33 ++ .../tests/Feature/WorkspaceFeaturesTest.php | 31 ++ frontend/e2e/command-center.spec.js | 230 +++++++++++- frontend/src/App.jsx | 16 +- frontend/src/components/Header.jsx | 11 +- frontend/src/components/PersianDateInput.jsx | 95 ++++- frontend/src/components/Sidebar.jsx | 16 +- frontend/src/pages/Dashboard.jsx | 15 +- frontend/src/pages/Kanban.jsx | 28 +- frontend/src/pages/MeetingWorkspace.jsx | 105 +++++- frontend/src/pages/Projects.jsx | 12 +- frontend/src/pages/Reports.jsx | 52 ++- frontend/src/pages/Settings.jsx | 329 +++++++++++++++--- frontend/src/pages/Tasks.jsx | 13 +- frontend/src/styles/global.css | 131 ++++++- frontend/src/utils/date.js | 13 + start-dev.bat | 67 ++++ 27 files changed, 1316 insertions(+), 109 deletions(-) create mode 100644 .gitignore create mode 100644 backend/app/Models/MeetingEffectivenessReview.php create mode 100644 backend/database/migrations/2026_08_31_000002_create_meeting_effectiveness_reviews_table.php create mode 100644 backend/php.ini create mode 100644 start-dev.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3018b3a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.tmp/ diff --git a/backend/app/Http/Controllers/Api/MeetingController.php b/backend/app/Http/Controllers/Api/MeetingController.php index cc40b31..d6a49a6 100644 --- a/backend/app/Http/Controllers/Api/MeetingController.php +++ b/backend/app/Http/Controllers/Api/MeetingController.php @@ -13,6 +13,7 @@ use App\Models\Decision; use App\Models\Meeting; use App\Models\MeetingActionItem; use App\Models\MeetingType; +use App\Models\Setting; use App\Models\Task; use App\Models\User; use App\Services\NotificationService; @@ -411,6 +412,58 @@ class MeetingController extends Controller ]); } + public function effectiveness(Request $request, Meeting $meeting): JsonResponse + { + $enabled = (bool) (Setting::where('key', 'meeting_effectiveness_enabled')->value('value') ?? false); + $reviews = $meeting->effectivenessReviews()->with('user:id,name')->latest()->get(); + $ratingFields = ['objective_clarity', 'time_management', 'participation', 'outcome_quality', 'overall_rating']; + $averages = collect($ratingFields)->mapWithKeys(fn (string $field) => [ + $field => $reviews->isEmpty() ? null : round((float) $reviews->avg($field), 1), + ]); + + return response()->json([ + 'success' => true, + 'data' => [ + 'enabled' => $enabled, + 'can_review' => $enabled && $meeting->status === 'completed', + 'current_user_review' => $reviews->firstWhere('user_id', $request->user()->id), + 'summary' => ['count' => $reviews->count(), 'averages' => $averages], + ], + 'message' => 'ارزیابی اثربخشی جلسه', + ]); + } + + public function storeEffectiveness(Request $request, Meeting $meeting): JsonResponse + { + $enabled = (bool) (Setting::where('key', 'meeting_effectiveness_enabled')->value('value') ?? false); + if (! $enabled) { + return response()->json(['success' => false, 'message' => 'ارزیابی اثربخشی جلسه غیرفعال است.'], 422); + } + if ($meeting->status !== 'completed') { + return response()->json(['success' => false, 'message' => 'ارزیابی فقط پس از پایان جلسه قابل ثبت است.'], 422); + } + + $data = $request->validate([ + 'objective_clarity' => 'required|integer|between:1,5', + 'time_management' => 'required|integer|between:1,5', + 'participation' => 'required|integer|between:1,5', + 'outcome_quality' => 'required|integer|between:1,5', + 'overall_rating' => 'required|integer|between:1,5', + 'comment' => 'nullable|string|max:2000', + ]); + + $review = $meeting->effectivenessReviews()->updateOrCreate( + ['user_id' => $request->user()->id], + $data, + ); + + return response()->json([ + 'success' => true, + 'data' => $review->fresh()->load('user:id,name'), + 'message' => $review->wasRecentlyCreated ? 'ارزیابی جلسه ثبت شد' : 'ارزیابی جلسه به‌روزرسانی شد', + ], $review->wasRecentlyCreated ? 201 : 200); + } + public function addDecision(Request $request, Meeting $meeting): JsonResponse { $data = $request->validate([ diff --git a/backend/app/Http/Controllers/Api/SettingController.php b/backend/app/Http/Controllers/Api/SettingController.php index 9ca47d3..53f4405 100644 --- a/backend/app/Http/Controllers/Api/SettingController.php +++ b/backend/app/Http/Controllers/Api/SettingController.php @@ -6,10 +6,23 @@ use App\Http\Controllers\Controller; use App\Models\Setting; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; class SettingController extends Controller { + public function branding(Request $request): JsonResponse + { + $companyName = Setting::where('key', 'company_name')->value('value') ?: 'مدیریت پروژه'; + $logoPath = Setting::where('key', 'company_logo')->value('value'); + + return response()->json([ + 'success' => true, + 'data' => $this->brandingData($request, $companyName, $logoPath), + 'message' => 'هویت سازمان', + ]); + } + public function index(Request $request): JsonResponse { try { @@ -103,4 +116,65 @@ class SettingController extends Controller ], 500); } } + + public function uploadCompanyLogo(Request $request): JsonResponse + { + $request->validate([ + 'logo' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048', + ]); + + $setting = Setting::firstOrNew(['key' => 'company_logo']); + $previousPath = is_string($setting->value) ? $setting->value : null; + $path = $request->file('logo')->store('organization-logos', 'public'); + + $setting->fill(['value' => $path, 'group' => 'general', 'type' => 'string'])->save(); + if ($previousPath && str_starts_with($previousPath, 'organization-logos/')) { + Storage::disk('public')->delete($previousPath); + } + + $companyName = Setting::where('key', 'company_name')->value('value') ?: 'مدیریت پروژه'; + + return response()->json([ + 'success' => true, + 'data' => [ + 'setting' => $setting->fresh(), + 'branding' => $this->brandingData($request, $companyName, $path), + ], + 'message' => 'لوگوی شرکت بارگذاری شد', + ], 201); + } + + public function removeCompanyLogo(Request $request): JsonResponse + { + $setting = Setting::where('key', 'company_logo')->first(); + $path = is_string($setting?->value) ? $setting->value : null; + if ($path && str_starts_with($path, 'organization-logos/')) { + Storage::disk('public')->delete($path); + } + $setting?->update(['value' => '']); + $companyName = Setting::where('key', 'company_name')->value('value') ?: 'مدیریت پروژه'; + + return response()->json([ + 'success' => true, + 'data' => [ + 'setting' => $setting?->fresh(), + 'branding' => $this->brandingData($request, $companyName, null), + ], + 'message' => 'لوگوی شرکت حذف شد', + ]); + } + + private function brandingData(Request $request, mixed $companyName, mixed $logoPath): array + { + $path = is_string($logoPath) ? $logoPath : null; + $logoUrl = $path && Storage::disk('public')->exists($path) + ? $request->getSchemeAndHttpHost().Storage::disk('public')->url($path) + : null; + + return [ + 'company_name' => is_string($companyName) ? $companyName : 'مدیریت پروژه', + 'logo_path' => $path, + 'logo_url' => $logoUrl, + ]; + } } diff --git a/backend/app/Models/Meeting.php b/backend/app/Models/Meeting.php index db9dee0..9ccbff0 100644 --- a/backend/app/Models/Meeting.php +++ b/backend/app/Models/Meeting.php @@ -79,6 +79,11 @@ class Meeting extends Model return $this->hasMany(Blocker::class); } + public function effectivenessReviews(): HasMany + { + return $this->hasMany(MeetingEffectivenessReview::class); + } + public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); diff --git a/backend/app/Models/MeetingEffectivenessReview.php b/backend/app/Models/MeetingEffectivenessReview.php new file mode 100644 index 0000000..3b8f3b1 --- /dev/null +++ b/backend/app/Models/MeetingEffectivenessReview.php @@ -0,0 +1,32 @@ + 'integer', + 'time_management' => 'integer', + 'participation' => 'integer', + 'outcome_quality' => 'integer', + 'overall_rating' => 'integer', + ]; + + public function meeting(): BelongsTo + { + return $this->belongsTo(Meeting::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 5fdbf11..82ed2f0 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -112,4 +112,9 @@ class User extends Authenticatable { return $this->hasMany(ActivityLog::class); } + + public function meetingEffectivenessReviews(): HasMany + { + return $this->hasMany(MeetingEffectivenessReview::class); + } } diff --git a/backend/database/migrations/2026_08_31_000002_create_meeting_effectiveness_reviews_table.php b/backend/database/migrations/2026_08_31_000002_create_meeting_effectiveness_reviews_table.php new file mode 100644 index 0000000..d284a17 --- /dev/null +++ b/backend/database/migrations/2026_08_31_000002_create_meeting_effectiveness_reviews_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('meeting_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('objective_clarity'); + $table->unsignedTinyInteger('time_management'); + $table->unsignedTinyInteger('participation'); + $table->unsignedTinyInteger('outcome_quality'); + $table->unsignedTinyInteger('overall_rating'); + $table->text('comment')->nullable(); + $table->timestamps(); + + $table->unique(['meeting_id', 'user_id']); + $table->index(['meeting_id', 'overall_rating']); + }); + } + + public function down(): void + { + Schema::dropIfExists('meeting_effectiveness_reviews'); + } +}; diff --git a/backend/php.ini b/backend/php.ini new file mode 100644 index 0000000..70cf0cb --- /dev/null +++ b/backend/php.ini @@ -0,0 +1,13 @@ +extension_dir = "${PHP_EXT_DIR}" + +extension=curl +extension=fileinfo +extension=gd +extension=mbstring +extension=mysqli +extension=openssl +extension=pdo_mysql +extension=pdo_sqlite +extension=zip + +date.timezone = "Asia/Tehran" diff --git a/backend/routes/api.php b/backend/routes/api.php index 1042a2e..fddc6cd 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -137,6 +137,8 @@ Route::middleware(['auth:sanctum', 'resource.access'])->group(function () { Route::delete('/meetings/{meeting}', [MeetingController::class, 'destroy'])->middleware('permission:meetings.delete'); Route::post('/meetings/{meeting}/start', [MeetingController::class, 'start'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/complete', [MeetingController::class, 'complete'])->middleware('permission:meetings.edit'); + Route::get('/meetings/{meeting}/effectiveness', [MeetingController::class, 'effectiveness'])->middleware('permission:meetings.view'); + Route::post('/meetings/{meeting}/effectiveness', [MeetingController::class, 'storeEffectiveness'])->middleware('permission:meetings.view'); Route::post('/meetings/{meeting}/decisions', [MeetingController::class, 'addDecision'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/structured-action-items', [MeetingController::class, 'addStructuredActionItem'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/blockers', [MeetingController::class, 'addBlocker'])->middleware('permission:meetings.edit'); @@ -200,8 +202,11 @@ Route::middleware(['auth:sanctum', 'resource.access'])->group(function () { Route::apiResource('permissions', PermissionController::class)->only(['index', 'show'])->middleware('permission:roles.view'); Route::get('/settings', [SettingController::class, 'index'])->middleware('permission:settings.view'); + Route::get('/branding', [SettingController::class, 'branding']); Route::post('/settings', [SettingController::class, 'store'])->middleware(['permission:settings.edit', 'throttle:sensitive']); Route::put('/settings/{setting}', [SettingController::class, 'update'])->middleware(['permission:settings.edit', 'throttle:sensitive']); + Route::post('/settings/company-logo', [SettingController::class, 'uploadCompanyLogo'])->middleware(['permission:settings.edit', 'throttle:sensitive']); + Route::delete('/settings/company-logo', [SettingController::class, 'removeCompanyLogo'])->middleware(['permission:settings.edit', 'throttle:sensitive']); Route::get('/search', [SearchController::class, 'search']); }); diff --git a/backend/tests/Feature/BacklogLifecycleTest.php b/backend/tests/Feature/BacklogLifecycleTest.php index 6025fd0..10c7c92 100644 --- a/backend/tests/Feature/BacklogLifecycleTest.php +++ b/backend/tests/Feature/BacklogLifecycleTest.php @@ -44,7 +44,11 @@ class BacklogLifecycleTest extends TestCase 'type' => 'feature', 'project_id' => $project->id, 'priority' => 'medium', - ])->assertCreated()->assertJsonPath('data.description', 'توضیحات اولیه'); + ])->assertCreated() + ->assertJsonPath('data.description', 'توضیحات اولیه') + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.is_archived', false) + ->assertJsonPath('data.archived_at', null); $backlogId = $response->json('data.id'); @@ -60,6 +64,8 @@ class BacklogLifecycleTest extends TestCase 'id' => $backlogId, 'description' => 'توضیحات ویرایش‌شده', 'status' => 'active', + 'archived_at' => null, + 'converted_to_task_id' => null, ]); } diff --git a/backend/tests/Feature/SettingTest.php b/backend/tests/Feature/SettingTest.php index 3415862..b01617a 100644 --- a/backend/tests/Feature/SettingTest.php +++ b/backend/tests/Feature/SettingTest.php @@ -5,6 +5,8 @@ namespace Tests\Feature; use App\Models\Setting; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class SettingTest extends TestCase @@ -78,4 +80,35 @@ class SettingTest extends TestCase $this->assertSame('smtp.example.com', $plain->fresh()->value['host']); } + + public function test_company_logo_can_be_uploaded_exposed_as_branding_and_removed(): void + { + Storage::fake('public'); + $user = User::factory()->create(['status' => 'active']); + $this->grantAdminRole($user); + $token = $user->createToken('api-token')->plainTextToken; + + $response = $this->withToken($token) + ->postJson('/api/settings/company-logo', [ + 'logo' => UploadedFile::fake()->image('company-logo.png', 240, 240), + ]) + ->assertCreated() + ->assertJsonPath('data.setting.key', 'company_logo'); + + $path = $response->json('data.setting.value'); + Storage::disk('public')->assertExists($path); + + $brandingResponse = $this->withToken($token) + ->getJson('/api/branding') + ->assertOk() + ->assertJsonPath('data.logo_path', $path); + $this->assertStringEndsWith('/storage/'.$path, $brandingResponse->json('data.logo_url')); + + $this->withToken($token) + ->deleteJson('/api/settings/company-logo') + ->assertOk() + ->assertJsonPath('data.branding.logo_url', null); + + Storage::disk('public')->assertMissing($path); + } } diff --git a/backend/tests/Feature/WorkspaceFeaturesTest.php b/backend/tests/Feature/WorkspaceFeaturesTest.php index d063bae..3e3e98d 100644 --- a/backend/tests/Feature/WorkspaceFeaturesTest.php +++ b/backend/tests/Feature/WorkspaceFeaturesTest.php @@ -6,6 +6,7 @@ use App\Models\Meeting; use App\Models\MeetingType; use App\Models\Notification; use App\Models\Project; +use App\Models\Setting; use App\Models\Sprint; use App\Models\Task; use App\Models\User; @@ -165,5 +166,35 @@ class WorkspaceFeaturesTest extends TestCase ->assertJsonCount(1, 'data.structured_decisions') ->assertJsonCount(1, 'data.structured_action_items') ->assertJsonCount(1, 'data.blockers'); + + Setting::updateOrCreate( + ['key' => 'meeting_effectiveness_enabled'], + ['value' => true, 'group' => 'meeting', 'type' => 'boolean'], + ); + $review = [ + 'objective_clarity' => 5, + 'time_management' => 4, + 'participation' => 4, + 'outcome_quality' => 5, + 'overall_rating' => 5, + 'comment' => 'جلسه مفید و نتیجه‌محور بود.', + ]; + + $this->withToken($token) + ->postJson("/api/meetings/{$meeting->id}/effectiveness", $review) + ->assertCreated() + ->assertJsonPath('data.overall_rating', 5); + + $this->withToken($token) + ->postJson("/api/meetings/{$meeting->id}/effectiveness", [...$review, 'overall_rating' => 4]) + ->assertOk() + ->assertJsonPath('data.overall_rating', 4); + + $this->withToken($token) + ->getJson("/api/meetings/{$meeting->id}/effectiveness") + ->assertOk() + ->assertJsonPath('data.summary.count', 1) + ->assertJsonPath('data.summary.averages.overall_rating', 4) + ->assertJsonPath('data.can_review', true); } } diff --git a/frontend/e2e/command-center.spec.js b/frontend/e2e/command-center.spec.js index 2872cec..3d6a23f 100644 --- a/frontend/e2e/command-center.spec.js +++ b/frontend/e2e/command-center.spec.js @@ -37,29 +37,43 @@ test('dashboard command surfaces and settings render without runtime errors', as upcoming_meetings: [], trends: [{ month: '2026-06', created: 12, completed: 9 }, { month: '2026-07', created: 14, completed: 13 }], } } }); + if (path === '/api/reports/project-status') return route.fulfill({ json: { success: true, data: [ + { status: 'in_progress', label: 'in_progress', count: 3, value: 3 }, + { status: 'cancelled', label: 'cancelled', count: 1, value: 1 }, + ] } }); + if (path === '/api/reports/team-performance') return route.fulfill({ json: { success: true, data: [ + { name: 'عضو نمونه تیم', completed: 8, in_progress: 3, delayed: 1 }, + ] } }); if (path === '/api/calendar/events') return route.fulfill({ json: { success: true, data: [] } }); if (path === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0, archived_count: 0 } } }); if (path === '/api/settings') return route.fulfill({ json: { success: true, data: [ { id: 1, key: 'working_days', value: ['saturday', 'sunday'], group: 'calendar', type: 'list' }, { id: 2, key: 'default_meeting_reminder_minutes', value: 30, group: 'meeting', type: 'number' }, ] } }); + if (path === '/api/branding') return route.fulfill({ json: { success: true, data: { company_name: 'شرکت آزمون', logo_path: null, logo_url: null } } }); return route.fulfill({ json: { success: true, data: [] } }); }); await page.goto('/'); await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible(); + await expect(page.getByRole('cell', { name: '۱۴۰۵ خرداد', exact: true })).toBeVisible(); + await expect(page.getByText('2026-06')).toHaveCount(0); await expect(page.locator('img.sidebar-brand-mark')).toBeVisible(); + await page.getByRole('button', { name: 'جمع کردن منوی اصلی' }).click(); + await expect(page.locator('.main-content')).toHaveClass(/sidebar-collapsed/); + await page.getByRole('button', { name: 'باز کردن منوی اصلی' }).click(); + await expect(page.locator('.main-content')).not.toHaveClass(/sidebar-collapsed/); await page.getByRole('button', { name: 'تقویم رویدادها' }).click(); await expect(page.getByRole('region', { name: 'تقویم رویدادها' })).toBeVisible(); await expect(page.getByText('برنامه روز')).toBeVisible(); - await page.mouse.click(900, 760); + await page.keyboard.press('Escape'); await expect(page.locator('.calendar-popover')).toBeHidden(); await page.getByRole('button', { name: 'اعلان‌ها' }).click(); await expect(page.getByLabel('مرکز اعلان‌ها')).toBeVisible(); await expect(page.getByText(/برای آرشیو به راست/)).toBeVisible(); - await page.mouse.click(900, 760); + await page.keyboard.press('Escape'); await expect(page.locator('.notification-center')).toBeHidden(); await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click(); @@ -84,10 +98,32 @@ test('dashboard command surfaces and settings render without runtime errors', as await page.goto('/settings'); await expect(page.getByRole('heading', { name: 'مرکز تنظیمات' })).toBeVisible(); - await expect(page.getByText('شخصی‌سازی')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'شخصی‌سازی' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'تنظیم جدید' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'ساده' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'پیشرفته' })).toHaveCount(0); + await page.getByRole('button', { name: /تقویم و زمان کاری/ }).click(); + await expect(page.getByText('شنبه', { exact: true })).toBeVisible(); + await page.getByText('جزئیات فنی', { exact: true }).first().click(); + await expect(page.locator('.settings-technical-details').first().locator('input[readonly]')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('settings-command-center.png'), fullPage: true }); + await page.goto('/reports'); + await expect(page.getByRole('heading', { name: 'جزئیات' })).toBeVisible(); + await expect(page.getByText('در حال انجام', { exact: true }).last()).toBeVisible(); + await expect(page.getByText('لغوشده', { exact: true }).last()).toBeVisible(); + await expect(page.getByText('in_progress', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'عملکرد تیم' }).click(); + const teamNameTick = page.locator('.team-performance-chart text').filter({ hasText: 'عضو نمونه تیم' }); + await expect(teamNameTick).toBeVisible(); + const tickAndGrid = await page.locator('.team-performance-chart').evaluate((chart) => { + const tick = [...chart.querySelectorAll('text')].find((node) => node.textContent.includes('عضو نمونه تیم')); + const gridLine = chart.querySelector('.recharts-cartesian-grid line'); + return { tickRight: tick.getBoundingClientRect().right, gridLeft: gridLine.getBoundingClientRect().left }; + }); + expect(tickAndGrid.tickRight).toBeLessThanOrEqual(tickAndGrid.gridLeft + 1); + await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => localStorage.setItem('preferredAppMode', 'desktop')); await page.goto('/'); @@ -107,6 +143,194 @@ test('dashboard command surfaces and settings render without runtime errors', as expect(runtimeErrors).toEqual([]); }); +test('dark projects and task details keep readable centered surfaces', async ({ page }, testInfo) => { + const user = { id: 1, name: 'مدیر رابط', email: 'manager@example.test' }; + const project = { id: 10, title: 'پروژه خوانا در تاریکی', description: 'شرح نمونه پروژه', status: 'in_progress', priority: 'high', progress: 45, project_manager: user, end_date: '2026-09-20' }; + const task = { id: 20, title: 'تسک مودال مرکزی', description: 'شرح تسک برای بررسی مودال', project_id: project.id, project, assignee: user, assignee_id: user.id, priority: 'medium', status: 'todo', start_date: '2026-09-01', due_date: '2026-09-10', estimated_time: 4 }; + + await page.addInitScript(({ testUser }) => { + localStorage.setItem('token', 'visual-details-token'); + localStorage.setItem('user', JSON.stringify(testUser)); + localStorage.setItem('theme', 'dark'); + }, { testUser: user }); + + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === '/api/user') return route.fulfill({ json: { success: true, data: user } }); + if (url.pathname === '/api/projects' && route.request().method() === 'GET') return route.fulfill({ json: { success: true, data: [project], meta: { current_page: 1, last_page: 1 } } }); + if (url.pathname === '/api/tasks' && route.request().method() === 'GET') return route.fulfill({ json: { success: true, data: [task], meta: { current_page: 1, last_page: 1 } } }); + if (url.pathname === '/api/users') return route.fulfill({ json: { success: true, data: [user] } }); + if (url.pathname === '/api/departments') return route.fulfill({ json: { success: true, data: [], flat: [] } }); + if (url.pathname === `/api/tasks/${task.id}/checklists`) return route.fulfill({ json: { success: true, data: [] } }); + if (url.pathname === '/api/comments') return route.fulfill({ json: { success: true, data: [] } }); + if (url.pathname === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0 } } }); + return route.fulfill({ json: { success: true, data: [] } }); + }); + + await page.goto('/projects'); + const projectTitle = page.getByRole('link', { name: project.title }); + await expect(projectTitle).toBeVisible(); + expect(await projectTitle.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(255, 255, 255)'); + + await page.goto('/tasks'); + await page.getByRole('cell', { name: task.title, exact: true }).click(); + const dialog = page.getByRole('dialog', { name: task.title }); + await expect(dialog).toBeVisible(); + const metrics = await dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const styles = getComputedStyle(element); + return { + centerOffsetX: Math.abs((rect.left + rect.width / 2) - window.innerWidth / 2), + centerOffsetY: Math.abs((rect.top + rect.height / 2) - window.innerHeight / 2), + radii: [styles.borderTopLeftRadius, styles.borderTopRightRadius, styles.borderBottomRightRadius, styles.borderBottomLeftRadius], + }; + }); + expect(metrics.centerOffsetX).toBeLessThan(3); + expect(metrics.centerOffsetY).toBeLessThan(3); + expect(metrics.radii.every((radius) => parseFloat(radius) >= 18)).toBe(true); + await page.screenshot({ path: testInfo.outputPath('dark-task-modal.png'), fullPage: true }); +}); + +test('backlog conversion date pickers stay visible inside the modal flow', async ({ page }, testInfo) => { + const user = { id: 1, name: 'مدیر بک‌لاگ', email: 'backlog@example.test' }; + const member = { id: 2, name: 'عضو پروژه', email: 'member@example.test', status: 'active' }; + const project = { id: 10, title: 'پروژه تقویم', project_manager: user, members: [member] }; + const backlog = { + id: 30, + title: 'بک‌لاگ قابل تبدیل', + description: 'بررسی نمایش تقویم در مودال', + type: 'feature', + priority: 'medium', + project_id: project.id, + project, + is_archived: false, + }; + let conversionPayload; + + await page.addInitScript(({ testUser }) => { + localStorage.setItem('token', 'backlog-date-token'); + localStorage.setItem('user', JSON.stringify(testUser)); + }, { testUser: user }); + + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + const method = route.request().method(); + if (url.pathname === '/api/user') return route.fulfill({ json: { success: true, data: user } }); + if (url.pathname === '/api/backlog-items' && method === 'GET') return route.fulfill({ json: { success: true, data: [backlog] } }); + if (url.pathname === '/api/projects' && method === 'GET') return route.fulfill({ json: { success: true, data: [project] } }); + if (url.pathname === '/api/users' && method === 'GET') return route.fulfill({ json: { success: true, data: [user, member] } }); + if (url.pathname === `/api/projects/${project.id}` && method === 'GET') return route.fulfill({ json: { success: true, data: project } }); + if (url.pathname === `/api/backlog-items/${backlog.id}/convert-to-task` && method === 'POST') { + conversionPayload = route.request().postDataJSON(); + return route.fulfill({ json: { success: true, data: { id: 99 } } }); + } + if (url.pathname === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0 } } }); + return route.fulfill({ json: { success: true, data: [] } }); + }); + + const assertCalendarIsInViewport = async (calendar) => { + await expect(calendar).toBeVisible(); + const metrics = await calendar.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const centerElement = document.elementFromPoint(rect.left + (rect.width / 2), rect.top + 12); + return { + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + isTopLayerHit: Boolean(centerElement?.closest('.persian-date-popover')), + }; + }); + expect(metrics.top).toBeGreaterThanOrEqual(0); + expect(metrics.left).toBeGreaterThanOrEqual(0); + expect(metrics.right).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.bottom).toBeLessThanOrEqual(metrics.viewportHeight); + expect(metrics.isTopLayerHit).toBe(true); + }; + + await page.goto('/backlog'); + await page.getByRole('button', { name: `تبدیل ${backlog.title} به تسک` }).click(); + const conversionDialog = page.getByRole('dialog', { name: 'تبدیل بک‌لاگ به تسک' }); + await expect(conversionDialog).toBeVisible(); + await conversionDialog.locator('#task-assignee').selectOption(String(member.id)); + + const startDateTrigger = conversionDialog.locator('.form-group').filter({ hasText: 'تاریخ شروع' }).locator('.persian-date-input'); + await startDateTrigger.click(); + const startCalendar = page.getByRole('dialog', { name: 'انتخاب تاریخ شمسی' }); + await assertCalendarIsInViewport(startCalendar); + await page.keyboard.press('Escape'); + await expect(startCalendar).toBeHidden(); + await expect(conversionDialog).toBeVisible(); + await expect(startDateTrigger).toBeFocused(); + await startDateTrigger.click(); + await assertCalendarIsInViewport(startCalendar); + await startCalendar.locator('button[aria-current="date"]').click(); + await expect(startDateTrigger).toHaveAttribute('aria-expanded', 'false'); + + await page.setViewportSize({ width: 390, height: 700 }); + const dueDateTrigger = conversionDialog.locator('.form-group').filter({ hasText: 'تاریخ پایان' }).locator('.persian-date-input'); + await dueDateTrigger.click(); + const dueCalendar = page.getByRole('dialog', { name: 'انتخاب تاریخ شمسی' }); + await assertCalendarIsInViewport(dueCalendar); + await page.screenshot({ path: testInfo.outputPath('mobile-backlog-date-picker.png'), fullPage: true }); + await dueCalendar.locator('button[aria-current="date"]').click(); + + await conversionDialog.getByRole('button', { name: 'ایجاد و تخصیص تسک' }).click(); + await expect(conversionDialog).toBeHidden(); + expect(conversionPayload.assignee_id).toBe(String(member.id)); + expect(conversionPayload.start_date).toBeTruthy(); + expect(conversionPayload.due_date).toBe(conversionPayload.start_date); +}); + +test('kanban task cards show assignee photos and keep initials as fallback', async ({ page }) => { + const user = { id: 1, name: 'مدیر کانبان', email: 'kanban@example.test' }; + const avatarDataUrl = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="20" height="20"%3E%3Crect width="20" height="20" fill="%236366f1"/%3E%3C/svg%3E'; + const tasks = [ + { + id: 71, + title: 'تسک دارای عکس', + status: 'todo', + priority: 'medium', + assignee: { id: 2, name: 'علی رضایی', avatar: 'avatars/ali.webp', avatar_url: avatarDataUrl }, + project: { id: 10, title: 'پروژه کانبان' }, + }, + { + id: 72, + title: 'تسک بدون عکس', + status: 'in_progress', + priority: 'low', + assignee: { id: 3, name: 'بهار احمدی', avatar: null, avatar_url: null }, + project: { id: 10, title: 'پروژه کانبان' }, + }, + ]; + + await page.addInitScript(({ testUser }) => { + localStorage.setItem('token', 'kanban-avatar-token'); + localStorage.setItem('user', JSON.stringify(testUser)); + }, { testUser: user }); + + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/user') return route.fulfill({ json: { success: true, data: user } }); + if (path === '/api/tasks') return route.fulfill({ json: { success: true, data: tasks } }); + if (path === '/api/projects') return route.fulfill({ json: { success: true, data: [{ id: 10, title: 'پروژه کانبان' }] } }); + if (path === '/api/users') return route.fulfill({ json: { success: true, data: tasks.map((task) => task.assignee) } }); + if (path === '/api/branding') return route.fulfill({ json: { success: true, data: { company_name: 'مدیریت پروژه', logo_url: null } } }); + if (path === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0 } } }); + return route.fulfill({ json: { success: true, data: [] } }); + }); + + await page.goto('/kanban'); + const photoCard = page.locator('.kanban-task-card[data-task-id="71"]'); + const fallbackCard = page.locator('.kanban-task-card[data-task-id="72"]'); + + await expect(photoCard.getByRole('img', { name: 'عکس پروفایل علی رضایی' })).toBeVisible(); + await expect(fallbackCard.locator('.kanban-assignee-avatar')).toHaveText('ب'); + await expect(fallbackCard.locator('.kanban-assignee-avatar img')).toHaveCount(0); +}); + test('project task can be transferred to an approved project member', async ({ page }) => { const assignPermission = { id: 1, name: 'tasks.assign', display_name: 'تخصیص وظایف' }; const user = { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2a9d5cd..8d0dc43 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,13 +35,16 @@ function PageFallback() { function AppLayout({ children, title }) { const [collapsed, setCollapsed] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 768px)').matches); useEffect(() => { const handleKeyDown = (event) => { if (event.key === 'Escape') setMobileNavOpen(false); }; const handleResize = () => { - if (window.innerWidth > 768) setMobileNavOpen(false); + const nextIsMobile = window.matchMedia('(max-width: 768px)').matches; + setIsMobile(nextIsMobile); + if (!nextIsMobile) setMobileNavOpen(false); }; document.addEventListener('keydown', handleKeyDown); window.addEventListener('resize', handleResize); @@ -56,12 +59,21 @@ function AppLayout({ children, title }) { setMobileNavOpen((current) => !current); }; + const togglePrimaryNav = () => { + if (isMobile) { + toggleMobileNav(); + return; + } + setMobileNavOpen(false); + setCollapsed((current) => !current); + }; + return (
setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} /> {mobileNavOpen &&
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 18fb693..b3b5bfd 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -11,7 +11,7 @@ import useDismissibleLayer from '../hooks/useDismissibleLayer'; import Modal from './Modal'; import Profile from '../pages/Profile'; -export default function Header({ title, collapsed = false, mobileNavOpen = false, onToggleMobileNav }) { +export default function Header({ title, collapsed = false, mobileNavOpen = false, isMobile = false, onToggleNavigation }) { const { user, logout } = useAuth(); const navigate = useNavigate(); const [activeLayer, setActiveLayer] = useState(null); @@ -20,6 +20,7 @@ export default function Header({ title, collapsed = false, mobileNavOpen = false const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [unreadCount, setUnreadCount] = useState(0); + const navigationExpanded = isMobile ? mobileNavOpen : !collapsed; const calendarButtonRef = useRef(null); const calendarPanelRef = useRef(null); const notificationButtonRef = useRef(null); @@ -97,11 +98,11 @@ export default function Header({ title, collapsed = false, mobileNavOpen = false
diff --git a/frontend/src/components/PersianDateInput.jsx b/frontend/src/components/PersianDateInput.jsx index 90f49f2..af7f999 100644 --- a/frontend/src/components/PersianDateInput.jsx +++ b/frontend/src/components/PersianDateInput.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { getJalaliWeekday, @@ -31,6 +31,9 @@ export default function PersianDateInput({ value, onChange, className = '', plac const [open, setOpen] = useState(false); const [view, setView] = useState(selected || today); const wrapRef = useRef(null); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const popoverId = useId(); useEffect(() => { if (selected) setView(selected); @@ -44,6 +47,67 @@ export default function PersianDateInput({ value, onChange, className = '', plac return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + useEffect(() => { + if (!open) return undefined; + + const handleEscape = (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + }; + + document.addEventListener('keydown', handleEscape, true); + return () => document.removeEventListener('keydown', handleEscape, true); + }, [open]); + + useLayoutEffect(() => { + if (!open) return undefined; + + const trigger = triggerRef.current; + const popover = popoverRef.current; + if (!trigger || !popover) return undefined; + + const supportsPopover = typeof popover.showPopover === 'function'; + const containingModal = popover.closest('.modal'); + if (!supportsPopover) containingModal?.classList.add('modal-has-floating-date'); + if (supportsPopover && !popover.matches(':popover-open')) popover.showPopover(); + + const positionPopover = () => { + const triggerRect = trigger.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + const viewportGap = 8; + const controlGap = 6; + const width = popoverRect.width || Math.min(292, window.innerWidth - (viewportGap * 2)); + const height = popoverRect.height; + const roomBelow = window.innerHeight - triggerRect.bottom - viewportGap; + const roomAbove = triggerRect.top - viewportGap; + const openAbove = height > roomBelow && roomAbove > roomBelow; + const preferredTop = openAbove + ? triggerRect.top - height - controlGap + : triggerRect.bottom + controlGap; + const preferredLeft = document.documentElement.dir === 'rtl' + ? triggerRect.right - width + : triggerRect.left; + + popover.style.top = `${Math.max(viewportGap, Math.min(preferredTop, window.innerHeight - height - viewportGap))}px`; + popover.style.left = `${Math.max(viewportGap, Math.min(preferredLeft, window.innerWidth - width - viewportGap))}px`; + popover.dataset.placement = openAbove ? 'top' : 'bottom'; + }; + + positionPopover(); + window.addEventListener('resize', positionPopover); + window.addEventListener('scroll', positionPopover, true); + + return () => { + window.removeEventListener('resize', positionPopover); + window.removeEventListener('scroll', positionPopover, true); + containingModal?.classList.remove('modal-has-floating-date'); + if (supportsPopover && popover.matches(':popover-open')) popover.hidePopover(); + }; + }, [open, view]); + const days = useMemo(() => { const firstWeekday = getJalaliWeekday(view.jy, view.jm, 1); const length = jalaliMonthLength(view.jy, view.jm); @@ -79,13 +143,29 @@ export default function PersianDateInput({ value, onChange, className = '', plac return (
- {open && ( -
+ )} @@ -150,6 +198,61 @@ function ResultSection({ title, items = [] }) { return

{title}

{items?.length || 0}
{!items?.length ?
موردی ثبت نشده است.
: items.map((item) =>
{item.title}{item.description || item.status}
{item.status || item.severity}
)}
; } +const effectivenessFields = [ + ['objective_clarity', 'وضوح هدف جلسه'], + ['time_management', 'مدیریت زمان'], + ['participation', 'مشارکت اعضا'], + ['outcome_quality', 'کیفیت خروجی‌ها'], + ['overall_rating', 'امتیاز کلی'], +]; + +function EffectivenessPanel({ data, form, setForm, saving, onSubmit }) { + const averages = data.summary?.averages || {}; + return ( +
+
+

ارزیابی اثربخشی جلسه

+ {data.summary?.count || 0} پاسخ +
+ {(data.summary?.count || 0) > 0 && ( +
+ {effectivenessFields.map(([key, label]) =>
{label}{averages[key]?.toLocaleString('fa-IR') || '—'} از ۵
)} +
+ )} + {data.can_review && ( +
+

{data.current_user_review ? 'می‌توانید ارزیابی قبلی خود را به‌روزرسانی کنید.' : 'تجربه این جلسه را ثبت کنید تا کیفیت جلسات بعدی بهتر شود.'}

+
+ {effectivenessFields.map(([key, label]) => ( +
+ {label} +
+ {[1, 2, 3, 4, 5].map((rating) => ( + + ))} +
+
+ ))} +
+