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