harden access controls and prepare mysql launch

مخزن کامیت contained در
مسعود نیک زاد 2026-07-29 16:07:37 +03:30
والد 53ab7244e4
کامیت d22b776572
123فایلهای تغییر یافته به همراه5057 افزوده شده و 777 حذف شده

60
.github/workflows/ci.yml فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1,60 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, pdo_sqlite, pdo_mysql
coverage: none
- run: composer install --no-interaction --prefer-dist --no-progress
- run: composer validate --strict --no-check-publish
- run: vendor/bin/pint --test
- run: php artisan test
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npx oxlint src
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: composer audit --locked --working-dir=backend
- run: npm audit --prefix frontend --audit-level=high

مشاهده پرونده

@ -27,11 +27,23 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
# Controlled SQLite -> MySQL migration (keep separate from the active connection)
MIGRATION_SQLITE_PATH=
MIGRATION_MYSQL_HOST=127.0.0.1
MIGRATION_MYSQL_PORT=3306
MIGRATION_MYSQL_DATABASE=pm_migration
MIGRATION_MYSQL_USERNAME=
MIGRATION_MYSQL_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=false
SANCTUM_TOKEN_EXPIRATION=480
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:5173,127.0.0.1,127.0.0.1:8000
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local

مشاهده پرونده

@ -0,0 +1,268 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
class TransferSqliteToMysql extends Command
{
protected $signature = 'db:transfer-sqlite-to-mysql
{--execute : Perform writes; without this flag the command is a dry run}
{--truncate : Empty transferable target tables before importing}
{--chunk=250 : Rows processed per batch}';
protected $description = 'Idempotently transfer application data from SQLite to a separately configured MySQL database';
private const EXCLUDED_TABLES = [
'migrations',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'password_reset_tokens',
'sessions',
'personal_access_tokens',
];
private const NATURAL_KEYS = [
'role_user' => ['role_id', 'user_id'],
'permission_role' => ['permission_id', 'role_id'],
'project_user' => ['project_id', 'user_id'],
'sprint_task' => ['sprint_id', 'task_id'],
'sprint_members' => ['sprint_id', 'user_id'],
'meeting_user' => ['meeting_id', 'user_id'],
'department_user' => ['department_id', 'user_id'],
];
public function handle(): int
{
$sourcePath = config('database.connections.migration_sqlite.database');
if (! is_string($sourcePath) || ! is_file($sourcePath)) {
$this->error('SQLite source file does not exist.');
return self::FAILURE;
}
if (config('database.connections.migration_mysql.driver') !== 'mysql') {
$this->error('The migration target must use the mysql driver.');
return self::FAILURE;
}
$source = DB::connection('migration_sqlite');
$target = DB::connection('migration_mysql');
try {
$target->select('SELECT 1');
} catch (Throwable $exception) {
$this->error('MySQL target connection failed: '.$exception->getCode());
return self::FAILURE;
}
$tables = $this->transferableTables($source);
if ($tables === []) {
$this->warn('No transferable tables were found.');
return self::SUCCESS;
}
$this->table(['Mode', 'Source', 'Target', 'Tables'], [[
$this->option('execute') ? 'EXECUTE' : 'DRY RUN',
basename($sourcePath),
(string) config('database.connections.migration_mysql.database'),
count($tables),
]]);
if (! $this->option('execute')) {
foreach ($tables as $table) {
$this->line(sprintf('%s: %d rows', $table, $source->table($table)->count()));
}
return self::SUCCESS;
}
$chunkSize = max(10, min((int) $this->option('chunk'), 2000));
Schema::connection('migration_mysql')->disableForeignKeyConstraints();
try {
if ($this->option('truncate')) {
foreach (array_reverse($tables) as $table) {
$target->table($table)->truncate();
}
}
foreach ($tables as $table) {
$this->transferTable($source, $target, $table, $chunkSize);
}
} finally {
Schema::connection('migration_mysql')->enableForeignKeyConstraints();
}
$failed = false;
foreach ($tables as $table) {
$sourceCount = $source->table($table)->count();
$targetCount = $target->table($table)->count();
$sourceDigest = $this->tableDigest($source, $table);
$targetDigest = $this->tableDigest($target, $table);
$match = $sourceCount === $targetCount && hash_equals($sourceDigest, $targetDigest);
$failed = $failed || ! $match;
$this->line(sprintf(
'%s source=%d target=%d content=%s %s',
$table,
$sourceCount,
$targetCount,
hash_equals($sourceDigest, $targetDigest) ? 'MATCH' : 'MISMATCH',
$match ? 'OK' : 'MISMATCH'
));
}
if ($failed) {
$this->error('Transfer completed with count mismatches.');
return self::FAILURE;
}
$this->info('Transfer and row-count validation completed successfully.');
return self::SUCCESS;
}
private function transferableTables(ConnectionInterface $source): array
{
$sourceTables = collect($source->select(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
))->pluck('name');
$targetTables = collect(Schema::connection('migration_mysql')->getTableListing())
->map(fn (string $table) => str_contains($table, '.') ? str($table)->afterLast('.')->toString() : $table);
$missing = $sourceTables->diff($targetTables)->reject(
fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true)
);
if ($missing->isNotEmpty()) {
throw new RuntimeException('Target schema is missing tables: '.$missing->implode(', '));
}
return $sourceTables
->intersect($targetTables)
->reject(fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true))
->values()
->all();
}
private function transferTable(
ConnectionInterface $source,
ConnectionInterface $target,
string $table,
int $chunkSize
): void {
$columns = collect(Schema::connection('migration_mysql')->getColumnListing($table));
$keys = $this->keyColumns($source, $table);
$orderColumn = $keys[0] ?? $columns->first();
$processed = 0;
$source->table($table)
->orderBy($orderColumn)
->chunk($chunkSize, function ($rows) use ($target, $table, $columns, $keys, &$processed) {
$payload = collect($rows)->map(
fn ($row) => collect((array) $row)->only($columns)->all()
)->all();
if ($payload === []) {
return;
}
if ($keys !== []) {
$target->table($table)->upsert($payload, $keys, $columns->diff($keys)->all());
} else {
foreach ($payload as $row) {
$target->table($table)->updateOrInsert($row, $row);
}
}
$processed += count($payload);
});
$this->info(sprintf('%s: %d rows transferred', $table, $processed));
}
private function keyColumns(ConnectionInterface $source, string $table): array
{
if (isset(self::NATURAL_KEYS[$table])) {
return self::NATURAL_KEYS[$table];
}
return collect($source->select("PRAGMA table_info('".str_replace("'", "''", $table)."')"))
->filter(fn ($column) => (int) $column->pk > 0)
->sortBy(fn ($column) => (int) $column->pk)
->pluck('name')
->all();
}
private function tableDigest(ConnectionInterface $connection, string $table): string
{
$columns = Schema::connection($connection->getName())->getColumnListing($table);
sort($columns);
$order = $this->keyColumns(DB::connection('migration_sqlite'), $table);
if ($order === []) {
$order = $columns;
}
$query = $connection->table($table)->select($columns);
foreach ($order as $column) {
$query->orderBy($column);
}
$context = hash_init('sha256');
foreach ($query->cursor() as $row) {
$normalized = [];
foreach ($columns as $column) {
$normalized[$column] = $this->normalizeValue($row->{$column});
}
hash_update($context, json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n");
}
return hash_final($context);
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null) {
return null;
}
$string = (string) $value;
if (preg_match('/^\d{4}-\d{2}-\d{2} 00:00:00$/', $string)) {
return substr($string, 0, 10);
}
$decoded = json_decode($string, true);
if (json_last_error() === JSON_ERROR_NONE && (str_starts_with($string, '{') || str_starts_with($string, '['))) {
return $this->normalizeJson($decoded);
}
if (preg_match('/^-?\d+\.\d+$/', $string)) {
return rtrim(rtrim($string, '0'), '.');
}
return $string;
}
private function normalizeJson(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
return array_map(fn ($item) => $this->normalizeJson($item), $value);
}
}

مشاهده پرونده

@ -3,18 +3,21 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\ActivityLogService;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class AuthController extends Controller
{
@ -195,10 +198,12 @@ class AuthController extends Controller
}
$user->update(['password' => Hash::make($request->new_password)]);
$user->tokens()->delete();
return response()->json([
'success' => true,
'message' => 'رمز عبور با موفقیت تغییر یافت',
'reauthentication_required' => true,
]);
} catch (\Exception $e) {
return response()->json([
@ -210,9 +215,45 @@ class AuthController extends Controller
public function forgotPassword(Request $request): JsonResponse
{
$data = $request->validate(['email' => 'required|email']);
Password::sendResetLink(['email' => strtolower($data['email'])]);
return response()->json([
'success' => true,
'message' => 'لینک بازیابی رمز عبور به ایمیل شما ارسال شد',
'message' => 'اگر حسابی با این ایمیل وجود داشته باشد، لینک بازیابی ارسال می‌شود.',
]);
}
public function resetPassword(Request $request): JsonResponse
{
$data = $request->validate([
'token' => 'required|string',
'email' => 'required|email',
'password' => 'required|string|min:12|confirmed',
]);
$status = Password::reset(
$data,
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
$user->tokens()->delete();
event(new PasswordReset($user));
}
);
if ($status !== Password::PASSWORD_RESET) {
return response()->json([
'success' => false,
'message' => 'توکن بازیابی نامعتبر یا منقضی شده است.',
], 422);
}
return response()->json([
'success' => true,
'message' => 'رمز عبور تغییر کرد. دوباره وارد شوید.',
]);
}

مشاهده پرونده

@ -8,15 +8,24 @@ use App\Http\Resources\BacklogItemResource;
use App\Http\Resources\TaskResource;
use App\Models\BacklogItem;
use App\Models\Task;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BacklogController extends Controller
{
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
public function index(Request $request): JsonResponse
{
try {
$query = BacklogItem::with(['project', 'assignedSprint', 'creator']);
if (! $this->resourceAccess->canSeeAll($request->user())) {
$query->where(function ($scope) use ($request) {
$scope->where('created_by', $request->user()->id)
->orWhereIn('project_id', $this->resourceAccess->projects($request->user())->select('projects.id'));
});
}
if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id);
@ -31,8 +40,10 @@ class BacklogController extends Controller
$query->where('priority', $request->priority);
}
$perPage = $request->input('per_page', 15);
$sortBy = $request->input('sort_by', 'created_at');
$perPage = min(max((int) $request->input('per_page', 15), 1), 100);
$sortBy = in_array($request->input('sort_by'), ['id', 'title', 'priority', 'status', 'created_at', 'updated_at'], true)
? $request->input('sort_by')
: 'created_at';
$sortDir = $request->input('sort_dir', 'desc');
$items = $query->orderBy($sortBy, $sortDir)->paginate($perPage);
@ -77,6 +88,13 @@ class BacklogController extends Controller
{
try {
$data = $request->validated();
if (! empty($data['project_id'])) {
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
}
$data['created_by'] = $request->user()->id;
$item = BacklogItem::create($data);
$item->load(['project', 'assignedSprint', 'creator']);
@ -109,7 +127,7 @@ class BacklogController extends Controller
$backlogItem->update($request->only([
'title', 'description', 'type', 'priority',
'estimated_effort', 'status', 'assigned_sprint_id'
'estimated_effort', 'status', 'assigned_sprint_id',
]));
$backlogItem->load(['project', 'assignedSprint', 'creator']);

مشاهده پرونده

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CalendarService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CalendarController extends Controller
{
public function __construct(private readonly CalendarService $calendarService) {}
public function events(Request $request): JsonResponse
{
$data = $request->validate([
'from' => 'nullable|date',
'to' => 'nullable|date|after_or_equal:from',
'types' => 'nullable|array',
'types.*' => 'string|in:meeting,task,sprint,project,action_item',
]);
$from = Carbon::parse($data['from'] ?? now()->startOfMonth())->startOfDay();
$to = Carbon::parse($data['to'] ?? now()->endOfMonth())->endOfDay();
if ($from->diffInDays($to) > 93) {
return response()->json(['success' => false, 'message' => 'بازه تقویم نمی‌تواند بیشتر از ۹۳ روز باشد.'], 422);
}
return response()->json([
'success' => true,
'data' => $this->calendarService->events($request->user(), $from, $to, $data['types'] ?? []),
'meta' => ['from' => $from->toDateString(), 'to' => $to->toDateString()],
'message' => 'رویدادهای تقویم',
]);
}
}

مشاهده پرونده

@ -6,9 +6,13 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreCommentRequest;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -17,12 +21,13 @@ class CommentController extends Controller
public function __construct(
protected ActivityLogService $activityLogService,
protected NotificationService $notificationService,
protected ResourceAccessService $resourceAccess,
) {}
public function index(Request $request): JsonResponse
{
try {
$query = Comment::with(['user', 'files']);
$query = $this->resourceAccess->comments($request->user())->with(['user', 'files']);
if ($request->filled('commentable_type')) {
$query->where('commentable_type', $request->commentable_type);
@ -57,6 +62,13 @@ class CommentController extends Controller
{
try {
$data = $request->validated();
$allowed = match ($data['commentable_type']) {
Project::class => $this->resourceAccess->projects($request->user())->whereKey($data['commentable_id'])->exists(),
Task::class => $this->resourceAccess->tasks($request->user())->whereKey($data['commentable_id'])->exists(),
Meeting::class => $this->resourceAccess->meetings($request->user())->whereKey($data['commentable_id'])->exists(),
default => false,
};
abort_unless($allowed, 403, 'شما به محل انتخاب‌شده برای نظر دسترسی ندارید.');
$data['user_id'] = $request->user()->id;
$comment = Comment::create($data);
$comment->load('user');

مشاهده پرونده

@ -46,4 +46,22 @@ class DashboardController extends Controller
], 500);
}
}
public function monitoring(Request $request): JsonResponse
{
try {
return response()->json([
'success' => true,
'data' => $this->dashboardService->getMonitoring($request->user()),
'message' => 'مرکز مانیتورینگ',
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => 'خطا در دریافت اطلاعات مانیتورینگ',
], 500);
}
}
}

مشاهده پرونده

@ -50,6 +50,7 @@ class DepartmentController extends Controller
private function formatNode($dept, $all): array
{
$children = $all->where('parent_id', $dept->id)->values();
return [
'id' => $dept->id,
'name' => $dept->name,
@ -117,6 +118,7 @@ class DepartmentController extends Controller
{
try {
$department->load(['manager', 'parent', 'children']);
return response()->json([
'success' => true,
'data' => $department,

مشاهده پرونده

@ -4,8 +4,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\FileResource;
use App\Models\Comment;
use App\Models\File;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Task;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -14,10 +18,12 @@ use Illuminate\Validation\ValidationException;
class FileController extends Controller
{
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
public function index(Request $request): JsonResponse
{
try {
$query = File::with('user');
$query = $this->resourceAccess->files($request->user())->with('user');
if ($request->filled('fileable_type')) {
$query->where('fileable_type', $request->fileable_type);
@ -73,6 +79,15 @@ class FileController extends Controller
], 422);
}
$allowed = match ($fileableType) {
Project::class => $this->resourceAccess->projects($request->user())->whereKey($fileableId)->exists(),
Task::class => $this->resourceAccess->tasks($request->user())->whereKey($fileableId)->exists(),
Meeting::class => $this->resourceAccess->meetings($request->user())->whereKey($fileableId)->exists(),
Comment::class => $this->resourceAccess->comments($request->user())->whereKey($fileableId)->exists(),
default => false,
};
abort_unless($allowed, 403, 'شما به محل انتخاب‌شده برای فایل دسترسی ندارید.');
$uploadedFile = $request->file('file');
$originalName = $uploadedFile->getClientOriginalName();
$extension = strtolower($uploadedFile->getClientOriginalExtension());

مشاهده پرونده

@ -4,25 +4,34 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreMeetingRequest;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\MeetingActionItemResource;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\TaskResource;
use App\Models\ActionItem;
use App\Models\Blocker;
use App\Models\Decision;
use App\Models\Meeting;
use App\Models\MeetingActionItem;
use App\Models\MeetingType;
use App\Models\Task;
use App\Models\User;
use App\Services\NotificationService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeetingController extends Controller
{
public function __construct(protected NotificationService $notificationService) {}
public function __construct(
protected NotificationService $notificationService,
protected ResourceAccessService $resourceAccess,
) {}
public function index(Request $request): JsonResponse
{
try {
$query = Meeting::with(['project', 'creator', 'participants']);
$query = $this->resourceAccess->meetings($request->user())
->with(['project', 'sprint', 'type', 'creator', 'participants']);
if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id);
@ -36,6 +45,12 @@ class MeetingController extends Controller
if ($request->filled('meeting_type')) {
$query->where('meeting_type', $request->meeting_type);
}
if ($request->filled('sprint_id')) {
$query->where('sprint_id', $request->sprint_id);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$perPage = $request->input('per_page', 15);
$sortBy = $request->input('sort_by', 'date');
@ -64,7 +79,11 @@ class MeetingController extends Controller
public function show(Meeting $meeting): JsonResponse
{
try {
$meeting->load(['project', 'creator', 'participants', 'actionItems.assignee']);
$meeting->load([
'project', 'sprint', 'type', 'creator', 'owner', 'facilitator',
'participants', 'actionItems.assignee', 'structuredDecisions',
'structuredActionItems', 'blockers', 'files',
]);
return response()->json([
'success' => true,
@ -83,9 +102,17 @@ class MeetingController extends Controller
{
try {
$data = $request->validated();
if (! empty($data['project_id'])) {
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
}
$data['created_by'] = $request->user()->id;
$data['owner_id'] ??= $request->user()->id;
$meeting = Meeting::create($data);
$meeting->load(['project', 'creator']);
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner']);
return response()->json([
'success' => true,
@ -103,24 +130,43 @@ class MeetingController extends Controller
public function update(Request $request, Meeting $meeting): JsonResponse
{
try {
$request->validate([
$data = $request->validate([
'title' => 'sometimes|required|string|max:255',
'date' => 'sometimes|required|date',
'start_time' => 'nullable',
'end_time' => 'nullable',
'meeting_type' => 'sometimes|required|string|max:50',
'meeting_type_id' => 'nullable|exists:meeting_types,id',
'project_id' => 'nullable|exists:projects,id',
'sprint_id' => 'nullable|exists:sprints,id',
'status' => 'sometimes|string|in:draft,scheduled,in_progress,completed,cancelled,postponed',
'location' => 'nullable|string',
'meeting_link' => 'nullable|string',
'meeting_link' => 'nullable|url',
'objective' => 'nullable|string',
'agenda' => 'nullable|string',
'notes' => 'nullable|string',
'summary' => 'nullable|string',
'decisions' => 'nullable|string',
'owner_id' => 'nullable|exists:users,id',
'facilitator_id' => 'nullable|exists:users,id',
'reminder_minutes' => 'nullable|integer|min:0|max:10080',
'recurrence_rule' => 'nullable|array',
]);
if (isset($data['project_id'])) {
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
}
$meeting->update($request->only([
'title', 'date', 'start_time', 'end_time', 'meeting_type',
'location', 'meeting_link', 'agenda', 'notes', 'decisions'
]));
$meeting->load(['project', 'creator']);
$meeting->update(collect($data)->only([
'title', 'project_id', 'sprint_id', 'date', 'start_time', 'end_time',
'meeting_type', 'meeting_type_id', 'status', 'location', 'meeting_link',
'objective', 'agenda', 'notes', 'summary', 'decisions', 'owner_id',
'facilitator_id', 'reminder_minutes', 'recurrence_rule',
])->all());
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner', 'facilitator']);
return response()->json([
'success' => true,
@ -277,6 +323,11 @@ class MeetingController extends Controller
$request->validate([
'project_id' => 'required|exists:projects,id',
]);
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($request->project_id)->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
$task = Task::create([
'title' => $actionItem->title,
@ -303,4 +354,131 @@ class MeetingController extends Controller
], 500);
}
}
public function workspace(Meeting $meeting): JsonResponse
{
$meeting->load([
'project', 'sprint', 'type', 'creator', 'owner', 'facilitator',
'participants', 'actionItems.assignee', 'structuredDecisions',
'structuredActionItems', 'blockers', 'files',
]);
return response()->json([
'success' => true,
'data' => new MeetingResource($meeting),
'message' => 'فضای کاری جلسه',
]);
}
public function start(Request $request, Meeting $meeting): JsonResponse
{
if (! in_array($meeting->status, ['draft', 'scheduled', 'postponed'], true)) {
return response()->json(['success' => false, 'message' => 'این جلسه قابل شروع نیست'], 422);
}
$meeting->update(['status' => 'in_progress', 'started_at' => now()]);
return response()->json([
'success' => true,
'data' => new MeetingResource($meeting->fresh()),
'message' => 'جلسه شروع شد',
]);
}
public function complete(Request $request, Meeting $meeting): JsonResponse
{
$data = $request->validate([
'summary' => 'nullable|string',
'force' => 'nullable|boolean',
]);
if ($meeting->status !== 'in_progress') {
return response()->json(['success' => false, 'message' => 'فقط جلسه در حال برگزاری قابل تکمیل است'], 422);
}
if (blank($data['summary'] ?? $meeting->summary) && ! $request->boolean('force')) {
return response()->json(['success' => false, 'message' => 'برای تکمیل جلسه، خلاصه را ثبت کنید یا تأیید ویژه بدهید.'], 422);
}
$meeting->update([
'status' => 'completed',
'completed_at' => now(),
'summary' => $data['summary'] ?? $meeting->summary,
]);
return response()->json([
'success' => true,
'data' => new MeetingResource($meeting->fresh()),
'message' => 'جلسه تکمیل شد',
]);
}
public function addDecision(Request $request, Meeting $meeting): JsonResponse
{
$data = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'status' => 'nullable|in:proposed,pending,approved,rejected,reversed,superseded',
'owner_id' => 'nullable|exists:users,id',
'rationale' => 'nullable|string',
'impact' => 'nullable|string',
]);
$decision = Decision::create($data + [
'meeting_id' => $meeting->id,
'sprint_id' => $meeting->sprint_id,
'project_id' => $meeting->project_id,
'created_by' => $request->user()->id,
]);
return response()->json(['success' => true, 'data' => $decision, 'message' => 'تصمیم ثبت شد'], 201);
}
public function addStructuredActionItem(Request $request, Meeting $meeting): JsonResponse
{
$data = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'owner_id' => 'required|exists:users,id',
'due_date' => 'required|date',
'priority' => 'nullable|in:low,medium,high,urgent',
]);
$item = ActionItem::create($data + [
'meeting_id' => $meeting->id,
'sprint_id' => $meeting->sprint_id,
'project_id' => $meeting->project_id,
'created_by' => $request->user()->id,
]);
return response()->json(['success' => true, 'data' => $item, 'message' => 'اقدام ثبت شد'], 201);
}
public function addBlocker(Request $request, Meeting $meeting): JsonResponse
{
$data = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'severity' => 'nullable|in:low,medium,high,critical',
'owner_id' => 'nullable|exists:users,id',
'task_id' => 'nullable|exists:tasks,id',
'due_date' => 'nullable|date',
]);
$blocker = Blocker::create($data + [
'meeting_id' => $meeting->id,
'sprint_id' => $meeting->sprint_id,
'project_id' => $meeting->project_id,
'raised_by' => $request->user()->id,
]);
return response()->json(['success' => true, 'data' => $blocker, 'message' => 'مانع ثبت شد'], 201);
}
public function types(): JsonResponse
{
return response()->json([
'success' => true,
'data' => MeetingType::where('is_active', true)->orderBy('name')->get(),
'message' => 'انواع جلسه',
]);
}
}

مشاهده پرونده

@ -15,6 +15,12 @@ class NotificationController extends Controller
{
try {
$query = $request->user()->notifications();
$archived = $request->boolean('archived');
$query->when($archived, fn ($q) => $q->whereNotNull('archived_at'))
->when(! $archived, fn ($q) => $q->whereNull('archived_at'));
if ($request->boolean('unread')) {
$query->whereNull('read_at');
}
$perPage = $request->input('per_page', 15);
$notifications = $query->orderBy('created_at', 'desc')->paginate($perPage);
@ -27,7 +33,8 @@ class NotificationController extends Controller
'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(),
'total' => $notifications->total(),
'unread_count' => $request->user()->notifications()->whereNull('read_at')->count(),
'unread_count' => $request->user()->notifications()->whereNull('archived_at')->whereNull('read_at')->count(),
'archived_count' => $request->user()->notifications()->whereNotNull('archived_at')->count(),
],
'message' => 'لیست اعلان‌ها',
]);
@ -107,4 +114,55 @@ class NotificationController extends Controller
], 500);
}
}
public function archive(Request $request, Notification $notification): JsonResponse
{
if ($notification->user_id !== $request->user()->id) {
return response()->json(['success' => false, 'message' => 'شما اجازه آرشیو این اعلان را ندارید'], 403);
}
$notification->update([
'archived_at' => now(),
'read_at' => $notification->read_at ?? now(),
'is_read' => true,
]);
return response()->json([
'success' => true,
'data' => new NotificationResource($notification->fresh()),
'message' => 'اعلان آرشیو شد',
]);
}
public function restore(Request $request, Notification $notification): JsonResponse
{
if ($notification->user_id !== $request->user()->id) {
return response()->json(['success' => false, 'message' => 'شما اجازه بازیابی این اعلان را ندارید'], 403);
}
$notification->update(['archived_at' => null]);
return response()->json([
'success' => true,
'data' => new NotificationResource($notification->fresh()),
'message' => 'اعلان از آرشیو خارج شد',
]);
}
public function preview(Request $request, Notification $notification): JsonResponse
{
if ($notification->user_id !== $request->user()->id) {
return response()->json(['success' => false, 'message' => 'شما اجازه مشاهده این اعلان را ندارید'], 403);
}
if (! $notification->read_at) {
$notification->update(['read_at' => now(), 'is_read' => true]);
}
return response()->json([
'success' => true,
'data' => new NotificationResource($notification->fresh()),
'message' => 'جزئیات اعلان',
]);
}
}

مشاهده پرونده

@ -11,6 +11,7 @@ use App\Models\User;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use App\Services\ProjectProgressService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -20,12 +21,14 @@ class ProjectController extends Controller
protected ActivityLogService $activityLogService,
protected NotificationService $notificationService,
protected ProjectProgressService $projectProgressService,
protected ResourceAccessService $resourceAccess,
) {}
public function index(Request $request): JsonResponse
{
try {
$query = Project::with(['projectManager', 'creator', 'department']);
$query = $this->resourceAccess->projects($request->user())
->with(['projectManager', 'creator', 'department']);
if ($request->filled('status')) {
$query->where('status', $request->status);

مشاهده پرونده

@ -5,15 +5,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\CommentResource;
use App\Http\Resources\FileResource;
use App\Models\Notification;
use App\Models\ActivityLog;
use App\Models\Comment;
use App\Models\File;
use App\Models\Notification;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use App\Services\ProjectProgressService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -22,7 +23,10 @@ use Illuminate\Validation\Rule;
class PwaController extends Controller
{
public function __construct(protected ProjectProgressService $projectProgressService) {}
public function __construct(
protected ProjectProgressService $projectProgressService,
protected ResourceAccessService $resourceAccess,
) {}
public function profile(Request $request): JsonResponse
{
@ -784,18 +788,7 @@ class PwaController extends Controller
private function visibleTasksQuery(User $user)
{
if ($user->hasPermission('reports.view')) {
return Task::query();
}
$manageableProjectIds = $this->manageableProjectIds($user);
return Task::query()->where(function ($query) use ($user, $manageableProjectIds) {
$query->where('assignee_id', $user->id)
->orWhere('reporter_id', $user->id)
->orWhere('created_by', $user->id)
->when($manageableProjectIds->isNotEmpty(), fn($innerQuery) => $innerQuery->orWhereIn('project_id', $manageableProjectIds));
});
return $this->resourceAccess->tasks($user);
}
private function abortUnlessTaskVisible(Request $request, Task $task): void
@ -807,15 +800,7 @@ class PwaController extends Controller
private function visibleProjectsQuery(User $user)
{
if ($user->hasPermission('reports.view')) {
return Project::query();
}
return Project::query()->where(function ($query) use ($user) {
$query->where('project_manager_id', $user->id)
->orWhere('created_by', $user->id)
->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id));
});
return $this->resourceAccess->projects($user);
}
private function abortUnlessProjectVisible(Request $request, Project $project): void

مشاهده پرونده

@ -33,7 +33,7 @@ class ReportController extends Controller
{
try {
$data = $this->reportService->delayedTasks($request->only([
'date_from', 'date_to', 'project_id', 'user_id', 'status'
'date_from', 'date_to', 'project_id', 'user_id', 'status',
]));
return response()->json([
@ -53,7 +53,7 @@ class ReportController extends Controller
{
try {
$data = $this->reportService->teamPerformance($request->only([
'date_from', 'date_to', 'user_id'
'date_from', 'date_to', 'user_id',
]));
return response()->json([
@ -91,7 +91,7 @@ class ReportController extends Controller
{
try {
$data = $this->reportService->sprintProgress($request->only([
'project_id', 'status'
'project_id', 'status',
]));
return response()->json([
@ -111,7 +111,7 @@ class ReportController extends Controller
{
try {
$data = $this->reportService->timeEstimate($request->only([
'date_from', 'date_to', 'project_id', 'user_id', 'status'
'date_from', 'date_to', 'project_id', 'user_id', 'status',
]));
return response()->json([
@ -131,7 +131,7 @@ class ReportController extends Controller
{
try {
$data = $this->reportService->recentActivities($request->only([
'date_from', 'date_to', 'project_id', 'user_id', 'action'
'date_from', 'date_to', 'project_id', 'user_id', 'action',
]));
return response()->json([
@ -151,7 +151,7 @@ class ReportController extends Controller
{
try {
$data = $this->reportService->riskyProjects($request->only([
'status', 'project_id'
'status', 'project_id',
]));
return response()->json([

مشاهده پرونده

@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRoleRequest;
use App\Http\Requests\UpdateRoleRequest;
use App\Http\Resources\RoleResource;
use App\Http\Resources\PermissionResource;
use App\Http\Resources\RoleResource;
use App\Models\Role;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

مشاهده پرونده

@ -3,21 +3,21 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\BacklogItemResource;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TaskResource;
use App\Http\Resources\UserResource;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\BacklogItemResource;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use App\Models\Meeting;
use App\Models\BacklogItem;
use App\Models\User;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SearchController extends Controller
{
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
public function search(Request $request): JsonResponse
{
try {
@ -25,11 +25,25 @@ class SearchController extends Controller
$keyword = $request->q;
$limit = $request->input('limit', 5);
$projects = Project::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$tasks = Task::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$users = User::where('name', 'like', "%{$keyword}%")->orWhere('email', 'like', "%{$keyword}%")->limit($limit)->get();
$meetings = Meeting::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$backlogItems = BacklogItem::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$projects = $this->resourceAccess->projects($request->user())
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$tasks = $this->resourceAccess->tasks($request->user())
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$meetings = $this->resourceAccess->meetings($request->user())
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$backlogItems = BacklogItem::where('title', 'like', "%{$keyword}%")
->where(function ($query) use ($request) {
$query->whereNull('project_id')
->orWhereIn('project_id', $this->resourceAccess->projects($request->user())->select('projects.id'));
})->limit($limit)->get();
$canViewTeam = $request->user()->loadMissing('roles.permissions')->roles->contains('name', 'admin')
|| $request->user()->hasPermission('team.view');
$users = $canViewTeam
? User::where(function ($query) use ($keyword) {
$query->where('name', 'like', "%{$keyword}%")
->orWhere('email', 'like', "%{$keyword}%");
})->limit($limit)->get()
: collect();
return response()->json([
'success' => true,

مشاهده پرونده

@ -5,8 +5,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\SprintResource;
use App\Http\Resources\TaskResource;
use App\Models\Meeting;
use App\Models\MeetingType;
use App\Models\Sprint;
use App\Models\Task;
use App\Services\ResourceAccessService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -14,12 +18,15 @@ use Illuminate\Validation\ValidationException;
class SprintController extends Controller
{
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
private array $statuses = ['planning', 'active', 'completed', 'cancelled'];
public function index(Request $request): JsonResponse
{
try {
$query = Sprint::with(['project', 'tasks', 'members']);
$query = $this->resourceAccess->sprints($request->user())
->with(['project', 'tasks', 'members']);
if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id);
@ -81,15 +88,48 @@ class SprintController extends Controller
{
try {
$data = $this->validatedSprintData($request);
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
$memberIds = $data['member_ids'] ?? [];
$meetingSetup = $data['meeting_setup'] ?? [];
unset($data['member_ids']);
unset($data['meeting_setup']);
$data['created_by'] = $request->user()->id;
$data['status'] = 'planning';
$sprint = DB::transaction(function () use ($data, $memberIds) {
$sprint = DB::transaction(function () use ($data, $memberIds, $meetingSetup, $request) {
$sprint = Sprint::create($data);
$sprint->members()->sync($memberIds);
foreach ($meetingSetup as $setup) {
$type = MeetingType::where('key', $setup['type_key'])->where('is_active', true)->first();
if (! $type) {
continue;
}
Meeting::create([
'title' => $setup['title'] ?? $type->default_title ?? $type->name,
'project_id' => $sprint->project_id,
'sprint_id' => $sprint->id,
'meeting_type_id' => $type->id,
'meeting_type' => $type->key,
'date' => $setup['date'],
'start_time' => $setup['start_time'] ?? null,
'end_time' => $setup['end_time'] ?? null,
'location' => $setup['location'] ?? null,
'meeting_link' => $setup['meeting_link'] ?? null,
'agenda' => $type->agenda_template,
'status' => 'scheduled',
'owner_id' => $setup['owner_id'] ?? $request->user()->id,
'facilitator_id' => $setup['facilitator_id'] ?? null,
'reminder_minutes' => $setup['reminder_minutes'] ?? 30,
'recurrence_rule' => $setup['recurrence_rule'] ?? $type->recurrence_rule,
'created_by' => $request->user()->id,
]);
}
return $sprint;
});
@ -111,6 +151,13 @@ class SprintController extends Controller
{
try {
$data = $this->validatedSprintData($request, true);
if (isset($data['project_id'])) {
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
}
$memberIds = $data['member_ids'] ?? null;
unset($data['member_ids']);
@ -407,6 +454,66 @@ class SprintController extends Controller
}
}
public function workspace(Sprint $sprint): JsonResponse
{
$sprint->load([
'project', 'tasks.assignee', 'members', 'retrospective',
'meetings.type', 'meetings.participants', 'decisions', 'actionItems',
'blockers', 'scopeChanges',
]);
$tasks = $sprint->tasks;
$total = $tasks->count();
$completed = $tasks->where('status', 'done')->count();
$blocked = $tasks->where('is_blocked', true)->count() + $sprint->blockers->whereNotIn('status', ['resolved', 'closed'])->count();
$overdue = $tasks->filter(fn (Task $task) => $task->due_date?->isPast() && $task->status !== 'done')->count();
$progress = $total > 0 ? round(($completed / $total) * 100) : 0;
$today = Carbon::today();
$duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1);
$elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0);
$expected = min(100, round(($elapsed / $duration) * 100));
$health = 'on_track';
if ($sprint->end_date?->isPast() && $progress < 100) {
$health = 'off_track';
} elseif ($blocked > 1 || $overdue > 2 || $progress + 20 < $expected) {
$health = 'at_risk';
}
$upcomingMeeting = $sprint->meetings
->whereNotIn('status', ['completed', 'cancelled'])
->filter(fn (Meeting $meeting) => $meeting->date?->gte($today))
->sortBy(fn (Meeting $meeting) => $meeting->date?->format('Y-m-d').' '.$meeting->start_time)
->first();
return response()->json([
'success' => true,
'data' => [
'sprint' => new SprintResource($sprint),
'health' => [
'status' => $health,
'progress' => $progress,
'expected_progress' => $expected,
'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0),
'open_blockers' => $blocked,
'overdue_tasks' => $overdue,
],
'progress' => [
'total_tasks' => $total,
'completed_tasks' => $completed,
'in_progress_tasks' => $tasks->where('status', 'in_progress')->count(),
'blocked_tasks' => $blocked,
'overdue_tasks' => $overdue,
],
'upcoming_meeting' => $upcomingMeeting,
'meetings' => $sprint->meetings->values(),
'decisions' => $sprint->decisions,
'action_items' => $sprint->actionItems,
'blockers' => $sprint->blockers,
'scope_changes' => $sprint->scopeChanges,
],
'message' => 'فضای کاری Sprint',
]);
}
private function validatedSprintData(Request $request, bool $partial = false): array
{
$required = $partial ? 'sometimes|required' : 'required';
@ -420,6 +527,18 @@ class SprintController extends Controller
'end_date' => "{$required}|date|after_or_equal:start_date",
'member_ids' => 'nullable|array',
'member_ids.*' => 'exists:users,id',
'meeting_setup' => 'nullable|array',
'meeting_setup.*.type_key' => 'required|string|exists:meeting_types,key',
'meeting_setup.*.title' => 'nullable|string|max:255',
'meeting_setup.*.date' => 'required|date',
'meeting_setup.*.start_time' => 'nullable|date_format:H:i',
'meeting_setup.*.end_time' => 'nullable|date_format:H:i|after:meeting_setup.*.start_time',
'meeting_setup.*.location' => 'nullable|string|max:255',
'meeting_setup.*.meeting_link' => 'nullable|url',
'meeting_setup.*.owner_id' => 'nullable|exists:users,id',
'meeting_setup.*.facilitator_id' => 'nullable|exists:users,id',
'meeting_setup.*.reminder_minutes' => 'nullable|integer|min:0|max:10080',
'meeting_setup.*.recurrence_rule' => 'nullable|array',
]);
}
}

مشاهده پرونده

@ -10,6 +10,7 @@ use App\Models\Task;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use App\Services\ProjectProgressService;
use App\Services\ResourceAccessService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -20,12 +21,15 @@ class TaskController extends Controller
protected ActivityLogService $activityLogService,
protected NotificationService $notificationService,
protected ProjectProgressService $projectProgressService,
protected ResourceAccessService $resourceAccess,
) {}
public function index(Request $request): JsonResponse
{
try {
$query = Task::with(['project', 'assignee', 'reporter'])->withCount(['comments', 'files']);
$query = $this->resourceAccess->tasks($request->user())
->with(['project', 'assignee', 'reporter'])
->withCount(['comments', 'files']);
if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id);
@ -92,6 +96,11 @@ class TaskController extends Controller
{
try {
$data = $request->validated();
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
$data['reporter_id'] = $request->user()->id;
$data['created_by'] = $request->user()->id;
$task = Task::create($data);
@ -142,8 +151,16 @@ class TaskController extends Controller
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
{
try {
$validated = $request->validated();
if (isset($validated['project_id'])) {
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($validated['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
}
$oldStatus = $task->status;
$task->update($request->validated());
$task->update($validated);
if ($task->wasChanged('status')) {
$this->activityLogService->log(

مشاهده پرونده

@ -6,8 +6,8 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Models\Task;
use App\Models\User;
use App\Services\WorkloadService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

مشاهده پرونده

@ -0,0 +1,71 @@
<?php
namespace App\Http\Middleware;
use App\Models\BacklogItem;
use App\Models\Checklist;
use App\Models\Comment;
use App\Models\File;
use App\Models\Meeting;
use App\Models\MeetingActionItem;
use App\Models\Notification;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Subtask;
use App\Models\Task;
use App\Services\ResourceAccessService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureResourceAccess
{
public function __construct(private readonly ResourceAccessService $access) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return response()->json(['success' => false, 'message' => 'Unauthenticated.'], 401);
}
$route = $request->route();
$checks = [
'project' => fn ($model) => $model instanceof Project && $this->access->canAccessProject($user, $model),
'task' => fn ($model) => $model instanceof Task && $this->access->canAccessTask($user, $model),
'sprint' => fn ($model) => $model instanceof Sprint && $this->access->canAccessSprint($user, $model),
'meeting' => fn ($model) => $model instanceof Meeting && $this->access->canAccessMeeting($user, $model),
'comment' => fn ($model) => $model instanceof Comment && $this->access->canAccessComment($user, $model),
'file' => fn ($model) => $model instanceof File && $this->access->canAccessFile($user, $model),
'backlogItem' => fn ($model) => $model instanceof BacklogItem
&& ($this->access->canSeeAll($user)
|| $model->created_by === $user->id
|| ($model->project_id !== null
&& $this->access->projects($user)->whereKey($model->project_id)->exists())),
'checklist' => fn ($model) => $model instanceof Checklist && $this->access->tasks($user)->whereKey($model->task_id)->exists(),
'subtask' => fn ($model) => $model instanceof Subtask && $this->access->tasks($user)->whereKey($model->task_id)->exists(),
'notification' => fn ($model) => $model instanceof Notification && $model->user_id === $user->id,
];
foreach ($checks as $parameter => $allowed) {
$model = $route?->parameter($parameter);
if ($model !== null && ! $allowed($model)) {
abort(403, 'شما به این منبع دسترسی ندارید.');
}
}
$actionItem = $route?->parameter('actionItem');
$meeting = $route?->parameter('meeting');
if ($actionItem instanceof MeetingActionItem) {
abort_unless(
$meeting instanceof Meeting
&& $actionItem->meeting_id === $meeting->id
&& $this->access->canAccessMeeting($user, $meeting),
403,
'این اقدام متعلق به جلسه انتخاب‌شده نیست.'
);
}
return $next($request);
}
}

مشاهده پرونده

@ -15,7 +15,7 @@ class ChangePasswordRequest extends FormRequest
{
return [
'current_password' => 'required',
'new_password' => 'required|min:8|confirmed',
'new_password' => 'required|string|min:12|confirmed',
];
}
}

مشاهده پرونده

@ -16,10 +16,23 @@ class StoreMeetingRequest extends FormRequest
return [
'title' => 'required|string|max:255',
'project_id' => 'nullable|exists:projects,id',
'sprint_id' => 'nullable|exists:sprints,id',
'meeting_type_id' => 'nullable|exists:meeting_types,id',
'date' => 'required|date',
'start_time' => 'nullable',
'end_time' => 'nullable',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i|after:start_time',
'meeting_type' => 'required|string|max:50',
'status' => 'nullable|string|in:draft,scheduled,in_progress,completed,cancelled,postponed',
'objective' => 'nullable|string|max:2000',
'agenda' => 'nullable|string',
'notes' => 'nullable|string',
'summary' => 'nullable|string',
'location' => 'nullable|string|max:255',
'meeting_link' => 'nullable|url|max:2048',
'owner_id' => 'nullable|exists:users,id',
'facilitator_id' => 'nullable|exists:users,id',
'reminder_minutes' => 'nullable|integer|min:0|max:10080',
'recurrence_rule' => 'nullable|array',
];
}
}

مشاهده پرونده

@ -14,6 +14,7 @@ class UpdateRoleRequest extends FormRequest
public function rules(): array
{
$roleId = $this->route('role');
return [
'name' => 'sometimes|required|string|max:255|unique:roles,name,'.$roleId,
'display_name' => 'sometimes|required|string|max:255',

مشاهده پرونده

@ -30,6 +30,7 @@ class UpdateUserRequest extends FormRequest
{
$userId = $this->route('user');
$userId = is_object($userId) ? $userId->id : $userId;
return [
'name' => 'sometimes|required|string|max:255',
'email' => 'sometimes|required|email|unique:users,email,'.$userId,

مشاهده پرونده

@ -14,19 +14,38 @@ class MeetingResource extends JsonResource
'title' => $this->title,
'project_id' => $this->project_id,
'project' => new ProjectResource($this->whenLoaded('project')),
'sprint_id' => $this->sprint_id,
'sprint' => new SprintResource($this->whenLoaded('sprint')),
'meeting_type_id' => $this->meeting_type_id,
'type' => $this->whenLoaded('type'),
'date' => $this->date?->format('Y-m-d'),
'start_time' => $this->start_time,
'end_time' => $this->end_time,
'location' => $this->location,
'meeting_link' => $this->meeting_link,
'meeting_type' => $this->meeting_type,
'status' => $this->status ?? 'scheduled',
'objective' => $this->objective,
'agenda' => $this->agenda,
'notes' => $this->notes,
'summary' => $this->summary,
'decisions' => $this->decisions,
'structured_decisions' => $this->whenLoaded('structuredDecisions'),
'structured_action_items' => $this->whenLoaded('structuredActionItems'),
'blockers' => $this->whenLoaded('blockers'),
'files' => FileResource::collection($this->whenLoaded('files')),
'participants' => UserResource::collection($this->whenLoaded('participants')),
'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')),
'created_by' => $this->created_by,
'creator' => new UserResource($this->whenLoaded('creator')),
'owner_id' => $this->owner_id,
'owner' => new UserResource($this->whenLoaded('owner')),
'facilitator_id' => $this->facilitator_id,
'facilitator' => new UserResource($this->whenLoaded('facilitator')),
'reminder_minutes' => $this->reminder_minutes,
'recurrence_rule' => $this->recurrence_rule,
'started_at' => $this->started_at,
'completed_at' => $this->completed_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];

مشاهده پرونده

@ -9,6 +9,20 @@ class NotificationResource extends JsonResource
{
public function toArray(Request $request): array
{
$entity = class_basename((string) $this->notifiable_type);
$targetUrl = match ($entity) {
'Meeting' => "/meetings/{$this->notifiable_id}/workspace",
'Sprint' => "/sprints/{$this->notifiable_id}/workspace",
'Task' => "/tasks?preview={$this->notifiable_id}",
'Project' => "/projects/{$this->notifiable_id}",
'ActionItem' => '/meetings',
default => null,
};
$dataUrl = is_array($this->data) ? ($this->data['url'] ?? null) : null;
if (! $targetUrl && is_string($dataUrl) && str_starts_with($dataUrl, '/')) {
$targetUrl = $dataUrl;
}
return [
'id' => $this->id,
'type' => $this->type,
@ -20,6 +34,16 @@ class NotificationResource extends JsonResource
'notifiable_id' => $this->notifiable_id,
'is_read' => $this->is_read,
'read_at' => $this->read_at,
'archived_at' => $this->archived_at,
'entity_type' => $entity ? strtolower($entity) : null,
'entity_id' => $this->notifiable_id,
'target_url' => $targetUrl,
'can_navigate' => (bool) $targetUrl,
'preview' => [
'title' => $this->title ?: 'اعلان',
'body' => $this->body ?: ($this->data['message'] ?? null),
'type' => $this->type,
],
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];

مشاهده پرونده

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ActionItem extends Model
{
use SoftDeletes;
protected $guarded = [];
protected $casts = ['due_date' => 'date'];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function sprint(): BelongsTo
{
return $this->belongsTo(Sprint::class);
}
public function meeting(): BelongsTo
{
return $this->belongsTo(Meeting::class);
}
}

مشاهده پرونده

@ -9,7 +9,7 @@ class ActivityLog extends Model
{
protected $fillable = [
'user_id', 'action', 'description', 'subject_type', 'subject_id',
'project_id', 'task_id', 'properties'
'project_id', 'task_id', 'properties',
];
protected $casts = [

مشاهده پرونده

@ -9,7 +9,7 @@ class BacklogItem extends Model
{
protected $fillable = [
'title', 'description', 'type', 'project_id', 'priority',
'estimated_effort', 'status', 'assigned_sprint_id', 'created_by'
'estimated_effort', 'status', 'assigned_sprint_id', 'created_by',
];
public function project(): BelongsTo

18
backend/app/Models/Blocker.php normal فایل
مشاهده پرونده

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Blocker extends Model
{
use SoftDeletes;
protected $guarded = [];
protected $casts = [
'due_date' => 'date',
'resolved_at' => 'datetime',
];
}

18
backend/app/Models/Decision.php normal فایل
مشاهده پرونده

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Decision extends Model
{
use SoftDeletes;
protected $guarded = [];
protected $casts = [
'effective_date' => 'date',
'review_date' => 'date',
];
}

مشاهده پرونده

@ -11,12 +11,17 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
class Meeting extends Model
{
protected $fillable = [
'title', 'project_id', 'date', 'start_time', 'end_time', 'location',
'meeting_link', 'meeting_type', 'agenda', 'notes', 'decisions', 'created_by'
'title', 'project_id', 'sprint_id', 'meeting_type_id', 'date', 'start_time',
'end_time', 'location', 'meeting_link', 'meeting_type', 'status', 'objective',
'agenda', 'notes', 'summary', 'decisions', 'reminder_minutes', 'recurrence_rule',
'started_at', 'completed_at', 'created_by', 'owner_id', 'facilitator_id',
];
protected $casts = [
'date' => 'date',
'recurrence_rule' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
public function project(): BelongsTo
@ -29,6 +34,26 @@ class Meeting extends Model
return $this->belongsTo(User::class, 'created_by');
}
public function sprint(): BelongsTo
{
return $this->belongsTo(Sprint::class);
}
public function type(): BelongsTo
{
return $this->belongsTo(MeetingType::class, 'meeting_type_id');
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function facilitator(): BelongsTo
{
return $this->belongsTo(User::class, 'facilitator_id');
}
public function participants(): BelongsToMany
{
return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps();
@ -39,6 +64,21 @@ class Meeting extends Model
return $this->hasMany(MeetingActionItem::class);
}
public function structuredActionItems(): HasMany
{
return $this->hasMany(ActionItem::class);
}
public function structuredDecisions(): HasMany
{
return $this->hasMany(Decision::class);
}
public function blockers(): HasMany
{
return $this->hasMany(Blocker::class);
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');

مشاهده پرونده

@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MeetingActionItem extends Model
{
protected $fillable = [
'meeting_id', 'title', 'assigned_to', 'due_date', 'is_completed', 'converted_to_task_id'
'meeting_id', 'title', 'assigned_to', 'due_date', 'is_completed', 'converted_to_task_id',
];
protected $casts = [

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MeetingType extends Model
{
protected $fillable = [
'key', 'name', 'default_title', 'suggested_duration', 'agenda_template',
'minutes_template', 'participant_roles', 'recurrence_rule', 'settings', 'is_active',
];
protected $casts = [
'participant_roles' => 'array',
'recurrence_rule' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
];
public function meetings(): HasMany
{
return $this->hasMany(Meeting::class);
}
}

مشاهده پرونده

@ -3,12 +3,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Notification extends Model
{
use SoftDeletes;
protected $fillable = [
'user_id', 'title', 'body', 'type', 'notifiable_type', 'notifiable_id',
'data', 'is_read', 'read_at', 'remind_at', 'dismissed_at'
'data', 'is_read', 'read_at', 'remind_at', 'dismissed_at', 'archived_at',
];
protected $casts = [
@ -17,5 +20,6 @@ class Notification extends Model
'read_at' => 'datetime',
'remind_at' => 'datetime',
'dismissed_at' => 'datetime',
'archived_at' => 'datetime',
];
}

مشاهده پرونده

@ -12,7 +12,7 @@ class Project extends Model
protected $fillable = [
'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date',
'priority', 'status', 'progress', 'risk_level', 'budget', 'estimated_hours',
'actual_hours', 'tags', 'notes', 'is_archived', 'created_by'
'actual_hours', 'tags', 'notes', 'is_archived', 'created_by',
];
protected $casts = [

مشاهده پرونده

@ -2,8 +2,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{

مشاهده پرونده

@ -52,4 +52,29 @@ class Sprint extends Model
{
return $this->hasMany(BacklogItem::class, 'assigned_sprint_id');
}
public function meetings(): HasMany
{
return $this->hasMany(Meeting::class);
}
public function decisions(): HasMany
{
return $this->hasMany(Decision::class);
}
public function actionItems(): HasMany
{
return $this->hasMany(ActionItem::class);
}
public function blockers(): HasMany
{
return $this->hasMany(Blocker::class);
}
public function scopeChanges(): HasMany
{
return $this->hasMany(SprintScopeChange::class);
}
}

مشاهده پرونده

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SprintScopeChange extends Model
{
protected $guarded = [];
protected $casts = [
'previous_state' => 'array',
'new_state' => 'array',
'capacity_impact' => 'decimal:2',
];
}

مشاهده پرونده

@ -12,7 +12,7 @@ class Task extends Model
protected $fillable = [
'title', 'description', 'project_id', 'assignee_id', 'reporter_id',
'priority', 'status', 'blocker_type', 'blocker_note', 'start_date', 'due_date', 'estimated_time',
'actual_time', 'tags', 'sort_order', 'created_by'
'actual_time', 'tags', 'sort_order', 'created_by',
];
protected $casts = [

مشاهده پرونده

@ -63,6 +63,7 @@ class User extends Authenticatable
return true;
}
}
return false;
}
@ -73,6 +74,7 @@ class User extends Authenticatable
return true;
}
}
return false;
}

مشاهده پرونده

@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\ActionItem;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class CalendarService
{
public function __construct(private readonly ResourceAccessService $access) {}
public function events(User $user, Carbon $from, Carbon $to, array $types = []): Collection
{
$enabled = fn (string $type): bool => $types === [] || in_array($type, $types, true);
$events = collect();
if ($enabled('meeting')) {
$this->access->meetings($user)->with(['project:id,title', 'sprint:id,title'])
->whereBetween('date', [$from->toDateString(), $to->toDateString()])
->orderBy('date')
->orderBy('start_time')
->get()
->each(function (Meeting $meeting) use ($events) {
$start = $meeting->date->format('Y-m-d').'T'.($meeting->start_time ?: '00:00:00');
$end = $meeting->end_time ? $meeting->date->format('Y-m-d').'T'.$meeting->end_time : null;
$events->push([
'id' => "meeting-{$meeting->id}",
'event_type' => 'meeting',
'entity_type' => 'meeting',
'entity_id' => $meeting->id,
'title' => $meeting->title,
'start_at' => $start,
'end_at' => $end,
'all_day' => ! $meeting->start_time,
'status' => $meeting->status ?? 'scheduled',
'color_token' => 'info',
'project' => $meeting->project?->only(['id', 'title']),
'sprint' => $meeting->sprint?->only(['id', 'title']),
'preview' => [
'زمان' => $meeting->start_time ? substr($meeting->start_time, 0, 5) : 'تمام روز',
'مکان' => $meeting->location,
'هدف' => $meeting->objective,
],
'target_url' => "/meetings/{$meeting->id}/workspace",
]);
});
}
if ($enabled('task')) {
$this->access->tasks($user)->with('project:id,title')
->whereNotNull('due_date')
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])
->where('status', '!=', 'done')
->orderBy('due_date')
->get()
->each(fn (Task $task) => $events->push([
'id' => "task-{$task->id}",
'event_type' => 'task',
'entity_type' => 'task',
'entity_id' => $task->id,
'title' => $task->title,
'start_at' => $task->due_date?->format('Y-m-d'),
'end_at' => null,
'all_day' => true,
'status' => $task->status,
'color_token' => $task->priority === 'urgent' ? 'danger' : 'warning',
'project' => $task->project?->only(['id', 'title']),
'sprint' => null,
'preview' => [
'وضعیت' => $task->status,
'اولویت' => $task->priority,
],
'target_url' => "/tasks?preview={$task->id}",
]));
}
if ($enabled('sprint')) {
$this->access->sprints($user)->with('project:id,title')
->where(function ($query) use ($from, $to) {
$query->whereBetween('start_date', [$from->toDateString(), $to->toDateString()])
->orWhereBetween('end_date', [$from->toDateString(), $to->toDateString()]);
})
->get()
->each(function (Sprint $sprint) use ($events, $from, $to) {
foreach ([['start_date', 'شروع'], ['end_date', 'پایان']] as [$field, $label]) {
$date = $sprint->{$field};
if (! $date || $date->lt($from) || $date->gt($to)) {
continue;
}
$events->push([
'id' => "sprint-{$field}-{$sprint->id}",
'event_type' => 'sprint',
'entity_type' => 'sprint',
'entity_id' => $sprint->id,
'title' => "{$label} {$sprint->title}",
'start_at' => $date->format('Y-m-d'),
'end_at' => null,
'all_day' => true,
'status' => $sprint->status,
'color_token' => $field === 'start_date' ? 'success' : 'primary',
'project' => $sprint->project?->only(['id', 'title']),
'sprint' => $sprint->only(['id', 'title']),
'preview' => [
'هدف' => $sprint->goal,
'ظرفیت' => $sprint->capacity_hours,
],
'target_url' => "/sprints/{$sprint->id}/workspace",
]);
}
});
}
if ($enabled('project')) {
$this->access->projects($user)->where(function ($query) use ($from, $to) {
$query->whereBetween('start_date', [$from->toDateString(), $to->toDateString()])
->orWhereBetween('end_date', [$from->toDateString(), $to->toDateString()]);
})->get()->each(function (Project $project) use ($events, $from, $to) {
foreach ([['start_date', 'شروع'], ['end_date', 'پایان']] as [$field, $label]) {
$date = $project->{$field};
if (! $date || Carbon::parse($date)->lt($from) || Carbon::parse($date)->gt($to)) {
continue;
}
$events->push([
'id' => "project-{$field}-{$project->id}",
'event_type' => 'project',
'entity_type' => 'project',
'entity_id' => $project->id,
'title' => "{$label} {$project->title}",
'start_at' => Carbon::parse($date)->format('Y-m-d'),
'end_at' => null,
'all_day' => true,
'status' => $project->status,
'color_token' => 'secondary',
'project' => $project->only(['id', 'title']),
'sprint' => null,
'preview' => ['پیشرفت' => "{$project->progress}%"],
'target_url' => "/projects/{$project->id}",
]);
}
});
}
if ($enabled('action_item') && class_exists(ActionItem::class)) {
ActionItem::with(['project:id,title', 'sprint:id,title'])
->when(! $this->access->canSeeAll($user), function ($query) use ($user) {
$query->where(function ($scope) use ($user) {
$scope->where('owner_id', $user->id)
->orWhere('created_by', $user->id)
->orWhereIn('project_id', $this->access->projects($user)->select('projects.id'));
});
})
->whereNotNull('due_date')
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])
->whereNotIn('status', ['completed', 'cancelled'])
->get()
->each(fn (ActionItem $item) => $events->push([
'id' => "action-item-{$item->id}",
'event_type' => 'action_item',
'entity_type' => 'action_item',
'entity_id' => $item->id,
'title' => $item->title,
'start_at' => $item->due_date?->format('Y-m-d'),
'end_at' => null,
'all_day' => true,
'status' => $item->status,
'color_token' => $item->due_date?->isPast() ? 'danger' : 'warning',
'project' => $item->project?->only(['id', 'title']),
'sprint' => $item->sprint?->only(['id', 'title']),
'preview' => ['اولویت' => $item->priority, 'وضعیت' => $item->status],
'target_url' => $item->sprint_id ? "/sprints/{$item->sprint_id}/workspace?tab=action-items" : '/meetings',
]));
}
return $events->sortBy('start_at')->values();
}
}

مشاهده پرونده

@ -2,11 +2,14 @@
namespace App\Services;
use App\Models\Project;
use App\Models\Task;
use App\Models\Meeting;
use App\Models\ActionItem;
use App\Models\ActivityLog;
use App\Models\Blocker;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Carbon\Carbon;
class DashboardService
@ -94,7 +97,7 @@ class DashboardService
}
$todayMeetings = Meeting::whereDate('date', Carbon::today())->count();
$teamMembers = \App\Models\User::count();
$teamMembers = User::count();
$projectProgresses = Project::where('is_archived', false)
->select(['id', 'title', 'progress', 'status'])
@ -159,4 +162,161 @@ class DashboardService
'monthly_tasks' => $monthlyTasks,
];
}
public function getMonitoring($user = null): array
{
$today = Carbon::today();
$weekEnd = $today->copy()->addDays(7);
$projects = Project::query()
->where('is_archived', false)
->withCount([
'tasks as total_tasks_count',
'tasks as completed_tasks_count' => fn ($query) => $query->where('status', 'done'),
'tasks as overdue_tasks_count' => fn ($query) => $query
->whereDate('due_date', '<', $today)
->whereNotIn('status', ['done', 'canceled']),
'tasks as blocked_tasks_count' => fn ($query) => $query->where('is_blocked', true),
])
->orderByRaw("CASE WHEN risk_level = 'critical' THEN 0 WHEN risk_level = 'high' THEN 1 ELSE 2 END")
->limit(8)
->get()
->map(function (Project $project) {
$health = 'on_track';
if ($project->risk_level === 'critical' || $project->overdue_tasks_count >= 5) {
$health = 'off_track';
} elseif ($project->risk_level === 'high' || $project->overdue_tasks_count > 0 || $project->blocked_tasks_count > 0) {
$health = 'at_risk';
}
return [
'id' => $project->id,
'title' => $project->title,
'status' => $project->status,
'risk_level' => $project->risk_level,
'progress' => $project->progress,
'health' => $health,
'total_tasks' => $project->total_tasks_count,
'completed_tasks' => $project->completed_tasks_count,
'overdue_tasks' => $project->overdue_tasks_count,
'blocked_tasks' => $project->blocked_tasks_count,
];
});
$sprints = Sprint::with(['project:id,title'])
->withCount([
'tasks as total_tasks_count',
'tasks as completed_tasks_count' => fn ($query) => $query->where('status', 'done'),
'tasks as blocked_tasks_count' => fn ($query) => $query->where('is_blocked', true),
])
->where('status', 'active')
->orderBy('end_date')
->limit(6)
->get()
->map(function (Sprint $sprint) use ($today) {
$progress = $sprint->total_tasks_count > 0
? round(($sprint->completed_tasks_count / $sprint->total_tasks_count) * 100)
: 0;
$elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0);
$duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1);
$expected = min(100, round(($elapsed / $duration) * 100));
$health = $sprint->blocked_tasks_count > 1 || $progress + 20 < $expected ? 'at_risk' : 'on_track';
if ($sprint->end_date?->isPast() && $progress < 100) {
$health = 'off_track';
}
return [
'id' => $sprint->id,
'title' => $sprint->title,
'project' => $sprint->project?->title,
'progress' => $progress,
'expected_progress' => $expected,
'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0),
'health' => $health,
'blocked_tasks' => $sprint->blocked_tasks_count,
];
});
$workload = User::query()
->where('status', 'active')
->withCount([
'tasks as open_tasks_count' => fn ($query) => $query->whereNotIn('status', ['done', 'canceled']),
'tasks as overdue_tasks_count' => fn ($query) => $query
->whereDate('due_date', '<', $today)
->whereNotIn('status', ['done', 'canceled']),
])
->orderByDesc('open_tasks_count')
->limit(8)
->get(['id', 'name'])
->map(fn (User $member) => [
'id' => $member->id,
'name' => $member->name,
'open_tasks' => $member->open_tasks_count,
'overdue_tasks' => $member->overdue_tasks_count,
'load' => $member->open_tasks_count >= 10 ? 'overloaded' : ($member->open_tasks_count >= 6 ? 'high' : 'balanced'),
]);
$attention = collect();
Task::with('project:id,title')
->whereNotIn('status', ['done', 'canceled'])
->where(function ($query) use ($today, $weekEnd) {
$query->where('is_blocked', true)
->orWhereDate('due_date', '<', $today)
->orWhereBetween('due_date', [$today, $weekEnd]);
})
->orderBy('due_date')
->limit(10)
->get()
->each(fn (Task $task) => $attention->push([
'id' => "task-{$task->id}",
'type' => $task->is_blocked ? 'blocker' : ($task->due_date?->isPast() ? 'overdue' : 'deadline'),
'title' => $task->title,
'context' => $task->project?->title,
'date' => $task->due_date?->format('Y-m-d'),
'target_url' => "/tasks?preview={$task->id}",
]));
$upcomingMeetings = Meeting::with(['project:id,title', 'sprint:id,title'])
->where(function ($query) use ($today) {
$query->whereDate('date', '>', $today)
->orWhere(function ($sameDay) use ($today) {
$sameDay->whereDate('date', $today)->whereTime('start_time', '>=', now()->format('H:i:s'));
});
})
->whereNotIn('status', ['cancelled', 'completed'])
->orderBy('date')
->orderBy('start_time')
->limit(5)
->get()
->map(fn (Meeting $meeting) => [
'id' => $meeting->id,
'title' => $meeting->title,
'date' => $meeting->date?->format('Y-m-d'),
'time' => $meeting->start_time ? substr($meeting->start_time, 0, 5) : null,
'project' => $meeting->project?->title,
'sprint' => $meeting->sprint?->title,
'target_url' => "/meetings/{$meeting->id}/workspace",
]);
return [
'generated_at' => now()->toIso8601String(),
'summary' => [
'active_projects' => Project::where('is_archived', false)->where('status', '!=', 'done')->count(),
'projects_at_risk' => $projects->whereIn('health', ['at_risk', 'off_track'])->count(),
'open_tasks' => Task::whereNotIn('status', ['done', 'canceled'])->count(),
'overdue_tasks' => Task::whereDate('due_date', '<', $today)->whereNotIn('status', ['done', 'canceled'])->count(),
'blocked_tasks' => Task::where('is_blocked', true)->whereNotIn('status', ['done', 'canceled'])->count(),
'active_sprints' => Sprint::where('status', 'active')->count(),
'meetings_today' => Meeting::whereDate('date', $today)->where('status', '!=', 'cancelled')->count(),
'overdue_action_items' => ActionItem::whereDate('due_date', '<', $today)->whereNotIn('status', ['completed', 'cancelled'])->count(),
'open_blockers' => Blocker::whereNotIn('status', ['resolved', 'closed'])->count(),
],
'projects' => $projects,
'sprints' => $sprints,
'workload' => $workload,
'attention' => $attention->values(),
'upcoming_meetings' => $upcomingMeetings,
'trends' => $this->getChartData()['monthlyTasks'],
];
}
}

مشاهده پرونده

@ -14,6 +14,7 @@ class ProjectProgressService
if ($totalTasks === 0) {
$project->update(['progress' => 0]);
return 0;
}

مشاهده پرونده

@ -2,10 +2,10 @@
namespace App\Services;
use App\Models\Project;
use App\Models\Task;
use App\Models\Sprint;
use App\Models\ActivityLog;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Carbon\Carbon;
@ -19,6 +19,7 @@ class ReportService
if (! empty($filters['date_to'])) {
$query->where($dateField, '<=', $filters['date_to']);
}
return $query;
}
@ -98,6 +99,7 @@ class ReportService
$completedTasks = $user->tasks->where('status', 'done')->count();
$delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count();
$inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count();
return [
'name' => $user->name,
'completed' => $completedTasks,
@ -152,6 +154,7 @@ class ReportService
return $sprints->map(function ($sprint) {
$totalTasks = $sprint->tasks->count();
$completedTasks = $sprint->tasks->where('status', 'done')->count();
return [
'id' => $sprint->id,
'title' => $sprint->title,
@ -185,6 +188,7 @@ class ReportService
return $tasks->map(function ($task) {
$diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0);
return [
'id' => $task->id,
'title' => $task->title,

مشاهده پرونده

@ -0,0 +1,163 @@
<?php
namespace App\Services;
use App\Models\Comment;
use App\Models\File;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class ResourceAccessService
{
public function canSeeAll(User $user): bool
{
$user->loadMissing('roles.permissions');
return $user->roles->contains('name', 'admin');
}
public function projects(User $user): Builder
{
$query = Project::query();
if ($this->canSeeAll($user)) {
return $query;
}
return $query->where(function (Builder $scope) use ($user) {
$scope->where('project_manager_id', $user->id)
->orWhere('created_by', $user->id)
->orWhereHas('members', fn (Builder $members) => $members->where('users.id', $user->id));
});
}
public function tasks(User $user): Builder
{
$query = Task::query();
if ($this->canSeeAll($user)) {
return $query;
}
return $query->where(function (Builder $scope) use ($user) {
$scope->where('assignee_id', $user->id)
->orWhere('reporter_id', $user->id)
->orWhere('created_by', $user->id)
->orWhereIn('project_id', $this->projects($user)->select('projects.id'));
});
}
public function sprints(User $user): Builder
{
$query = Sprint::query();
if ($this->canSeeAll($user)) {
return $query;
}
return $query->where(function (Builder $scope) use ($user) {
$scope->where('created_by', $user->id)
->orWhereHas('members', fn (Builder $members) => $members->where('users.id', $user->id))
->orWhereIn('project_id', $this->projects($user)->select('projects.id'));
});
}
public function meetings(User $user): Builder
{
$query = Meeting::query();
if ($this->canSeeAll($user)) {
return $query;
}
return $query->where(function (Builder $scope) use ($user) {
$scope->where('created_by', $user->id)
->orWhere('owner_id', $user->id)
->orWhere('facilitator_id', $user->id)
->orWhereHas('participants', fn (Builder $participants) => $participants->where('users.id', $user->id))
->orWhereIn('project_id', $this->projects($user)->select('projects.id'));
});
}
public function comments(User $user): Builder
{
$query = Comment::query();
if ($this->canSeeAll($user)) {
return $query;
}
return $query->where(function (Builder $scope) use ($user) {
$scope->where('user_id', $user->id)
->orWhere(function (Builder $tasks) use ($user) {
$tasks->where('commentable_type', Task::class)
->whereIn('commentable_id', $this->tasks($user)->select('tasks.id'));
})
->orWhere(function (Builder $projects) use ($user) {
$projects->where('commentable_type', Project::class)
->whereIn('commentable_id', $this->projects($user)->select('projects.id'));
})
->orWhere(function (Builder $meetings) use ($user) {
$meetings->where('commentable_type', Meeting::class)
->whereIn('commentable_id', $this->meetings($user)->select('meetings.id'));
});
});
}
public function files(User $user): Builder
{
$query = File::query();
if ($this->canSeeAll($user)) {
return $query;
}
return $query->where(function (Builder $scope) use ($user) {
$scope->where('user_id', $user->id)
->orWhere(function (Builder $projects) use ($user) {
$projects->where('fileable_type', Project::class)
->whereIn('fileable_id', $this->projects($user)->select('projects.id'));
})
->orWhere(function (Builder $tasks) use ($user) {
$tasks->where('fileable_type', Task::class)
->whereIn('fileable_id', $this->tasks($user)->select('tasks.id'));
})
->orWhere(function (Builder $meetings) use ($user) {
$meetings->where('fileable_type', Meeting::class)
->whereIn('fileable_id', $this->meetings($user)->select('meetings.id'));
})
->orWhere(function (Builder $comments) use ($user) {
$comments->where('fileable_type', Comment::class)
->whereIn('fileable_id', $this->comments($user)->select('comments.id'));
});
});
}
public function canAccessProject(User $user, Project $project): bool
{
return $this->projects($user)->whereKey($project->getKey())->exists();
}
public function canAccessTask(User $user, Task $task): bool
{
return $this->tasks($user)->whereKey($task->getKey())->exists();
}
public function canAccessSprint(User $user, Sprint $sprint): bool
{
return $this->sprints($user)->whereKey($sprint->getKey())->exists();
}
public function canAccessMeeting(User $user, Meeting $meeting): bool
{
return $this->meetings($user)->whereKey($meeting->getKey())->exists();
}
public function canAccessComment(User $user, Comment $comment): bool
{
return $this->comments($user)->whereKey($comment->getKey())->exists();
}
public function canAccessFile(User $user, File $file): bool
{
return $this->files($user)->whereKey($file->getKey())->exists();
}
}

مشاهده پرونده

@ -2,8 +2,8 @@
namespace App\Services;
use App\Models\User;
use App\Models\Task;
use App\Models\User;
use Carbon\Carbon;
class WorkloadService

مشاهده پرونده

@ -1,8 +1,9 @@
<?php
use Illuminate\Auth\AuthenticationException;
use App\Http\Middleware\EnsureResourceAccess;
use App\Http\Middleware\EnsureUserHasPermission;
use App\Http\Middleware\SecurityHeaders;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -19,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->append(SecurityHeaders::class);
$middleware->alias([
'permission' => EnsureUserHasPermission::class,
'resource.access' => EnsureResourceAccess::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

مشاهده پرونده

@ -64,6 +64,28 @@ return [
]) : [],
],
'migration_sqlite' => [
'driver' => 'sqlite',
'database' => env('MIGRATION_SQLITE_PATH', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => true,
],
'migration_mysql' => [
'driver' => 'mysql',
'host' => env('MIGRATION_MYSQL_HOST', '127.0.0.1'),
'port' => env('MIGRATION_MYSQL_PORT', '3306'),
'database' => env('MIGRATION_MYSQL_DATABASE', 'pm_migration'),
'username' => env('MIGRATION_MYSQL_USERNAME', 'root'),
'password' => env('MIGRATION_MYSQL_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => 'InnoDB',
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),

مشاهده پرونده

@ -15,7 +15,7 @@ return [
'guard' => ['web'],
'expiration' => null,
'expiration' => (int) env('SANCTUM_TOKEN_EXPIRATION', 480),
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),

مشاهده پرونده

@ -0,0 +1,186 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('meeting_types', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('name');
$table->string('default_title')->nullable();
$table->unsignedSmallInteger('suggested_duration')->nullable();
$table->text('agenda_template')->nullable();
$table->text('minutes_template')->nullable();
$table->json('participant_roles')->nullable();
$table->json('recurrence_rule')->nullable();
$table->json('settings')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
$now = now();
DB::table('meeting_types')->insert([
['key' => 'sprint_planning', 'name' => 'برنامه‌ریزی اسپرینت', 'default_title' => 'جلسه برنامه‌ریزی اسپرینت', 'suggested_duration' => 90, 'agenda_template' => "هدف اسپرینت\nظرفیت تیم\nانتخاب اقلام بک‌لاگ\nریسک‌ها و وابستگی‌ها", 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'daily_standup', 'name' => 'جلسه روزانه', 'default_title' => 'Daily Stand-up', 'suggested_duration' => 15, 'agenda_template' => "کارهای انجام‌شده\nبرنامه امروز\nموانع و نیاز به کمک", 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => json_encode(['frequency' => 'weekdays', 'until' => 'sprint_end']), 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'backlog_refinement', 'name' => 'پالایش بک‌لاگ', 'default_title' => 'پالایش بک‌لاگ', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'sprint_review', 'name' => 'بازبینی اسپرینت', 'default_title' => 'Sprint Review', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'sprint_retrospective', 'name' => 'بازنگری اسپرینت', 'default_title' => 'Sprint Retrospective', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'adhoc', 'name' => 'جلسه موردی اسپرینت', 'default_title' => 'جلسه موردی', 'suggested_duration' => 30, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'blocker_resolution', 'name' => 'رفع مانع', 'default_title' => 'جلسه رفع مانع', 'suggested_duration' => 30, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['key' => 'scope_change', 'name' => 'تغییر محدوده', 'default_title' => 'بررسی تغییر محدوده', 'suggested_duration' => 45, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
]);
Schema::table('meetings', function (Blueprint $table) {
$table->foreignId('sprint_id')->nullable()->after('project_id')->constrained()->nullOnDelete();
$table->foreignId('meeting_type_id')->nullable()->after('sprint_id')->constrained()->nullOnDelete();
$table->foreignId('owner_id')->nullable()->after('created_by')->constrained('users')->nullOnDelete();
$table->foreignId('facilitator_id')->nullable()->after('owner_id')->constrained('users')->nullOnDelete();
$table->string('status')->default('scheduled')->after('meeting_type');
$table->text('objective')->nullable()->after('status');
$table->text('summary')->nullable()->after('notes');
$table->unsignedSmallInteger('reminder_minutes')->nullable()->after('summary');
$table->json('recurrence_rule')->nullable()->after('reminder_minutes');
$table->timestamp('started_at')->nullable()->after('recurrence_rule');
$table->timestamp('completed_at')->nullable()->after('started_at');
$table->index(['sprint_id', 'date']);
$table->index(['status', 'date']);
});
Schema::create('meeting_relations', function (Blueprint $table) {
$table->id();
$table->foreignId('meeting_id')->constrained()->cascadeOnDelete();
$table->string('related_type');
$table->unsignedBigInteger('related_id');
$table->string('relation_type')->default('related');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['meeting_id', 'related_type', 'related_id', 'relation_type'], 'meeting_relation_unique');
$table->index(['related_type', 'related_id']);
});
Schema::create('decisions', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('proposed');
$table->string('decision_type')->nullable();
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('decision_maker_id')->nullable()->constrained('users')->nullOnDelete();
$table->text('rationale')->nullable();
$table->text('impact')->nullable();
$table->date('effective_date')->nullable();
$table->date('review_date')->nullable();
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->softDeletes();
});
Schema::create('action_items', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('open');
$table->string('priority')->default('medium');
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('decision_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('converted_to_task_id')->nullable()->constrained('tasks')->nullOnDelete();
$table->date('due_date')->nullable();
$table->text('completion_notes')->nullable();
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'due_date']);
});
Schema::create('blockers', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->string('severity')->default('medium');
$table->string('status')->default('open');
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('raised_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('task_id')->nullable()->constrained()->nullOnDelete();
$table->date('due_date')->nullable();
$table->text('resolution')->nullable();
$table->timestamp('resolved_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'severity']);
});
Schema::create('sprint_scope_changes', function (Blueprint $table) {
$table->id();
$table->foreignId('sprint_id')->constrained()->cascadeOnDelete();
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('task_id')->nullable()->constrained()->nullOnDelete();
$table->string('change_type');
$table->json('previous_state')->nullable();
$table->json('new_state')->nullable();
$table->text('reason')->nullable();
$table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
$table->decimal('capacity_impact', 8, 2)->nullable();
$table->text('goal_impact')->nullable();
$table->timestamps();
});
Schema::table('notifications', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('dismissed_at')->index();
$table->softDeletes();
});
if (Schema::hasTable('settings')) {
DB::table('settings')->insertOrIgnore([
['key' => 'working_days', 'value' => json_encode(['saturday', 'sunday', 'monday', 'tuesday', 'wednesday']), 'group' => 'calendar', 'type' => 'list', 'created_at' => $now, 'updated_at' => $now],
['key' => 'organization_timezone', 'value' => 'Asia/Tehran', 'group' => 'calendar', 'type' => 'string', 'created_at' => $now, 'updated_at' => $now],
['key' => 'default_meeting_reminder_minutes', 'value' => '30', 'group' => 'meeting', 'type' => 'number', 'created_at' => $now, 'updated_at' => $now],
['key' => 'meeting_effectiveness_enabled', 'value' => 'true', 'group' => 'meeting', 'type' => 'boolean', 'created_at' => $now, 'updated_at' => $now],
['key' => 'sprint_health_override_requires_reason', 'value' => 'true', 'group' => 'sprint', 'type' => 'boolean', 'created_at' => $now, 'updated_at' => $now],
]);
}
}
public function down(): void
{
Schema::table('notifications', function (Blueprint $table) {
$table->dropSoftDeletes();
$table->dropColumn('archived_at');
});
Schema::dropIfExists('sprint_scope_changes');
Schema::dropIfExists('blockers');
Schema::dropIfExists('action_items');
Schema::dropIfExists('decisions');
Schema::dropIfExists('meeting_relations');
Schema::table('meetings', function (Blueprint $table) {
$table->dropForeign(['sprint_id']);
$table->dropForeign(['meeting_type_id']);
$table->dropForeign(['owner_id']);
$table->dropForeign(['facilitator_id']);
$table->dropColumn([
'sprint_id', 'meeting_type_id', 'owner_id', 'facilitator_id', 'status',
'objective', 'summary', 'reminder_minutes', 'recurrence_rule',
'started_at', 'completed_at',
]);
});
Schema::dropIfExists('meeting_types');
}
};

مشاهده پرونده

@ -4,7 +4,7 @@ namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class DatabaseSeeder extends Seeder
{
@ -12,8 +12,9 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
DB::statement('PRAGMA foreign_keys = OFF');
Schema::disableForeignKeyConstraints();
try {
$this->call([
RolePermissionSeeder::class,
UserSeeder::class,
@ -21,13 +22,15 @@ class DatabaseSeeder extends Seeder
TaskSeeder::class,
SprintSeeder::class,
BacklogSeeder::class,
MeetingTypeSeeder::class,
MeetingSeeder::class,
CommentSeeder::class,
ActivityLogSeeder::class,
NotificationSeeder::class,
SettingSeeder::class,
]);
DB::statement('PRAGMA foreign_keys = ON');
} finally {
Schema::enableForeignKeyConstraints();
}
}
}

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use App\Models\MeetingType;
use Illuminate\Database\Seeder;
class MeetingTypeSeeder extends Seeder
{
public function run(): void
{
$types = [
['key' => 'sprint_planning', 'name' => 'برنامه‌ریزی اسپرینت', 'suggested_duration' => 90, 'default_title' => 'جلسه برنامه‌ریزی اسپرینت', 'agenda_template' => "هدف اسپرینت\nظرفیت تیم\nانتخاب اقلام بک‌لاگ\nریسک‌ها و وابستگی‌ها"],
['key' => 'daily_standup', 'name' => 'جلسه روزانه', 'suggested_duration' => 15, 'default_title' => 'Daily Stand-up', 'agenda_template' => "کارهای انجام‌شده\nبرنامه امروز\nموانع و نیاز به کمک", 'recurrence_rule' => ['frequency' => 'weekdays', 'until' => 'sprint_end']],
['key' => 'backlog_refinement', 'name' => 'پالایش بک‌لاگ', 'suggested_duration' => 60, 'default_title' => 'پالایش بک‌لاگ'],
['key' => 'sprint_review', 'name' => 'بازبینی اسپرینت', 'suggested_duration' => 60, 'default_title' => 'Sprint Review'],
['key' => 'sprint_retrospective', 'name' => 'بازنگری اسپرینت', 'suggested_duration' => 60, 'default_title' => 'Sprint Retrospective'],
['key' => 'adhoc', 'name' => 'جلسه موردی اسپرینت', 'suggested_duration' => 30, 'default_title' => 'جلسه موردی'],
['key' => 'blocker_resolution', 'name' => 'رفع مانع', 'suggested_duration' => 30, 'default_title' => 'جلسه رفع مانع'],
['key' => 'scope_change', 'name' => 'تغییر محدوده', 'suggested_duration' => 45, 'default_title' => 'بررسی تغییر محدوده'],
];
foreach ($types as $type) {
MeetingType::updateOrCreate(['key' => $type['key']], $type + ['is_active' => true]);
}
}
}

مشاهده پرونده

@ -108,10 +108,50 @@ class SettingSeeder extends Seeder
'created_at' => $now,
'updated_at' => $now,
],
[
'key' => 'working_days',
'value' => json_encode(['saturday', 'sunday', 'monday', 'tuesday', 'wednesday'], JSON_UNESCAPED_UNICODE),
'group' => 'calendar',
'type' => 'list',
'created_at' => $now,
'updated_at' => $now,
],
[
'key' => 'organization_timezone',
'value' => 'Asia/Tehran',
'group' => 'calendar',
'type' => 'string',
'created_at' => $now,
'updated_at' => $now,
],
[
'key' => 'default_meeting_reminder_minutes',
'value' => '30',
'group' => 'meeting',
'type' => 'number',
'created_at' => $now,
'updated_at' => $now,
],
[
'key' => 'meeting_effectiveness_enabled',
'value' => 'true',
'group' => 'meeting',
'type' => 'boolean',
'created_at' => $now,
'updated_at' => $now,
],
[
'key' => 'sprint_health_override_requires_reason',
'value' => 'true',
'group' => 'sprint',
'type' => 'boolean',
'created_at' => $now,
'updated_at' => $now,
],
];
foreach ($settings as $setting) {
DB::table('settings')->insert($setting);
DB::table('settings')->updateOrInsert(['key' => $setting['key']], $setting);
}
}
}

مشاهده پرونده

@ -1,32 +1,34 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\ChecklistController;
use App\Http\Controllers\Api\SubtaskController;
use App\Http\Controllers\Api\SprintController;
use App\Http\Controllers\Api\BacklogController;
use App\Http\Controllers\Api\MeetingController;
use App\Http\Controllers\Api\CommentController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\ActivityLogController;
use App\Http\Controllers\Api\NotificationController;
use App\Http\Controllers\Api\ReportController;
use App\Http\Controllers\Api\SearchController;
use App\Http\Controllers\Api\RoleController;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BacklogController;
use App\Http\Controllers\Api\CalendarController;
use App\Http\Controllers\Api\ChecklistController;
use App\Http\Controllers\Api\CommentController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\DepartmentController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\MeetingController;
use App\Http\Controllers\Api\NotificationController;
use App\Http\Controllers\Api\PermissionController;
use App\Http\Controllers\Api\SettingController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\PwaController;
use App\Http\Controllers\Api\ReportController;
use App\Http\Controllers\Api\RoleController;
use App\Http\Controllers\Api\SearchController;
use App\Http\Controllers\Api\SettingController;
use App\Http\Controllers\Api\SprintController;
use App\Http\Controllers\Api\SubtaskController;
use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\UserController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('throttle:login');
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('throttle:sensitive');
Route::middleware('auth:sanctum')->group(function () {
Route::middleware(['auth:sanctum', 'resource.access'])->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
Route::put('/user/profile', [AuthController::class, 'updateProfile']);
@ -55,6 +57,8 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/dashboard/summary', [DashboardController::class, 'summary'])->middleware('permission:reports.view');
Route::get('/dashboard/charts', [DashboardController::class, 'charts'])->middleware('permission:reports.view');
Route::get('/dashboard/monitoring', [DashboardController::class, 'monitoring'])->middleware('permission:reports.view');
Route::get('/calendar/events', [CalendarController::class, 'events']);
Route::get('/users', [UserController::class, 'index'])->middleware(['permission:team.view', 'throttle:sensitive']);
Route::post('/users', [UserController::class, 'store'])->middleware(['permission:team.create', 'throttle:sensitive']);
@ -62,11 +66,11 @@ Route::middleware('auth:sanctum')->group(function () {
Route::put('/users/{user}', [UserController::class, 'update'])->middleware(['permission:team.edit', 'throttle:sensitive']);
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware(['permission:team.edit', 'throttle:sensitive']);
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware(['permission:team.delete', 'throttle:sensitive']);
Route::patch('/users/{user}/status', [UserController::class, 'updateStatus']);
Route::put('/users/{user}/status', [UserController::class, 'updateStatus']);
Route::patch('/users/{user}/status', [UserController::class, 'updateStatus'])->middleware(['permission:team.edit', 'throttle:sensitive']);
Route::put('/users/{user}/status', [UserController::class, 'updateStatus'])->middleware(['permission:team.edit', 'throttle:sensitive']);
Route::put('/users/{user}/roles', [UserController::class, 'syncRoles'])->middleware(['permission:roles.edit', 'throttle:sensitive']);
Route::get('/users/{user}/workload', [UserController::class, 'updateWorkload']);
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats']);
Route::get('/users/{user}/workload', [UserController::class, 'updateWorkload'])->middleware('permission:team.view');
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats'])->middleware('permission:team.view');
Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view');
Route::post('/projects', [ProjectController::class, 'store'])->middleware('permission:projects.create');
@ -92,15 +96,16 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/my-tasks', [TaskController::class, 'myTasks']);
Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']);
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow();
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete']);
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow()->middleware('permission:tasks.edit');
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete'])->middleware('permission:tasks.edit');
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow();
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus']);
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow()->middleware('permission:tasks.edit');
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus'])->middleware('permission:tasks.edit');
Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view');
Route::post('/sprints', [SprintController::class, 'store'])->middleware('permission:sprints.create');
Route::get('/sprints/{sprint}', [SprintController::class, 'show'])->middleware('permission:sprints.view');
Route::get('/sprints/{sprint}/workspace', [SprintController::class, 'workspace'])->middleware('permission:sprints.view');
Route::put('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit');
Route::patch('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit');
Route::delete('/sprints/{sprint}', [SprintController::class, 'destroy'])->middleware('permission:sprints.delete');
@ -122,12 +127,19 @@ Route::middleware('auth:sanctum')->group(function () {
Route::delete('/backlog-items/{backlogItem}', [BacklogController::class, 'destroy'])->middleware('permission:backlog.delete');
Route::post('/backlog-items/{backlogItem}/convert-to-task', [BacklogController::class, 'convertToTask'])->middleware('permission:backlog.edit');
Route::get('/meeting-types', [MeetingController::class, 'types'])->middleware('permission:meetings.view');
Route::get('/meetings', [MeetingController::class, 'index'])->middleware('permission:meetings.view');
Route::post('/meetings', [MeetingController::class, 'store'])->middleware('permission:meetings.create');
Route::get('/meetings/{meeting}', [MeetingController::class, 'show'])->middleware('permission:meetings.view');
Route::get('/meetings/{meeting}/workspace', [MeetingController::class, 'workspace'])->middleware('permission:meetings.view');
Route::put('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit');
Route::patch('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit');
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::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');
Route::post('/meetings/{meeting}/participants', [MeetingController::class, 'addParticipant'])->middleware('permission:meetings.edit');
Route::delete('/meetings/{meeting}/participants/{user}', [MeetingController::class, 'removeParticipant'])->middleware('permission:meetings.edit');
Route::post('/meetings/{meeting}/action-items', [MeetingController::class, 'addActionItem'])->middleware('permission:meetings.edit');
@ -149,6 +161,9 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/notifications', [NotificationController::class, 'index'])->middleware('permission:notifications.view');
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->middleware('permission:notifications.view');
Route::get('/notifications/{notification}/preview', [NotificationController::class, 'preview'])->middleware('permission:notifications.view');
Route::patch('/notifications/{notification}/archive', [NotificationController::class, 'archive'])->middleware('permission:notifications.view');
Route::patch('/notifications/{notification}/restore', [NotificationController::class, 'restore'])->middleware('permission:notifications.view');
Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->middleware('permission:notifications.view');
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view');

مشاهده پرونده

@ -3,9 +3,11 @@
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class AuthTest extends TestCase
@ -91,4 +93,41 @@ class AuthTest extends TestCase
->getJson('/api/user')
->assertUnauthorized();
}
public function test_password_change_revokes_all_access_tokens(): void
{
$user = User::factory()->create([
'password' => Hash::make('old-password'),
'status' => 'active',
]);
$currentToken = $user->createToken('current')->plainTextToken;
$user->createToken('other');
$this->withToken($currentToken)
->putJson('/api/user/password', [
'current_password' => 'old-password',
'new_password' => 'new-secure-password',
'new_password_confirmation' => 'new-secure-password',
])
->assertOk()
->assertJsonPath('reauthentication_required', true);
$this->assertSame(0, $user->tokens()->count());
$this->assertTrue(Hash::check('new-secure-password', $user->fresh()->password));
}
public function test_forgot_password_sends_reset_notification_without_account_disclosure(): void
{
Notification::fake();
$user = User::factory()->create(['email' => 'reset@example.com', 'status' => 'active']);
$this->postJson('/api/forgot-password', ['email' => $user->email])
->assertOk()
->assertJsonPath('success', true);
Notification::assertSentTo($user, ResetPassword::class);
$this->postJson('/api/forgot-password', ['email' => 'missing@example.com'])
->assertOk()
->assertJsonPath('success', true);
}
}

مشاهده پرونده

@ -3,7 +3,11 @@
namespace Tests\Feature;
use App\Models\File;
use App\Models\Meeting;
use App\Models\MeetingActionItem;
use App\Models\Permission;
use App\Models\Project;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@ -84,4 +88,118 @@ class SecurityHardeningTest extends TestCase
->assertForbidden()
->assertJsonPath('success', false);
}
public function test_project_permission_does_not_expose_unrelated_projects(): void
{
$owner = User::factory()->create(['status' => 'active']);
$viewer = User::factory()->create(['status' => 'active']);
$permission = Permission::create([
'name' => 'projects.view',
'display_name' => 'View projects',
'guard_name' => 'web',
'module' => 'projects',
]);
$role = Role::create(['name' => 'project-viewer', 'display_name' => 'Project viewer', 'guard_name' => 'web']);
$role->permissions()->attach($permission);
$viewer->roles()->attach($role);
$foreignProject = Project::create([
'title' => 'Private project',
'project_manager_id' => $owner->id,
'created_by' => $owner->id,
]);
$token = $viewer->createToken('api-token')->plainTextToken;
$this->withToken($token)
->getJson('/api/projects')
->assertOk()
->assertJsonPath('meta.total', 0);
$this->withToken($token)
->getJson("/api/projects/{$foreignProject->id}")
->assertForbidden();
}
public function test_authenticated_user_cannot_change_another_user_status_without_permission(): void
{
$actor = User::factory()->create(['status' => 'active']);
$target = User::factory()->create(['status' => 'active']);
$token = $actor->createToken('api-token')->plainTextToken;
$this->withToken($token)
->patchJson("/api/users/{$target->id}/status", ['status' => 'inactive'])
->assertForbidden();
$this->assertSame('active', $target->fresh()->status);
}
public function test_user_cannot_download_file_from_unrelated_project(): void
{
$owner = User::factory()->create(['status' => 'active']);
$viewer = User::factory()->create(['status' => 'active']);
$permission = Permission::create([
'name' => 'files.view',
'display_name' => 'View files',
'guard_name' => 'web',
'module' => 'files',
]);
$role = Role::create(['name' => 'file-viewer', 'display_name' => 'File viewer', 'guard_name' => 'web']);
$role->permissions()->attach($permission);
$viewer->roles()->attach($role);
$project = Project::create([
'title' => 'Private project',
'project_manager_id' => $owner->id,
'created_by' => $owner->id,
]);
$file = File::create([
'name' => 'private.pdf',
'original_name' => 'private.pdf',
'path' => 'files/private.pdf',
'mime_type' => 'application/pdf',
'size' => 10,
'fileable_type' => Project::class,
'fileable_id' => $project->id,
'user_id' => $owner->id,
]);
$token = $viewer->createToken('api-token')->plainTextToken;
$this->withToken($token)->getJson("/api/files/{$file->id}")->assertForbidden();
}
public function test_meeting_action_item_must_belong_to_meeting_in_route(): void
{
$user = User::factory()->create(['status' => 'active']);
$this->grantAdminRole($user);
$project = Project::create([
'title' => 'Meeting security',
'project_manager_id' => $user->id,
'created_by' => $user->id,
]);
$first = Meeting::create([
'title' => 'First',
'project_id' => $project->id,
'date' => now()->toDateString(),
'meeting_type' => 'other',
'created_by' => $user->id,
]);
$second = Meeting::create([
'title' => 'Second',
'project_id' => $project->id,
'date' => now()->toDateString(),
'meeting_type' => 'other',
'created_by' => $user->id,
]);
$item = MeetingActionItem::create([
'meeting_id' => $second->id,
'title' => 'Private action',
]);
$token = $user->createToken('api-token')->plainTextToken;
$this->withToken($token)
->putJson("/api/meetings/{$first->id}/action-items/{$item->id}", ['title' => 'Tampered'])
->assertForbidden();
$this->assertSame('Private action', $item->fresh()->title);
}
}

مشاهده پرونده

@ -58,7 +58,10 @@ class SettingTest extends TestCase
$this->withToken($token)
->getJson('/api/settings')
->assertOk()
->assertJsonPath('data.0.value', 'شرکت پیشگام');
->assertJsonFragment([
'key' => 'company_name',
'value' => 'شرکت پیشگام',
]);
$this->withToken($token)
->putJson("/api/settings/{$plain->id}", [

مشاهده پرونده

@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Models\Meeting;
use App\Models\MeetingType;
use App\Models\Notification;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WorkspaceFeaturesTest extends TestCase
{
use RefreshDatabase;
private function authenticatedAdmin(): array
{
$user = User::factory()->create(['status' => 'active']);
$this->grantAdminRole($user);
return [$user, $user->createToken('workspace-test')->plainTextToken];
}
private function project(User $user): Project
{
return Project::create([
'title' => 'پروژه تست Workspace',
'project_manager_id' => $user->id,
'created_by' => $user->id,
'start_date' => now()->startOfMonth()->toDateString(),
'end_date' => now()->endOfMonth()->toDateString(),
'status' => 'in_progress',
]);
}
public function test_calendar_aggregates_real_project_sprint_task_and_meeting_events(): void
{
[$user, $token] = $this->authenticatedAdmin();
$project = $this->project($user);
$sprint = Sprint::create([
'title' => 'Sprint تقویم',
'project_id' => $project->id,
'start_date' => now()->startOfWeek()->toDateString(),
'end_date' => now()->endOfWeek()->toDateString(),
'status' => 'active',
'created_by' => $user->id,
]);
Task::create([
'title' => 'تسک سررسیددار',
'project_id' => $project->id,
'assignee_id' => $user->id,
'reporter_id' => $user->id,
'created_by' => $user->id,
'due_date' => now()->toDateString(),
'status' => 'todo',
]);
Meeting::create([
'title' => 'جلسه تقویم',
'project_id' => $project->id,
'sprint_id' => $sprint->id,
'meeting_type' => 'daily_standup',
'status' => 'scheduled',
'date' => now()->toDateString(),
'start_time' => '09:30',
'created_by' => $user->id,
]);
$response = $this->withToken($token)->getJson('/api/calendar/events?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString());
$response->assertOk()->assertJsonPath('success', true);
$types = collect($response->json('data'))->pluck('event_type');
$this->assertTrue($types->contains('meeting'));
$this->assertTrue($types->contains('task'));
$this->assertTrue($types->contains('sprint'));
$this->assertTrue($types->contains('project'));
}
public function test_notification_can_be_previewed_archived_restored_and_soft_deleted(): void
{
[$user, $token] = $this->authenticatedAdmin();
$notification = Notification::create([
'user_id' => $user->id,
'type' => 'system',
'title' => 'اعلان تست',
'body' => 'جزئیات اعلان',
]);
$this->withToken($token)
->getJson("/api/notifications/{$notification->id}/preview")
->assertOk()
->assertJsonPath('data.preview.body', 'جزئیات اعلان');
$this->withToken($token)
->patchJson("/api/notifications/{$notification->id}/archive")
->assertOk()
->assertJsonPath('data.archived_at', fn ($value) => filled($value));
$this->withToken($token)
->patchJson("/api/notifications/{$notification->id}/restore")
->assertOk()
->assertJsonPath('data.archived_at', null);
$this->withToken($token)->deleteJson("/api/notifications/{$notification->id}")->assertOk();
$this->assertSoftDeleted('notifications', ['id' => $notification->id]);
}
public function test_sprint_creation_can_create_configured_meetings_and_workspace(): void
{
[$user, $token] = $this->authenticatedAdmin();
$project = $this->project($user);
$this->assertGreaterThan(0, MeetingType::count());
$response = $this->withToken($token)->postJson('/api/sprints', [
'title' => 'Sprint یکپارچه',
'project_id' => $project->id,
'start_date' => now()->addDay()->toDateString(),
'end_date' => now()->addDays(14)->toDateString(),
'member_ids' => [$user->id],
'meeting_setup' => [[
'type_key' => 'sprint_planning',
'date' => now()->addDay()->toDateString(),
'start_time' => '10:00',
]],
])->assertCreated();
$sprintId = $response->json('data.id');
$this->assertDatabaseHas('meetings', ['sprint_id' => $sprintId, 'meeting_type' => 'sprint_planning']);
$this->withToken($token)
->getJson("/api/sprints/{$sprintId}/workspace")
->assertOk()
->assertJsonPath('data.sprint.id', $sprintId)
->assertJsonCount(1, 'data.meetings');
}
public function test_meeting_lifecycle_and_structured_outputs_are_available_in_workspace(): void
{
[$user, $token] = $this->authenticatedAdmin();
$project = $this->project($user);
$meeting = Meeting::create([
'title' => 'جلسه چرخه عمر',
'project_id' => $project->id,
'meeting_type' => 'adhoc',
'status' => 'scheduled',
'date' => now()->toDateString(),
'created_by' => $user->id,
]);
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/start")->assertOk()->assertJsonPath('data.status', 'in_progress');
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/decisions", ['title' => 'تصمیم قطعی'])->assertCreated();
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/structured-action-items", [
'title' => 'اقدام بعدی',
'owner_id' => $user->id,
'due_date' => now()->addDay()->toDateString(),
])->assertCreated();
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/blockers", ['title' => 'مانع تست'])->assertCreated();
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/complete", ['summary' => 'خلاصه نهایی'])->assertOk()->assertJsonPath('data.status', 'completed');
$this->withToken($token)
->getJson("/api/meetings/{$meeting->id}/workspace")
->assertOk()
->assertJsonCount(1, 'data.structured_decisions')
->assertJsonCount(1, 'data.structured_action_items')
->assertJsonCount(1, 'data.blockers');
}
}

مشاهده پرونده

@ -0,0 +1,29 @@
# Production launch checklist
## Required environment
- `APP_ENV=production`, `APP_DEBUG=false`, HTTPS `APP_URL`
- a secret generated `APP_KEY`
- `LOG_LEVEL=warning` or stricter with centralized retention and alerting
- MySQL 8 with TLS where traffic leaves the host
- `SESSION_SECURE_COOKIE=true`
- explicit trusted proxy, CORS, Sanctum stateful-domain, and cookie-domain values
- real SMTP credentials stored in the deployment secret manager
- dedicated cache/queue stores and supervised queue workers
## Release gates
- Clean reviewed commit and green CI.
- Dependency audit has no unresolved high/critical advisory.
- Production build artifact is generated from the reviewed commit.
- Database backup and restore drill are successful.
- Migration, rollback, incident owner, and maintenance window are documented.
- Rate limiting, access-control regression tests, file authorization, and password reset pass.
- Health check `/up` is monitored externally.
## Post-deploy checks
- Login/logout/password reset and forced reauthentication after password change.
- Role and record-level authorization with two users from different projects.
- Project, task, sprint, meeting, file, notification, and settings workflows.
- Queue failures, HTTP 5xx, database latency, disk use, and backup freshness.

مشاهده پرونده

@ -0,0 +1,64 @@
# SQLite to MySQL controlled migration
## Preconditions
- Work from a dedicated branch and record the source commit.
- Put the application in a planned write freeze.
- Keep the source SQLite file outside the web root.
- Create an immutable copy and verify its SHA-256 hash.
- Provision an empty MySQL 8 database using `utf8mb4` and `utf8mb4_unicode_ci`.
- Use a dedicated least-privilege migration account. Do not commit its password.
## Dry run
Configure only the `MIGRATION_MYSQL_*` variables. Keep `DB_CONNECTION=sqlite`.
```sh
php artisan migrate --database=migration_mysql --force
php artisan db:transfer-sqlite-to-mysql
```
The transfer command does not write unless `--execute` is supplied.
## Transfer
```sh
php artisan db:transfer-sqlite-to-mysql --execute --truncate
php artisan db:transfer-sqlite-to-mysql --execute
```
The second execution proves idempotency. Both runs must finish with identical source
and target row counts. Sessions, access tokens, cache, queues, reset tokens, and the
migration repository are intentionally excluded; users must authenticate again.
## Validation gates
- All migrations succeed on an empty MySQL database.
- Every transferred table has equal source and target row counts.
- Foreign-key validation reports no orphan rows.
- Unique business keys have no duplicates.
- JSON columns contain valid JSON.
- Dates, times, decimals, booleans, Persian text, and emoji round-trip correctly.
- Backend, frontend build, and browser tests pass with MySQL as the active test database.
- A manual smoke test covers login, project/task CRUD, sprint, meeting, files, notifications, settings, and reports.
## Cutover
1. Enable maintenance/write-freeze mode.
2. Take and hash a final SQLite backup.
3. Re-run the idempotent transfer without `--truncate`.
4. Run validation gates.
5. Change production secrets to `DB_CONNECTION=mysql`.
6. Clear configuration cache, restart application and queue workers, then run health and smoke checks.
7. Retain the SQLite source read-only for the defined rollback period.
## Rollback
If any validation or smoke check fails:
1. Stop writes immediately.
2. Restore the previous application configuration and `DB_CONNECTION=sqlite`.
3. Restore the verified final SQLite copy if the source was touched.
4. Restart application and workers.
5. Run `/up`, authentication, project, task, and file smoke checks.
6. Preserve MySQL unchanged for incident analysis; never merge partial target data back manually.

2
frontend/.gitignore فروخته شده
مشاهده پرونده

@ -10,6 +10,8 @@ lerna-debug.log*
node_modules
dist
dist-ssr
test-results
playwright-report
*.local
# Editor directories and files

مشاهده پرونده

@ -0,0 +1,108 @@
import { expect, test } from '@playwright/test';
test('login password can be shown and hidden', async ({ page }) => {
await page.goto('/login');
const password = page.locator('#login-password');
await password.fill('secret-password');
await expect(password).toHaveAttribute('type', 'password');
await page.getByRole('button', { name: 'نمایش رمز عبور' }).click();
await expect(password).toHaveAttribute('type', 'text');
await page.getByRole('button', { name: 'مخفی کردن رمز عبور' }).click();
await expect(password).toHaveAttribute('type', 'password');
});
test('dashboard command surfaces and settings render without runtime errors', async ({ page }, testInfo) => {
const runtimeErrors = [];
page.on('pageerror', (error) => runtimeErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') runtimeErrors.push(message.text());
});
const user = { id: 1, name: 'مدیر تست رابط', email: 'ui@example.test', permissions: ['reports.view', 'settings.view', 'notifications.view'] };
await page.addInitScript(({ testUser }) => {
localStorage.setItem('token', 'visual-smoke-token');
localStorage.setItem('user', JSON.stringify(testUser));
}, { testUser: user });
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/api/user') return route.fulfill({ json: { success: true, data: user } });
if (path === '/api/dashboard/monitoring') return route.fulfill({ json: { success: true, data: {
generated_at: new Date().toISOString(),
summary: { active_projects: 4, projects_at_risk: 1, open_tasks: 18, overdue_tasks: 3, blocked_tasks: 2, active_sprints: 2, meetings_today: 3, overdue_action_items: 1, open_blockers: 2 },
projects: [{ id: 1, title: 'پروژه نمونه رابط', status: 'in_progress', health: 'at_risk', progress: 62, total_tasks: 14, completed_tasks: 8, overdue_tasks: 2, blocked_tasks: 1 }],
sprints: [{ id: 1, title: 'Sprint رابط', project: 'پروژه نمونه رابط', progress: 58, expected_progress: 65, remaining_days: 5, health: 'on_track', blocked_tasks: 0 }],
workload: [{ id: 1, name: 'کاربر نمونه', open_tasks: 7, overdue_tasks: 1, load: 'high' }],
attention: [],
upcoming_meetings: [],
trends: [{ month: '2026-06', created: 12, completed: 9 }, { month: '2026-07', created: 14, completed: 13 }],
} } });
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' },
] } });
return route.fulfill({ json: { success: true, data: [] } });
});
await page.goto('/');
await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible();
await expect(page.locator('img.sidebar-brand-mark')).toBeVisible();
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 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 expect(page.locator('.notification-center')).toBeHidden();
await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click();
await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeVisible();
await expect(page.getByRole('button', { name: 'ذخیره هویت' })).toBeVisible();
await page.getByRole('tab', { name: 'اطلاعات کاری' }).click();
await expect(page.getByRole('button', { name: 'ذخیره اطلاعات کاری' })).toBeVisible();
const desktopModalMetrics = await page.getByRole('dialog', { name: 'پروفایل کاربری' }).evaluate((element) => {
const body = element.querySelector('.modal-body');
const styles = getComputedStyle(element);
return {
overflow: styles.overflowY,
bodyOverflow: getComputedStyle(body).overflowY,
radius: [styles.borderTopLeftRadius, styles.borderTopRightRadius, styles.borderBottomRightRadius, styles.borderBottomLeftRadius],
};
});
expect(desktopModalMetrics.overflow).toBe('hidden');
expect(desktopModalMetrics.bodyOverflow).toBe('hidden');
expect(desktopModalMetrics.radius.every((value) => parseFloat(value) > 0)).toBe(true);
await page.getByRole('button', { name: 'بستن', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeHidden();
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'مرکز تنظیمات' })).toBeVisible();
await expect(page.getByText('شخصی‌سازی')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('settings-command-center.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => localStorage.setItem('preferredAppMode', 'desktop'));
await page.goto('/');
await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.getByRole('button', { name: 'باز کردن منوی اصلی' }).click();
await expect(page.locator('.app-sidebar.mobile-open')).toBeVisible();
await expect(page.locator('.app-sidebar.mobile-open').getByText('پروژه‌ها')).toBeVisible();
await page.locator('.app-sidebar.mobile-open').getByRole('button', { name: 'بستن منوی اصلی' }).click();
await expect(page.locator('.app-sidebar.mobile-open')).toHaveCount(0);
await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click();
await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeVisible();
expect(await page.getByRole('dialog', { name: 'پروفایل کاربری' }).evaluate((element) => element.getBoundingClientRect().height <= window.innerHeight)).toBe(true);
await page.getByRole('button', { name: 'بستن', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('mobile-dashboard.png'), fullPage: true });
expect(runtimeErrors).toEqual([]);
});

مشاهده پرونده

@ -2,9 +2,11 @@
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/brand/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2563eb" />
<meta name="theme-color" content="#061a3a" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>مدیریت پروژه | سامانه مدیریت پروژه‌های سازمانی</title>
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css" rel="stylesheet" type="text/css" />

64
frontend/package-lock.json تولید شده
مشاهده پرونده

@ -17,6 +17,7 @@
"recharts": "^3.9.0"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
@ -434,6 +435,22 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@ -1824,6 +1841,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

مشاهده پرونده

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {
"axios": "^1.18.1",
@ -18,6 +19,7 @@
"recharts": "^3.9.0"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",

36
frontend/playwright.config.js normal فایل
مشاهده پرونده

@ -0,0 +1,36 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 8_000 },
retries: 0,
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:4004',
locale: 'fa-IR',
timezoneId: 'Asia/Tehran',
viewport: { width: 1440, height: 1000 },
colorScheme: 'light',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH
|| (process.platform === 'win32' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' : undefined),
},
},
webServer: [
{
command: 'php ../backend/artisan serve --host=127.0.0.1 --port=8000',
url: 'http://127.0.0.1:8000',
reuseExistingServer: true,
timeout: 30_000,
},
{
command: 'npm run dev -- --host=127.0.0.1',
url: 'http://127.0.0.1:4004',
reuseExistingServer: true,
timeout: 30_000,
},
],
});

دودویی (BIN)
frontend/public/brand/app-icon-192.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 51 KiB

دودویی (BIN)
frontend/public/brand/app-icon-512.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 330 KiB

دودویی (BIN)
frontend/public/brand/apple-touch-icon.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 45 KiB

دودویی (BIN)
frontend/public/brand/favicon-16.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 873 B

دودویی (BIN)
frontend/public/brand/favicon-32.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 2.5 KiB

دودویی (BIN)
frontend/public/brand/project-mark-original.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.3 MiB

دودویی (BIN)
frontend/public/brand/project-mark.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.3 MiB

مشاهده پرونده

@ -7,13 +7,19 @@
"display": "standalone",
"start_url": "/pwa",
"scope": "/",
"theme_color": "#2563eb",
"background_color": "#f8fafc",
"theme_color": "#061a3a",
"background_color": "#061a3a",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/brand/app-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/brand/app-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]

مشاهده پرونده

@ -3,61 +3,65 @@ import { Toaster } from 'react-hot-toast';
import { AuthProvider, useAuth } from './context/AuthContext';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
import { useState } from 'react';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Tasks from './pages/Tasks';
import Kanban from './pages/Kanban';
import Backlog from './pages/Backlog';
import Sprints from './pages/Sprints';
import Team from './pages/Team';
import MeetingList from './pages/MeetingList';
import Files from './pages/Files';
import Reports from './pages/Reports';
import Notifications from './pages/Notifications';
import Roles from './pages/Roles';
import Settings from './pages/Settings';
import Profile from './pages/Profile';
import Organization from './pages/Organization';
import PwaLayout from './pwa/PwaLayout';
import { lazy, Suspense, useEffect, useState } from 'react';
import { defaultRouteForAppMode, isStandalonePwa } from './utils/appMode';
import './styles/pwa.css';
const pageTitles = {
'/': 'داشبورد',
'/projects': 'پروژه‌ها',
'/tasks': 'تسک‌ها',
'/kanban': 'برد کاری',
'/backlog': 'بک‌لاگ',
'/sprints': 'اسپرینت‌ها',
'/team': 'اعضای تیم',
'/meetings': 'جلسات',
'/files': 'فایل‌ها',
'/reports': 'گزارش‌ها',
'/notifications': 'اعلان‌ها',
'/roles': 'نقش‌ها و دسترسی‌ها',
'/organization': 'ساختار سازمانی',
'/settings': 'تنظیمات',
'/profile': 'پروفایل کاربری',
};
const Login = lazy(() => import('./pages/Login'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Projects = lazy(() => import('./pages/Projects'));
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'));
const Tasks = lazy(() => import('./pages/Tasks'));
const Kanban = lazy(() => import('./pages/Kanban'));
const Backlog = lazy(() => import('./pages/Backlog'));
const Sprints = lazy(() => import('./pages/Sprints'));
const Team = lazy(() => import('./pages/Team'));
const MeetingList = lazy(() => import('./pages/MeetingList'));
const Files = lazy(() => import('./pages/Files'));
const Reports = lazy(() => import('./pages/Reports'));
const Notifications = lazy(() => import('./pages/Notifications'));
const Roles = lazy(() => import('./pages/Roles'));
const Settings = lazy(() => import('./pages/Settings'));
const Organization = lazy(() => import('./pages/Organization'));
const SprintWorkspace = lazy(() => import('./pages/SprintWorkspace'));
const MeetingWorkspace = lazy(() => import('./pages/MeetingWorkspace'));
const PwaLayout = lazy(() => import('./pwa/PwaLayout'));
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>در حال بارگذاری...</div>;
if (!user) return <Navigate to="/login" />;
return children;
function PageFallback() {
return <div className="page-loading" role="status">در حال بارگذاری...</div>;
}
function AppLayout({ children, title }) {
const [collapsed, setCollapsed] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === 'Escape') setMobileNavOpen(false);
};
const handleResize = () => {
if (window.innerWidth > 768) setMobileNavOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleResize);
};
}, []);
const toggleMobileNav = () => {
setCollapsed(false);
setMobileNavOpen((current) => !current);
};
return (
<div className="layout">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
<div className="main-content" style={{ marginRight: collapsed ? '72px' : 'var(--sidebar-width)', transition: 'margin-right 0.2s ease' }}>
<Header title={title} />
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
{mobileNavOpen && <button type="button" className="mobile-sidebar-overlay" aria-label="بستن منوی اصلی" onClick={() => setMobileNavOpen(false)} />}
<div className={`main-content ${collapsed ? 'sidebar-collapsed' : ''}`}>
<Header title={title} collapsed={collapsed} mobileNavOpen={mobileNavOpen} onToggleMobileNav={toggleMobileNav} />
<div className="main-inner">{children}</div>
</div>
</div>
@ -77,10 +81,12 @@ function AppRoutes() {
if (!user) {
return (
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Suspense>
);
}
@ -88,6 +94,7 @@ function AppRoutes() {
if (isStandalonePwa() && !isPwaPath) return <Navigate to="/pwa" replace />;
return (
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/login" element={<Navigate to={defaultRouteForAppMode()} replace />} />
<Route path="/pwa/*" element={<PwaLayout />} />
@ -98,8 +105,10 @@ function AppRoutes() {
<Route path="/kanban" element={<AppLayout title="برد کاری"><Kanban /></AppLayout>} />
<Route path="/backlog" element={<AppLayout title="بک‌لاگ"><Backlog /></AppLayout>} />
<Route path="/sprints" element={<AppLayout title="اسپرینت‌ها"><Sprints /></AppLayout>} />
<Route path="/sprints/:id/workspace" element={<AppLayout title="مرکز فرمان Sprint"><SprintWorkspace /></AppLayout>} />
<Route path="/team" element={<AppLayout title="اعضای تیم"><Team /></AppLayout>} />
<Route path="/meetings" element={<AppLayout title="جلسات"><MeetingList /></AppLayout>} />
<Route path="/meetings/:id/workspace" element={<AppLayout title="فضای کاری جلسه"><MeetingWorkspace /></AppLayout>} />
<Route path="/files" element={<AppLayout title="فایل‌ها"><Files /></AppLayout>} />
<Route path="/reports" element={<AppLayout title="گزارش‌ها"><Reports /></AppLayout>} />
<Route path="/notifications" element={<AppLayout title="اعلان‌ها"><Notifications /></AppLayout>} />
@ -109,6 +118,7 @@ function AppRoutes() {
<Route path="/profile" element={<AppLayout title="پروفایل کاربری"><Profile /></AppLayout>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
);
}

مشاهده پرونده

@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight, Circle, RefreshCw } from 'lucide-react';
import api from '../services/api';
import {
getJalaliWeekday,
jalaliMonthLength,
jalaliToIso,
todayJalaliParts,
} from '../utils/date';
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
const weekdays = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
const typeLabels = { meeting: 'جلسه', task: 'تسک', sprint: 'اسپرینت', project: 'پروژه', action_item: 'اقدام' };
function moveMonth(view, delta) {
let jm = view.jm + delta;
let jy = view.jy;
if (jm > 12) { jm = 1; jy += 1; }
if (jm < 1) { jm = 12; jy -= 1; }
return { jy, jm };
}
export default function CalendarPopover({ onSelectEvent }) {
const today = useMemo(() => todayJalaliParts(), []);
const [view, setView] = useState({ jy: today.jy, jm: today.jm });
const [selectedIso, setSelectedIso] = useState(jalaliToIso(today.jy, today.jm, today.jd));
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeTypes, setActiveTypes] = useState(Object.keys(typeLabels));
const [agendaPage, setAgendaPage] = useState(0);
const monthLength = jalaliMonthLength(view.jy, view.jm);
const firstWeekday = getJalaliWeekday(view.jy, view.jm, 1);
const from = jalaliToIso(view.jy, view.jm, 1);
const to = jalaliToIso(view.jy, view.jm, monthLength);
const loadEvents = useCallback(async () => {
setLoading(true);
setError('');
try {
const { data } = await api.get('/calendar/events', { params: { from, to } });
setEvents(data.data || []);
} catch {
setError('دریافت رویدادها ناموفق بود.');
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => { loadEvents(); }, [loadEvents]);
const eventsByDate = useMemo(() => events.reduce((result, event) => {
const date = String(event.start_at || '').slice(0, 10);
if (!result[date]) result[date] = [];
result[date].push(event);
return result;
}, {}), [events]);
const selectedEvents = (eventsByDate[selectedIso] || []).filter((event) => activeTypes.includes(event.event_type));
const agendaPageSize = 2;
const agendaPageCount = Math.max(1, Math.ceil(selectedEvents.length / agendaPageSize));
const visibleEvents = selectedEvents.slice(agendaPage * agendaPageSize, agendaPage * agendaPageSize + agendaPageSize);
const cells = [
...Array.from({ length: firstWeekday }, () => null),
...Array.from({ length: monthLength }, (_, index) => index + 1),
];
const goToday = () => {
setView({ jy: today.jy, jm: today.jm });
setSelectedIso(jalaliToIso(today.jy, today.jm, today.jd));
};
const toggleType = (type) => {
setActiveTypes((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type]);
};
useEffect(() => { setAgendaPage(0); }, [selectedIso, activeTypes]);
return (
<section className="calendar-popover" aria-label="تقویم رویدادها">
<header className="calendar-popover-head">
<div>
<strong>{monthNames[view.jm - 1]} {view.jy}</strong>
<span>{events.length} رویداد</span>
</div>
<div className="calendar-controls">
<button type="button" onClick={() => setView(moveMonth(view, -1))} aria-label="ماه قبل"><ChevronRight size={18} /></button>
<button type="button" className="calendar-today" onClick={goToday}>امروز</button>
<button type="button" onClick={() => setView(moveMonth(view, 1))} aria-label="ماه بعد"><ChevronLeft size={18} /></button>
</div>
</header>
<div className="calendar-weekdays">
{weekdays.map((day) => <span key={day}>{day}</span>)}
</div>
<div className="calendar-grid">
{cells.map((day, index) => {
if (!day) return <span key={`blank-${index}`} className="calendar-day blank" />;
const iso = jalaliToIso(view.jy, view.jm, day);
const dayEvents = (eventsByDate[iso] || []).filter((event) => activeTypes.includes(event.event_type));
const isToday = iso === jalaliToIso(today.jy, today.jm, today.jd);
return (
<button
type="button"
key={iso}
className={`calendar-day ${selectedIso === iso ? 'selected' : ''} ${isToday ? 'today' : ''}`}
onClick={() => setSelectedIso(iso)}
aria-label={`${day} ${monthNames[view.jm - 1]}، ${dayEvents.length} رویداد`}
>
<span>{day}</span>
{dayEvents.length > 0 && <i>{dayEvents.slice(0, 3).map((event) => <b key={event.id} className={`tone-${event.color_token}`} />)}</i>}
</button>
);
})}
</div>
<div className="calendar-filters" aria-label="فیلتر نوع رویداد">
{Object.entries(typeLabels).map(([type, label]) => (
<button type="button" key={type} className={activeTypes.includes(type) ? 'active' : ''} onClick={() => toggleType(type)}>
<Circle size={8} fill="currentColor" />{label}
</button>
))}
</div>
<div className="calendar-agenda">
<div className="calendar-agenda-title">
<strong>برنامه روز</strong>
<span>{selectedEvents.length} مورد</span>
</div>
{loading ? (
<div className="calendar-state"><RefreshCw className="spin" size={18} /> در حال دریافت...</div>
) : error ? (
<button type="button" className="calendar-state error" onClick={loadEvents}>{error} تلاش دوباره</button>
) : selectedEvents.length === 0 ? (
<div className="calendar-state"><CalendarDays size={20} /> رویدادی برای این روز وجود ندارد.</div>
) : (
<>
{visibleEvents.map((event) => (
<button type="button" className="calendar-event" key={event.id} onClick={() => onSelectEvent(event)}>
<i className={`tone-${event.color_token}`} />
<span>
<strong>{event.title}</strong>
<small>{typeLabels[event.event_type]}{event.project?.title ? ` · ${event.project.title}` : ''}</small>
</span>
<ChevronLeft size={16} />
</button>
))}
{agendaPageCount > 1 && (
<div className="popover-pagination">
<button type="button" disabled={agendaPage === 0} onClick={() => setAgendaPage((page) => Math.max(0, page - 1))}><ChevronRight size={16} /> قبلی</button>
<span>{agendaPage + 1} از {agendaPageCount}</span>
<button type="button" disabled={agendaPage >= agendaPageCount - 1} onClick={() => setAgendaPage((page) => Math.min(agendaPageCount - 1, page + 1))}>بعدی <ChevronLeft size={16} /></button>
</div>
)}
</>
)}
</div>
</section>
);
}

مشاهده پرونده

@ -0,0 +1,88 @@
import { ArrowLeft, CalendarDays, ExternalLink, FolderKanban, Info, Timer } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import Modal from './Modal';
import { formatJalaliDateTime } from '../utils/date';
const typeLabels = {
meeting: 'جلسه',
task: 'تسک',
sprint: 'اسپرینت',
project: 'پروژه',
action_item: 'اقدام',
notification: 'اعلان',
};
export default function EntityPreviewModal({ item, onClose }) {
const navigate = useNavigate();
if (!item) return null;
const title = item.title || item.preview?.title || 'جزئیات';
const body = item.body || item.preview?.body || item.message;
const entityType = item.entity_type || item.event_type || 'notification';
const targetUrl = item.target_url;
const previewEntries = item.preview && !item.preview.title
? Object.entries(item.preview).filter(([, value]) => value !== null && value !== undefined && value !== '')
: [];
const goToTarget = () => {
if (!targetUrl) return;
onClose();
navigate(targetUrl);
};
return (
<Modal
title="مشاهده سریع"
onClose={onClose}
size="sm"
footer={(
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>بستن</button>
{targetUrl && (
<button type="button" className="btn btn-primary" onClick={goToTarget}>
رفتن به بخش مربوطه
<ArrowLeft size={16} />
</button>
)}
</>
)}
>
<article className="entity-preview">
<div className={`entity-preview-icon tone-${item.color_token || 'primary'}`}>
<Info size={24} />
</div>
<div className="entity-preview-heading">
<span className="badge badge-primary">{typeLabels[entityType] || entityType}</span>
<h2>{title}</h2>
{body && <p>{body}</p>}
</div>
<div className="entity-preview-meta">
{(item.start_at || item.created_at) && (
<div><CalendarDays size={16} /><span>{formatJalaliDateTime(item.start_at || item.created_at)}</span></div>
)}
{item.end_at && (
<div><Timer size={16} /><span>تا {formatJalaliDateTime(item.end_at)}</span></div>
)}
{item.project?.title && (
<div><FolderKanban size={16} /><span>{item.project.title}</span></div>
)}
{item.sprint?.title && (
<div><ExternalLink size={16} /><span>{item.sprint.title}</span></div>
)}
</div>
{previewEntries.length > 0 && (
<dl className="entity-preview-details">
{previewEntries.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{String(value)}</dd>
</div>
))}
</dl>
)}
</article>
</Modal>
);
}

مشاهده پرونده

@ -1,142 +1,161 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Bell, CalendarDays, LogOut, Menu, Search, Settings } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import api from '../services/api';
import { Search, Bell, CheckCheck } from 'lucide-react';
import ThemeModeToggle from './ThemeModeToggle';
import CalendarPopover from './CalendarPopover';
import NotificationCenter from './NotificationCenter';
import EntityPreviewModal from './EntityPreviewModal';
import useDismissibleLayer from '../hooks/useDismissibleLayer';
import Modal from './Modal';
import Profile from '../pages/Profile';
export default function Header({ title }) {
export default function Header({ title, collapsed = false, mobileNavOpen = false, onToggleMobileNav }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [showMenu, setShowMenu] = useState(false);
const [activeLayer, setActiveLayer] = useState(null);
const [previewItem, setPreviewItem] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const calendarButtonRef = useRef(null);
const calendarPanelRef = useRef(null);
const notificationButtonRef = useRef(null);
const notificationPanelRef = useRef(null);
const profileButtonRef = useRef(null);
const fetchNotifications = async () => {
const closeLayer = useCallback(() => setActiveLayer(null), []);
const layerRefs = activeLayer === 'calendar'
? [calendarButtonRef, calendarPanelRef]
: [notificationButtonRef, notificationPanelRef];
useDismissibleLayer(activeLayer === 'calendar' || activeLayer === 'notifications', layerRefs, closeLayer);
const fetchUnreadCount = useCallback(async () => {
try {
const { data } = await api.get('/notifications', { params: { per_page: 8 } });
setNotifications(data.data || []);
setUnreadCount(data.meta?.unread_count ?? (data.data || []).filter((item) => !item.read_at).length);
} catch {}
};
useEffect(() => {
fetchNotifications();
const timer = setInterval(fetchNotifications, 60000);
return () => clearInterval(timer);
const { data } = await api.get('/notifications', { params: { per_page: 1, unread: 1 } });
setUnreadCount(data.meta?.unread_count || 0);
} catch {
setUnreadCount(0);
}
}, []);
const handleSearch = async (e) => {
const q = e.target.value;
setSearchQuery(q);
if (q.length < 2) { setSearchResults([]); return; }
useEffect(() => {
fetchUnreadCount();
const timer = window.setInterval(fetchUnreadCount, 60000);
return () => window.clearInterval(timer);
}, [fetchUnreadCount]);
const handleSearch = async (event) => {
const query = event.target.value;
setSearchQuery(query);
if (query.length < 2) { setSearchResults([]); return; }
setSearching(true);
try {
const res = await api.get(`/search?q=${q}`);
setSearchResults(res.data.data || []);
} catch {} finally { setSearching(false); }
};
const notificationUrl = (notification) => notification.data?.url || (notification.type?.includes('meeting') ? '/meetings' : notification.type?.includes('task') || notification.type === 'mention' ? '/kanban' : '/notifications');
const markNotificationRead = async (notification) => {
if (!notification.read_at) {
try {
await api.post(`/notifications/${notification.id}/read`);
} catch {}
const { data } = await api.get('/search', { params: { q: query } });
setSearchResults(data.data || []);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
setShowNotifications(false);
fetchNotifications();
navigate(notificationUrl(notification));
};
const markAllNotificationsRead = async () => {
try {
await api.post('/notifications/read-all');
fetchNotifications();
} catch {}
};
const toggleLayer = (name) => setActiveLayer((current) => current === name ? null : name);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const openPreview = (item) => {
closeLayer();
setPreviewItem(item);
};
return (
<header style={{
position: 'fixed', top: 0, left: 0, right: 'var(--sidebar-width)', height: 'var(--header-height)',
background: 'var(--surface)', borderBottom: '1px solid var(--gray-200)', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 1.5rem', zIndex: 50, gap: '1rem',
}}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--gray-900)' }}>{title}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flex: 1, maxWidth: 400, position: 'relative' }}>
<input
value={searchQuery}
onChange={handleSearch}
placeholder="جستجوی پروژه، تسک، کاربر..."
className="form-input"
style={{ paddingRight: '2.5rem' }}
/>
<Search size={18} style={{ position: 'absolute', right: '0.75rem', color: 'var(--gray-400)' }} />
<>
<header className="app-header" style={{ right: collapsed ? 72 : 'var(--sidebar-width)' }}>
<h1>{title}</h1>
<div className="header-search">
<input value={searchQuery} onChange={handleSearch} placeholder="جستجوی پروژه، تسک، کاربر..." className="form-input" />
<Search size={18} />
{searching && <span className="header-searching">...</span>}
{searchResults.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--surface)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--gray-200)', zIndex: 100, maxHeight: 300, overflow: 'auto', marginTop: 4 }}>
{searchResults.map((item, i) => (
<Link key={i} to={item.url || '#'} style={{ display: 'block', padding: '0.75rem 1rem', borderBottom: '1px solid var(--gray-100)', fontSize: '0.875rem', color: 'var(--gray-700)' }}>
{item.title || item.name}
<div className="header-search-results">
{searchResults.slice(0, 5).map((item, index) => (
<Link key={`${item.url}-${index}`} to={item.url || '#'} onClick={() => { setSearchResults([]); setSearchQuery(''); }}>
<strong>{item.title || item.name}</strong>
{item.type && <small>{item.type}</small>}
</Link>
))}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="header-actions">
<button
type="button"
className={`header-icon-button mobile-nav-toggle ${mobileNavOpen ? 'active' : ''}`}
onClick={onToggleMobileNav}
aria-label={mobileNavOpen ? 'بستن منوی اصلی' : 'باز کردن منوی اصلی'}
aria-controls="main-navigation-drawer"
aria-expanded={mobileNavOpen}
>
<Menu size={20} />
</button>
<div className="header-layer">
<button ref={calendarButtonRef} type="button" className={`header-icon-button ${activeLayer === 'calendar' ? 'active' : ''}`} onClick={() => toggleLayer('calendar')} aria-label="تقویم رویدادها" aria-expanded={activeLayer === 'calendar'}>
<CalendarDays size={20} />
</button>
{activeLayer === 'calendar' && <div ref={calendarPanelRef} className="header-popover calendar-layer"><CalendarPopover onSelectEvent={openPreview} /></div>}
</div>
<ThemeModeToggle compact />
<div style={{ position: 'relative' }}>
<button className="header-bell" onClick={() => { setShowNotifications(!showNotifications); if (!showNotifications) fetchNotifications(); }} title="اعلان‌ها">
<div className="header-layer">
<button ref={notificationButtonRef} type="button" className={`header-icon-button ${activeLayer === 'notifications' ? 'active' : ''}`} onClick={() => toggleLayer('notifications')} aria-label="اعلان‌ها" aria-expanded={activeLayer === 'notifications'}>
<Bell size={20} />
{unreadCount > 0 && <span className="header-bell-dot" />}
{unreadCount > 0 && <span className="header-badge">{unreadCount > 99 ? '۹۹+' : unreadCount}</span>}
</button>
{showNotifications && (
<div className="notification-popover">
<div className="notification-popover-head">
<strong>اعلانها</strong>
{unreadCount > 0 && <button className="btn btn-sm btn-outline" onClick={markAllNotificationsRead}><CheckCheck size={14} /> خواندن همه</button>}
</div>
{notifications.length === 0 ? (
<div className="notification-empty">اعلان جدیدی ندارید</div>
) : notifications.map((notification) => (
<button key={notification.id} className={`notification-item ${notification.read_at ? '' : 'unread'}`} onClick={() => markNotificationRead(notification)}>
<span>{notification.title || notification.message}</span>
<small>{notification.read_at ? 'خوانده‌شده' : 'جدید'}</small>
</button>
))}
<Link className="notification-all-link" to="/notifications" onClick={() => setShowNotifications(false)}>مشاهده همه اعلانها</Link>
{activeLayer === 'notifications' && (
<div ref={notificationPanelRef} className="header-popover notification-layer">
<NotificationCenter onOpenNotification={openPreview} onCountChange={setUnreadCount} />
</div>
)}
</div>
<div style={{ position: 'relative' }}>
<div onClick={() => setShowMenu(!showMenu)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
{user?.avatar_url ? <img className="avatar avatar-sm avatar-img" src={user.avatar_url} alt={user?.name || 'avatar'} /> : <div className="avatar avatar-sm">{user?.name?.[0] || '?'}</div>}
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--gray-700)' }}>{user?.name}</span>
</div>
{showMenu && (
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 8, background: 'var(--surface)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--gray-200)', minWidth: 180, zIndex: 100 }}>
<Link to="/profile" style={{ display: 'block', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--gray-700)', borderBottom: '1px solid var(--gray-100)' }} onClick={() => setShowMenu(false)}>
پروفایل کاربری
</Link>
<Link to="/settings" style={{ display: 'block', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--gray-700)', borderBottom: '1px solid var(--gray-100)' }} onClick={() => setShowMenu(false)}>
تنظیمات
</Link>
<button onClick={handleLogout} style={{ display: 'block', width: '100%', textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--danger)' }}>
خروج
<div className="header-layer">
<button ref={profileButtonRef} type="button" className={`header-profile ${activeLayer === 'profile' ? 'active' : ''}`} onClick={() => toggleLayer('profile')} aria-label="باز کردن پروفایل کاربری" aria-haspopup="dialog" aria-expanded={activeLayer === 'profile'}>
{user?.avatar_url ? <img className="avatar avatar-sm avatar-img" src={user.avatar_url} alt="" /> : <span className="avatar avatar-sm">{user?.name?.[0] || '?'}</span>}
<span>{user?.name}</span>
</button>
</div>
)}
</div>
</div>
</header>
{activeLayer === 'profile' && (
<Modal
title="پروفایل کاربری"
size="xl"
onClose={closeLayer}
footer={(
<div className="profile-modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => { closeLayer(); navigate('/settings'); }}>
<Settings size={17} />
تنظیمات
</button>
<button type="button" className="btn btn-danger profile-logout-button" onClick={handleLogout}>
<LogOut size={17} />
خروج از حساب
</button>
</div>
)}
>
<Profile embedded />
</Modal>
)}
<EntityPreviewModal item={previewItem} onClose={() => setPreviewItem(null)} />
</>
);
}

مشاهده پرونده

@ -1,18 +1,43 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
export default function Modal({ title, children, onClose, footer, size }) {
const modalRef = useRef(null);
const lastFocusedRef = useRef(null);
useEffect(() => {
lastFocusedRef.current = document.activeElement;
const previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const modal = modalRef.current;
const focusable = modal?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
focusable?.focus();
const handleEscape = (e) => { if (e.key === 'Escape') onClose(); };
const handleTab = (event) => {
if (event.key !== 'Tab' || !modal) return;
const items = [...modal.querySelectorAll('button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')];
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
document.addEventListener('keydown', handleTab);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleTab);
document.body.style.overflow = previousBodyOverflow;
lastFocusedRef.current?.focus?.();
};
}, [onClose]);
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={`modal${size ? ` modal-${size}` : ''}`}>
<div ref={modalRef} className={`modal${size ? ` modal-${size}` : ''}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-header">
<h3 className="modal-title">{title}</h3>
<button className="modal-close" onClick={onClose}>&times;</button>
<button type="button" className="modal-close" onClick={onClose} aria-label="بستن"><X size={20} /></button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}

مشاهده پرونده

@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from 'react';
import { Archive, Bell, CheckCheck, ChevronLeft, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../services/api';
import { formatJalaliDateTime } from '../utils/date';
import useBidirectionalNotificationSwipe from '../pwa/useBidirectionalNotificationSwipe';
function NotificationRow({ notification, archived, onOpen, onRemove, onRestore }) {
const runAction = async (action) => {
try {
if (action === 'archive') {
await api.patch(`/notifications/${notification.id}/archive`);
toast.success('اعلان آرشیو شد');
} else if (action === 'delete') {
await api.delete(`/notifications/${notification.id}`);
toast.success('اعلان حذف شد');
}
onRemove(notification.id);
} catch {
toast.error('انجام عملیات اعلان ناموفق بود');
}
};
const swipe = useBidirectionalNotificationSwipe({
rightAction: 'archive',
leftAction: 'delete',
onExitComplete: runAction,
});
return (
<div
className={`notification-swipe-row ${swipe.direction || ''} ${swipe.dragging ? 'dragging' : ''} ${swipe.exiting ? 'exiting' : ''}`}
style={{
'--notification-translate': `${swipe.translateX}px`,
'--notification-progress': swipe.progress,
}}
{...swipe.swipeHandlers}
>
<div className="notification-swipe-action archive"><Archive size={18} /><span>آرشیو</span></div>
<div className="notification-swipe-action delete"><Trash2 size={18} /><span>حذف</span></div>
<div className={`notification-center-item ${notification.read_at ? '' : 'unread'}`}>
<button type="button" className="notification-center-main" onClick={() => onOpen(notification)}>
<span className="notification-center-icon"><Bell size={17} /></span>
<span>
<strong>{notification.title || notification.message || 'اعلان'}</strong>
<small>{notification.body || notification.preview?.body || formatJalaliDateTime(notification.created_at)}</small>
</span>
</button>
<div className="notification-center-actions">
{archived ? (
<button type="button" onClick={() => onRestore(notification)} title="بازیابی" aria-label="بازیابی اعلان"><RotateCcw size={15} /></button>
) : (
<button type="button" onClick={() => runAction('archive')} title="آرشیو" aria-label="آرشیو اعلان"><Archive size={15} /></button>
)}
<button type="button" className="danger" onClick={() => runAction('delete')} title="حذف" aria-label="حذف اعلان"><Trash2 size={15} /></button>
</div>
</div>
</div>
);
}
export default function NotificationCenter({ onOpenNotification, onCountChange }) {
const [tab, setTab] = useState('unread');
const [notifications, setNotifications] = useState([]);
const [meta, setMeta] = useState({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const pageSize = 4;
const fetchNotifications = useCallback(async () => {
setLoading(true);
try {
const params = { per_page: 20 };
if (tab === 'unread') params.unread = 1;
if (tab === 'archived') params.archived = 1;
const { data } = await api.get('/notifications', { params });
setNotifications(data.data || []);
setMeta(data.meta || {});
onCountChange?.(data.meta?.unread_count || 0);
} catch {
toast.error('دریافت اعلان‌ها ناموفق بود');
} finally {
setLoading(false);
}
}, [tab, onCountChange]);
useEffect(() => { fetchNotifications(); }, [fetchNotifications]);
useEffect(() => { setPage(0); }, [tab]);
const pageCount = Math.max(1, Math.ceil(notifications.length / pageSize));
const visibleNotifications = notifications.slice(page * pageSize, page * pageSize + pageSize);
const markAllRead = async () => {
try {
await api.post('/notifications/read-all');
toast.success('همه اعلان‌ها خوانده شدند');
fetchNotifications();
} catch {
toast.error('به‌روزرسانی اعلان‌ها ناموفق بود');
}
};
const openNotification = async (notification) => {
try {
const { data } = await api.get(`/notifications/${notification.id}/preview`);
setNotifications((current) => current.map((item) => item.id === notification.id ? { ...item, read_at: new Date().toISOString() } : item));
onCountChange?.(Math.max(0, (meta.unread_count || 0) - (notification.read_at ? 0 : 1)));
onOpenNotification(data.data);
} catch {
onOpenNotification(notification);
}
};
const restore = async (notification) => {
try {
await api.patch(`/notifications/${notification.id}/restore`);
setNotifications((current) => current.filter((item) => item.id !== notification.id));
toast.success('اعلان بازیابی شد');
} catch {
toast.error('بازیابی اعلان ناموفق بود');
}
};
return (
<section className="notification-center" aria-label="مرکز اعلان‌ها">
<header className="notification-center-head">
<div>
<strong>اعلانها</strong>
<span>{meta.unread_count || 0} خواندهنشده</span>
</div>
{(meta.unread_count || 0) > 0 && (
<button type="button" className="btn btn-sm btn-outline" onClick={markAllRead}><CheckCheck size={15} /> خواندن همه</button>
)}
</header>
<div className="notification-center-tabs" role="tablist">
<button type="button" className={tab === 'unread' ? 'active' : ''} onClick={() => setTab('unread')}>جدید</button>
<button type="button" className={tab === 'all' ? 'active' : ''} onClick={() => setTab('all')}>همه</button>
<button type="button" className={tab === 'archived' ? 'active' : ''} onClick={() => setTab('archived')}>آرشیو</button>
</div>
<div className="notification-center-list">
{loading ? (
Array.from({ length: 4 }).map((_, index) => <div key={index} className="skeleton notification-center-skeleton" />)
) : notifications.length === 0 ? (
<div className="notification-center-empty"><Bell size={24} /><strong>اعلانی در این بخش نیست</strong></div>
) : visibleNotifications.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
archived={tab === 'archived'}
onOpen={openNotification}
onRemove={(id) => setNotifications((current) => current.filter((item) => item.id !== id))}
onRestore={restore}
/>
))}
</div>
{!loading && notifications.length > pageSize && (
<div className="popover-pagination notification-pagination">
<button type="button" disabled={page === 0} onClick={() => setPage((current) => Math.max(0, current - 1))}><ChevronRight size={16} /> قبلی</button>
<span>{page + 1} از {pageCount}</span>
<button type="button" disabled={page >= pageCount - 1} onClick={() => setPage((current) => Math.min(pageCount - 1, current + 1))}>بعدی <ChevronLeft size={16} /></button>
</div>
)}
<footer className="notification-center-hint">برای آرشیو به راست و برای حذف به چپ بکشید؛ دکمهها نیز همیشه در دسترساند.</footer>
</section>
);
}

مشاهده پرونده

@ -1,6 +1,6 @@
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { LayoutDashboard, FolderKanban, CheckSquare, Kanban, Inbox, Timer, Users, Calendar, Paperclip, TrendingUp, Bell, Building2, Shield, Settings, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { LayoutDashboard, FolderKanban, CheckSquare, Kanban, Inbox, Timer, Users, Calendar, Paperclip, TrendingUp, Building2, Shield, Settings, PanelRightClose, PanelRightOpen, X } from 'lucide-react';
const iconSize = 18;
@ -15,16 +15,15 @@ const menuItems = [
{ path: '/meetings', label: 'جلسات', icon: Calendar },
{ path: '/files', label: 'فایل‌ها', icon: Paperclip },
{ path: '/reports', label: 'گزارش‌ها', icon: TrendingUp },
{ path: '/notifications', label: 'اعلان‌ها', icon: Bell },
{ path: '/organization', label: 'ساختار سازمانی', icon: Building2 },
{ path: '/roles', label: 'نقش‌ها و دسترسی‌ها', icon: Shield },
{ path: '/settings', label: 'تنظیمات', icon: Settings },
];
export default function Sidebar({ collapsed, onToggle }) {
export default function Sidebar({ collapsed, onToggle, mobileOpen = false, onMobileClose }) {
const { user } = useAuth();
return (
<aside style={{
<aside id="main-navigation-drawer" className={`app-sidebar ${mobileOpen ? 'mobile-open' : ''}`} aria-label="منوی اصلی" style={{
width: collapsed ? '72px' : 'var(--sidebar-width)',
height: '100vh',
position: 'fixed',
@ -35,16 +34,18 @@ export default function Sidebar({ collapsed, onToggle }) {
display: 'flex',
flexDirection: 'column',
zIndex: 100,
transition: 'width 0.2s ease',
transition: 'width 0.2s ease, transform 0.2s ease',
overflow: 'hidden',
}}>
<div style={{ padding: collapsed ? '0.875rem 0.75rem' : '1rem 1rem 1rem 1.25rem', display: 'flex', alignItems: 'center', gap: '0.75rem', borderBottom: '1px solid var(--gray-100)', justifyContent: collapsed ? 'center' : 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-on-primary)', fontWeight: 700, fontSize: '1rem', flexShrink: 0 }}>م</div>
<img className="sidebar-brand-mark" src="/brand/project-mark.png" alt="" />
{!collapsed && <span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--gray-900)', whiteSpace: 'nowrap' }}>مدیریت پروژه</span>}
</div>
<button
type="button"
onClick={onToggle}
className="sidebar-collapse-toggle"
title={collapsed ? 'باز کردن منو' : 'بستن منو'}
style={{
width: 34,
@ -64,10 +65,13 @@ export default function Sidebar({ collapsed, onToggle }) {
>
{collapsed ? <PanelRightOpen size={18} /> : <PanelRightClose size={18} />}
</button>
<button type="button" className="sidebar-mobile-close" onClick={onMobileClose} aria-label="بستن منوی اصلی">
<X size={20} />
</button>
</div>
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 0' }}>
{menuItems.map((item) => (
<NavLink key={item.path} to={item.path} end={item.path === '/'} style={({ isActive }) => ({
<NavLink key={item.path} to={item.path} end={item.path === '/'} onClick={onMobileClose} style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: collapsed ? '0.75rem 1.5rem' : '0.625rem 1.25rem',
margin: '0 0.5rem',

مشاهده پرونده

@ -0,0 +1,22 @@
import { useEffect } from 'react';
export default function useDismissibleLayer(open, refs, onDismiss) {
useEffect(() => {
if (!open) return undefined;
const handlePointerDown = (event) => {
const inside = refs.some((ref) => ref.current?.contains(event.target));
if (!inside) onDismiss();
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') onDismiss();
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, refs, onDismiss]);
}

مشاهده پرونده

@ -1,167 +1,213 @@
import { useState, useEffect } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Activity, AlertTriangle, Ban, CalendarDays, CheckCircle2, Clock3,
FolderKanban, RefreshCw, ShieldAlert, TimerReset,
} from 'lucide-react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import api from '../services/api';
import { KPISkeleton } from '../components/LoadingSkeleton';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FolderKanban, ClipboardList, CheckCircle, Clock, Users, Calendar, Plus, FileText, UserPlus } from 'lucide-react';
import { formatJalaliDate } from '../utils/date';
import { formatJalaliDate, formatJalaliDateTime } from '../utils/date';
const healthLabels = { on_track: 'روی برنامه', at_risk: 'در معرض ریسک', off_track: 'خارج از برنامه' };
const healthClass = { on_track: 'success', at_risk: 'warning', off_track: 'danger' };
function MetricCard({ label, value, detail, icon: Icon, tone = 'primary' }) {
return (
<article className={`monitor-metric tone-${tone}`}>
<span className="monitor-metric-icon"><Icon size={21} /></span>
<div><span>{label}</span><strong>{value ?? 0}</strong><small>{detail}</small></div>
</article>
);
}
function Panel({ title, subtitle, action, children, className = '' }) {
return (
<section className={`monitor-panel ${className}`}>
<header className="monitor-panel-head">
<div><h2>{title}</h2>{subtitle && <p>{subtitle}</p>}</div>
{action}
</header>
{children}
</section>
);
}
export default function Dashboard() {
const [summary, setSummary] = useState(null);
const [charts, setCharts] = useState(null);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [projectFilter, setProjectFilter] = useState('all');
useEffect(() => {
Promise.all([
api.get('/dashboard/summary'),
api.get('/dashboard/charts'),
]).then(([s, c]) => {
setSummary(s.data.data);
setCharts(c.data.data);
}).catch(() => {}).finally(() => setLoading(false));
const loadMonitoring = useCallback(async (silent = false) => {
if (silent) setRefreshing(true); else setLoading(true);
setError('');
try {
const response = await api.get('/dashboard/monitoring');
setData(response.data.data);
} catch {
setError('دریافت اطلاعات مانیتورینگ ناموفق بود.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
if (loading) return <div className="page-container"><KPISkeleton /></div>;
useEffect(() => {
loadMonitoring();
const timer = window.setInterval(() => loadMonitoring(true), 120000);
return () => window.clearInterval(timer);
}, [loadMonitoring]);
const kpis = [
{ label: 'پروژه‌های فعال', value: summary?.activeProjects ?? 0, color: 'var(--primary)', icon: FolderKanban, iconColor: 'var(--primary)' },
{ label: 'تسک‌های باز', value: summary?.openTasks ?? 0, color: 'var(--warning)', icon: ClipboardList, iconColor: 'var(--warning)' },
{ label: 'تسک‌های انجام‌شده', value: summary?.completedTasks ?? 0, color: 'var(--success)', icon: CheckCircle, iconColor: 'var(--success)' },
{ label: 'تسک‌های عقب‌افتاده', value: summary?.delayedTasks ?? 0, color: 'var(--danger)', icon: Clock, iconColor: 'var(--danger)' },
{ label: 'اعضای تیم', value: summary?.teamMembers ?? 0, color: 'var(--info)', icon: Users, iconColor: 'var(--info)' },
{ label: 'جلسات امروز', value: summary?.todayMeetings ?? 0, color: 'var(--secondary)', icon: Calendar, iconColor: 'var(--secondary)' },
];
const projects = useMemo(() => {
if (projectFilter === 'all') return data?.projects || [];
return (data?.projects || []).filter((project) => String(project.id) === projectFilter);
}, [data, projectFilter]);
const quickActions = [
{ label: 'پروژه جدید', link: '/projects', icon: Plus },
{ label: 'تسک جدید', link: '/tasks', icon: FileText },
{ label: 'ثبت جلسه', link: '/meetings', icon: Calendar },
{ label: 'افزودن عضو', link: '/team', icon: UserPlus },
];
const monthlyTasks = charts?.monthlyTasks || charts?.monthly_tasks || [];
if (loading) {
return <div className="page-container monitor-page"><div className="monitor-loading">{Array.from({ length: 10 }).map((_, index) => <div key={index} className="skeleton" />)}</div></div>;
}
if (error && !data) {
return (
<div className="page-container monitor-page">
<div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={() => loadMonitoring()}>تلاش دوباره</button></div>
</div>
);
}
const summary = data?.summary || {};
return (
<div className="page-container">
<div className="page-header">
<div style={{ display: 'flex', gap: '0.5rem' }}>
{quickActions.map((a, i) => (
<Link key={i} to={a.link} className="btn btn-outline btn-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.375rem' }}><a.icon size={16} /> {a.label}</Link>
))}
</div>
</div>
<div className="grid grid-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginBottom: '1.5rem' }}>
{kpis.map((kpi, i) => (
<div key={i} className="card" style={{ padding: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div className="page-container monitor-page">
<div className="monitor-toolbar">
<div>
<div style={{ fontSize: '0.8125rem', color: 'var(--gray-500)', marginBottom: '0.25rem' }}>{kpi.label}</div>
<div style={{ fontSize: '1.75rem', fontWeight: 700, color: kpi.color }}>{kpi.value}</div>
<div className="monitor-live"><i /> مانیتورینگ عملیاتی</div>
<p>وضعیت پروژهها، Sprintها و جریان کار در یک نمای قابل اقدام</p>
</div>
<kpi.icon size={28} strokeWidth={1.5} color={kpi.iconColor} />
</div>
</div>
))}
</div>
<div className="grid grid-2" style={{ marginBottom: '1.5rem' }}>
{summary?.activeSprint && (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<div>
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Sprint فعال من</h3>
<div style={{ fontSize: '0.875rem', color: 'var(--gray-600)', marginTop: 4 }}>
{summary.activeSprint.title} · {summary.activeSprint.project}
</div>
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--gray-500)' }}>
{summary.activeSprint.remaining_days} روز باقیمانده · پایان {formatJalaliDate(summary.activeSprint.end_date)}
</div>
</div>
<div className="progress-bar" style={{ marginBottom: '0.75rem' }}>
<div className="progress-fill" style={{ width: `${summary.activeSprint.progress || 0}%` }} />
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{(summary.activeSprint.my_tasks || []).slice(0, 4).map((task) => (
<span key={task.id} className="badge badge-gray">{task.title}</span>
))}
{(summary.activeSprint.my_tasks || []).length === 0 && <span style={{ fontSize: '0.8125rem', color: 'var(--gray-400)' }}>تسکی برای شما در این Sprint ثبت نشده است</span>}
</div>
</div>
)}
<div className="card">
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>پیشرفت پروژهها</h3>
{summary?.projectProgresses?.map((p, i) => (
<div key={i} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.375rem' }}>
<span>{p.title}</span>
<span style={{ fontWeight: 600 }}>{p.progress}%</span>
</div>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${p.progress}%` }} />
</div>
</div>
))}
{(!summary?.projectProgresses || summary.projectProgresses.length === 0) && (
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>پروژهای یافت نشد</div>
)}
</div>
<div className="card">
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>نزدیکترین ددلاینها</h3>
{summary?.upcomingDeadlines?.map((d, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.5rem 0', borderBottom: '1px solid var(--gray-100)' }}>
<div>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{d.title}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--gray-500)' }}>{d.project}</div>
</div>
<span style={{ fontSize: '0.8125rem', color: d.days_left <= 2 ? 'var(--danger)' : 'var(--warning)', fontWeight: 500 }}>
{d.days_left >= 0 ? `${d.days_left} روز باقی` : 'گذشته'}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--gray-500)' }}>{formatJalaliDate(d.due_date)}</span>
</div>
))}
{(!summary?.upcomingDeadlines || summary.upcomingDeadlines.length === 0) && (
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>ددلاینی وجود ندارد</div>
)}
<div className="monitor-toolbar-actions">
<select className="form-select" value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)} aria-label="فیلتر پروژه">
<option value="all">همه پروژهها</option>
{(data?.projects || []).map((project) => <option key={project.id} value={project.id}>{project.title}</option>)}
</select>
<span className="monitor-updated">آخرین بروزرسانی: {formatJalaliDateTime(data?.generated_at)}</span>
<button type="button" className="btn btn-outline" onClick={() => loadMonitoring(true)} disabled={refreshing}>
<RefreshCw size={16} className={refreshing ? 'spin' : ''} /> بروزرسانی
</button>
</div>
</div>
<div className="grid grid-2">
<div className="card">
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>آخرین فعالیتها</h3>
{summary?.recentActivities?.map((a, i) => (
<div key={i} style={{ display: 'flex', gap: '0.75rem', padding: '0.625rem 0', borderBottom: '1px solid var(--gray-50)' }}>
<div className="avatar avatar-sm">{a.user?.[0] || '?'}</div>
<div>
<div style={{ fontSize: '0.875rem' }}>{a.description}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--gray-400)' }}>{a.time_ago || ''}</div>
</div>
</div>
))}
{(!summary?.recentActivities || summary.recentActivities.length === 0) && (
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>فعالیتی وجود ندارد</div>
)}
<div className="monitor-metrics">
<MetricCard label="پروژه فعال" value={summary.active_projects} detail={`${summary.projects_at_risk || 0} مورد نیازمند توجه`} icon={FolderKanban} />
<MetricCard label="تسک باز" value={summary.open_tasks} detail={`${summary.overdue_tasks || 0} عقب‌افتاده`} icon={Activity} tone="info" />
<MetricCard label="تسک مسدود" value={summary.blocked_tasks} detail="نیازمند رفع مانع" icon={Ban} tone="danger" />
<MetricCard label="Sprint فعال" value={summary.active_sprints} detail="در حال اجرا" icon={TimerReset} tone="success" />
<MetricCard label="جلسه امروز" value={summary.meetings_today} detail="برنامه امروز تیم" icon={CalendarDays} tone="secondary" />
<MetricCard label="اقدام معوق" value={summary.overdue_action_items} detail={`${summary.open_blockers || 0} مانع باز`} icon={Clock3} tone="warning" />
</div>
<div className="card">
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>آمار ماهانه تسکها</h3>
{monthlyTasks.length ? (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={monthlyTasks}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Bar dataKey="created" fill="#343a40" name="ایجاد شده" />
<Bar dataKey="completed" fill="#22c55e" name="تکمیل شده" />
</BarChart>
<div className="monitor-primary-grid">
<Panel title="روند جریان کار" subtitle="تسک‌های ایجادشده در برابر تکمیل‌شده در شش ماه اخیر" className="monitor-trend">
{(data?.trends || []).length === 0 ? <div className="monitor-empty">داده کافی برای نمایش روند وجود ندارد.</div> : (
<>
<div className="monitor-chart" role="img" aria-label="نمودار روند ایجاد و تکمیل تسک‌ها">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.trends}>
<defs>
<linearGradient id="createdGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.28} /><stop offset="95%" stopColor="var(--info)" stopOpacity={0} /></linearGradient>
<linearGradient id="completedGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--success)" stopOpacity={0.22} /><stop offset="95%" stopColor="var(--success)" stopOpacity={0} /></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--gray-200)" vertical={false} />
<XAxis dataKey="month" tick={{ fill: 'var(--gray-500)', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: 'var(--gray-500)', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip contentStyle={{ background: 'var(--surface-elevated)', border: '1px solid var(--gray-200)', borderRadius: 10 }} />
<Area type="monotone" dataKey="created" name="ایجادشده" stroke="var(--info)" strokeWidth={2} fill="url(#createdGradient)" />
<Area type="monotone" dataKey="completed" name="تکمیل‌شده" stroke="var(--success)" strokeWidth={2} fill="url(#completedGradient)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>دادهای موجود نیست</div>
)}
</div>
<table className="sr-only-table">
<caption>دادههای روند جریان کار</caption>
<thead><tr><th>ماه</th><th>ایجادشده</th><th>تکمیلشده</th></tr></thead>
<tbody>{data.trends.map((row) => <tr key={row.month}><td>{row.month}</td><td>{row.created}</td><td>{row.completed}</td></tr>)}</tbody>
</table>
</>
)}
</Panel>
<Panel title="نیازمند توجه" subtitle="مواردی که اقدام سریع می‌خواهند" className="monitor-attention">
{(data?.attention || []).length === 0 ? (
<div className="monitor-empty success"><CheckCircle2 size={24} /> مورد بحرانی فعالی وجود ندارد.</div>
) : (
<div className="attention-list">
{data.attention.map((item) => (
<Link key={item.id} to={item.target_url || '#'} className={`attention-item ${item.type}`}>
<span><AlertTriangle size={17} /></span>
<div><strong>{item.title}</strong><small>{item.context || 'بدون پروژه'} · {formatJalaliDate(item.date)}</small></div>
</Link>
))}
</div>
)}
</Panel>
</div>
<Panel title="سلامت پروژه‌ها" subtitle="ترکیب ریسک، پیشرفت، تسک‌های معوق و موانع">
{projects.length === 0 ? <div className="monitor-empty">پروژهای برای نمایش وجود ندارد.</div> : (
<div className="project-health-table">
<div className="project-health-head"><span>پروژه</span><span>سلامت</span><span>پیشرفت</span><span>معوق</span><span>مسدود</span></div>
{projects.map((project) => (
<Link to={`/projects/${project.id}`} className="project-health-row" key={project.id}>
<div><strong>{project.title}</strong><small>{project.completed_tasks} از {project.total_tasks} تسک تکمیل شده</small></div>
<span className={`health-chip ${healthClass[project.health]}`}>{healthLabels[project.health]}</span>
<div className="health-progress"><span><i style={{ width: `${project.progress || 0}%` }} /></span><strong>{project.progress || 0}%</strong></div>
<strong className={project.overdue_tasks ? 'text-danger' : ''}>{project.overdue_tasks}</strong>
<strong className={project.blocked_tasks ? 'text-danger' : ''}>{project.blocked_tasks}</strong>
</Link>
))}
</div>
)}
</Panel>
<div className="monitor-secondary-grid">
<Panel title="سلامت Sprintها" subtitle="پیشرفت واقعی در برابر پیشرفت مورد انتظار">
<div className="sprint-health-list">
{(data?.sprints || []).length === 0 ? <div className="monitor-empty">Sprint فعالی وجود ندارد.</div> : data.sprints.map((sprint) => (
<Link to={`/sprints/${sprint.id}/workspace`} key={sprint.id} className="sprint-health-card">
<div><strong>{sprint.title}</strong><small>{sprint.project}</small></div>
<span className={`health-chip ${healthClass[sprint.health]}`}>{healthLabels[sprint.health]}</span>
<div className="sprint-health-bars">
<label>واقعی <span><i style={{ width: `${sprint.progress}%` }} /></span><b>{sprint.progress}%</b></label>
<label>انتظار <span><i className="expected" style={{ width: `${sprint.expected_progress}%` }} /></span><b>{sprint.expected_progress}%</b></label>
</div>
<small>{sprint.remaining_days} روز باقیمانده · {sprint.blocked_tasks} مسدود</small>
</Link>
))}
</div>
</Panel>
<Panel title="بار کاری تیم" subtitle="تعداد تسک‌های باز و عقب‌افتاده">
<div className="workload-list">
{(data?.workload || []).length === 0 ? <div className="monitor-empty">اطلاعات بار کاری موجود نیست.</div> : data.workload.map((member) => (
<div className="workload-row" key={member.id}>
<span className="avatar avatar-sm">{member.name?.[0]}</span>
<div><strong>{member.name}</strong><span><i className={member.load} style={{ width: `${Math.min(100, member.open_tasks * 8)}%` }} /></span></div>
<b>{member.open_tasks}</b>
{member.overdue_tasks > 0 && <small>{member.overdue_tasks} معوق</small>}
</div>
))}
</div>
</Panel>
<Panel title="جلسات پیش‌رو" subtitle="نزدیک‌ترین نشست‌های برنامه‌ریزی‌شده">
<div className="upcoming-meeting-list">
{(data?.upcoming_meetings || []).length === 0 ? <div className="monitor-empty">جلسهای برنامهریزی نشده است.</div> : data.upcoming_meetings.map((meeting) => (
<Link to={meeting.target_url} key={meeting.id}>
<span className="meeting-date"><b>{formatJalaliDate(meeting.date)}</b><small>{meeting.time || 'تمام روز'}</small></span>
<div><strong>{meeting.title}</strong><small>{meeting.sprint || meeting.project || 'جلسه عمومی'}</small></div>
</Link>
))}
</div>
</Panel>
</div>
</div>
);

مشاهده پرونده

@ -40,6 +40,8 @@ export default function Kanban() {
const [noteForm, setNoteForm] = useState({ body: '', mentioned_user_id: '' });
const [blockerForm, setBlockerForm] = useState({ blocker_type: '', blocker_note: '' });
const [savingDetail, setSavingDetail] = useState(false);
const [detailStep, setDetailStep] = useState(0);
const [commentIndex, setCommentIndex] = useState(0);
const fetchBoard = () => {
setLoading(true);
@ -98,6 +100,8 @@ export default function Kanban() {
const openTaskDetail = async (task) => {
setSelectedTask(task);
setDetailStep(0);
setCommentIndex(0);
setBlockerForm({ blocker_type: task.blocker_type || '', blocker_note: task.blocker_note || '' });
setNoteForm({ body: '', mentioned_user_id: '' });
setDetailLoading(true);
@ -250,9 +254,28 @@ export default function Kanban() {
)}
{selectedTask && (
<Modal title="جزئیات تسک" onClose={() => setSelectedTask(null)} size="xl">
<Modal
title="جزئیات تسک"
onClose={() => setSelectedTask(null)}
size="xl"
footer={(
<div className="modal-step-footer">
<button type="button" className="btn btn-secondary" disabled={detailStep === 0} onClick={() => setDetailStep((step) => Math.max(0, step - 1))}>مرحله قبل</button>
<span>مرحله {detailStep + 1} از ۳</span>
<button type="button" className="btn btn-primary" disabled={detailStep === 2} onClick={() => setDetailStep((step) => Math.min(2, step + 1))}>مرحله بعد</button>
</div>
)}
>
{detailLoading ? <div className="skeleton" style={{ height: 280, borderRadius: 'var(--radius)' }} /> : (
<div className="task-detail-modal">
<div className="modal-wizard-steps" role="tablist" aria-label="بخش‌های جزئیات تسک">
<button type="button" role="tab" aria-selected={detailStep === 0} className={detailStep === 0 ? 'active' : detailStep > 0 ? 'complete' : ''} onClick={() => setDetailStep(0)}><span>۱</span>نمای کلی</button>
<button type="button" role="tab" aria-selected={detailStep === 1} className={detailStep === 1 ? 'active' : detailStep > 1 ? 'complete' : ''} onClick={() => setDetailStep(1)}><span>۲</span>بلاکر</button>
<button type="button" role="tab" aria-selected={detailStep === 2} className={detailStep === 2 ? 'active' : ''} onClick={() => setDetailStep(2)}><span>۳</span>یادداشتها</button>
</div>
{detailStep === 0 && (
<div className="task-detail-wizard-panel">
<div className="task-detail-head">
<div>
<h2>{selectedTask.title}</h2>
@ -263,7 +286,6 @@ export default function Kanban() {
<PriorityBadge priority={selectedTask.priority} />
</div>
</div>
<div className="task-detail-grid compact">
<Info label="پروژه" value={selectedTask.project?.title || 'بدون پروژه'} />
<Info label="مسئول" value={selectedTask.assignee?.name || 'بدون مسئول'} />
@ -272,9 +294,11 @@ export default function Kanban() {
<Info label="ددلاین" value={selectedTask.due_date ? formatJalaliDate(selectedTask.due_date) : 'بدون ددلاین'} />
<Info label="تخمین" value={selectedTask.estimated_time ? `${selectedTask.estimated_time} ساعت` : '—'} />
</div>
</div>
)}
<div className="task-detail-two-col">
<div className="card task-detail-section">
{detailStep === 1 && (
<div className="card task-detail-section task-detail-wizard-panel">
<h3>بلاکر</h3>
<div className="form-group">
<label className="form-label">نوع بلاکر</label>
@ -288,7 +312,10 @@ export default function Kanban() {
</div>
<button className="btn btn-primary btn-sm" onClick={saveBlocker} disabled={savingDetail}><Save size={14} /> ذخیره بلاکر</button>
</div>
)}
{detailStep === 2 && (
<div className="task-notes-wizard task-detail-wizard-panel">
<div className="card task-detail-section">
<h3>نوت و منشن</h3>
<form onSubmit={addNote}>
@ -306,11 +333,11 @@ export default function Kanban() {
<button className="btn btn-primary" disabled={savingDetail || !noteForm.body.trim()}><MessageSquare size={16} /> ثبت نوت</button>
</form>
</div>
</div>
<div className="task-notes">
<h3>نوتهای اخیر</h3>
{comments.length === 0 ? <div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>هنوز نوتی ثبت نشده است.</div> : comments.map((comment) => (
{comments.length === 0 ? <div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>هنوز نوتی ثبت نشده است.</div> : (
<>
{comments.slice(commentIndex, commentIndex + 1).map((comment) => (
<div className="task-note" key={comment.id}>
<div className="avatar avatar-sm">{comment.user?.name?.[0] || '?'}</div>
<div>
@ -320,7 +347,16 @@ export default function Kanban() {
</div>
</div>
))}
<div className="schedule-wizard-navigation">
<button type="button" className="btn btn-secondary btn-sm" disabled={commentIndex === 0} onClick={() => setCommentIndex((index) => Math.max(0, index - 1))}>قبلی</button>
<span>{commentIndex + 1} از {comments.length}</span>
<button type="button" className="btn btn-secondary btn-sm" disabled={commentIndex >= comments.length - 1} onClick={() => setCommentIndex((index) => Math.min(comments.length - 1, index + 1))}>بعدی</button>
</div>
</>
)}
</div>
</div>
)}
</div>
)}
</Modal>

مشاهده پرونده

@ -2,12 +2,13 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import toast from 'react-hot-toast';
import { Moon, Sun } from 'lucide-react';
import { Eye, EyeOff, Moon, Sun } from 'lucide-react';
import { defaultRouteForAppMode } from '../utils/appMode';
export default function Login() {
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
const { login } = useAuth();
@ -43,18 +44,38 @@ export default function Login() {
</button>
<div className="card" style={{ maxWidth: 420, width: '100%', padding: '2.5rem' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: '1.75rem', fontWeight: 700, marginBottom: '1rem' }}>م</div>
<img src="/brand/project-mark.png" alt="" style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 18, marginBottom: '1rem', boxShadow: '0 12px 30px rgba(14,165,233,.24)' }} />
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--gray-900)' }}>ورود به سامانه مدیریت پروژه</h1>
<p style={{ fontSize: '0.875rem', color: 'var(--gray-500)', marginTop: '0.25rem' }}>برای مدیریت پروژههای خود وارد شوید</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">نام کاربری یا ایمیل</label>
<input className="form-input" type="text" value={identifier} onChange={e => setIdentifier(e.target.value)} placeholder="نام کاربری یا ایمیل" dir="auto" autoComplete="username" />
<label className="form-label" htmlFor="login-identifier">نام کاربری یا ایمیل</label>
<input id="login-identifier" className="form-input" type="text" value={identifier} onChange={e => setIdentifier(e.target.value)} placeholder="نام کاربری یا ایمیل" dir="auto" autoComplete="username" />
</div>
<div className="form-group">
<label className="form-label">رمز عبور</label>
<input className="form-input" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••••" />
<label className="form-label" htmlFor="login-password">رمز عبور</label>
<div className="password-input-wrap">
<input
id="login-password"
className="form-input"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
/>
<button
type="button"
className="password-visibility-toggle"
onClick={() => setShowPassword((visible) => !visible)}
aria-label={showPassword ? 'مخفی کردن رمز عبور' : 'نمایش رمز عبور'}
aria-pressed={showPassword}
title={showPassword ? 'مخفی کردن رمز عبور' : 'نمایش رمز عبور'}
>
{showPassword ? <EyeOff size={19} /> : <Eye size={19} />}
</button>
</div>
</div>
<button type="submit" className="btn btn-primary btn-lg" style={{ width: '100%', justifyContent: 'center' }} disabled={loading}>
{loading ? 'در حال ورود...' : 'ورود'}

مشاهده پرونده

@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import api from '../services/api';
import StatusBadge from '../components/StatusBadge';
import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog';
import EmptyState from '../components/EmptyState';
@ -9,6 +8,7 @@ import PersianDateInput from '../components/PersianDateInput';
import toast from 'react-hot-toast';
import { Calendar, Edit3, Trash2, X, ArrowLeft } from 'lucide-react';
import { formatJalaliDate, todayIsoDate } from '../utils/date';
import { Link } from 'react-router-dom';
export default function MeetingList() {
const [meetings, setMeetings] = useState([]);
@ -225,6 +225,9 @@ export default function MeetingList() {
<button className="modal-close" onClick={() => setSelected(null)}>&times;</button>
</div>
<div className="drawer-body">
<Link className="btn btn-primary" style={{ width: '100%', marginBottom: '1rem' }} to={`/meetings/${selected.id}/workspace`} onClick={() => setSelected(null)}>
ورود به فضای کاری جلسه <ArrowLeft size={16} />
</Link>
<div className="card" style={{ padding: '1rem', marginBottom: '1rem' }}>
<div style={{ display: 'grid', gap: '0.5rem', fontSize: '0.875rem' }}>
<div><strong>پروژه:</strong> {selected.project?.title || '—'}</div>

مشاهده پرونده

@ -0,0 +1,193 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowRight, Ban, CheckCircle2, ClipboardList, FileText, Play, Plus, Save, ShieldAlert, Users } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../services/api';
import Modal from '../components/Modal';
import { formatJalaliDate } from '../utils/date';
const stages = [['before', 'قبل از جلسه'], ['during', 'حین جلسه'], ['after', 'بعد از جلسه']];
export default function MeetingWorkspace() {
const { id } = useParams();
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stage, setStage] = useState('before');
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ objective: '', agenda: '', notes: '', summary: '' });
const [quickForm, setQuickForm] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get(`/meetings/${id}/workspace`);
setMeeting(data.data);
setForm({ objective: data.data.objective || '', agenda: data.data.agenda || '', notes: data.data.notes || '', summary: data.data.summary || '' });
setStage(data.data.status === 'completed' ? 'after' : data.data.status === 'in_progress' ? 'during' : 'before');
setError('');
} catch {
setError('دریافت فضای کاری جلسه ناموفق بود.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { load(); }, [load]);
const saveNotes = async () => {
setSaving(true);
try {
await api.patch(`/meetings/${id}`, form);
toast.success('اطلاعات جلسه ذخیره شد');
await load();
} catch {
toast.error('ذخیره جلسه ناموفق بود');
} finally {
setSaving(false);
}
};
const startMeeting = async () => {
try {
await api.post(`/meetings/${id}/start`);
toast.success('جلسه شروع شد');
await load();
} catch (err) {
toast.error(err.response?.data?.message || 'شروع جلسه ناموفق بود');
}
};
const completeMeeting = async () => {
try {
await api.post(`/meetings/${id}/complete`, { summary: form.summary });
toast.success('جلسه تکمیل شد');
await load();
} catch (err) {
toast.error(err.response?.data?.message || 'تکمیل جلسه ناموفق بود');
}
};
if (loading) return <div className="page-container workspace-page"><div className="workspace-loading">{Array.from({ length: 7 }).map((_, index) => <div className="skeleton" key={index} />)}</div></div>;
if (error || !meeting) return <div className="page-container workspace-page"><div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={load}>تلاش دوباره</button></div></div>;
return (
<div className="page-container workspace-page meeting-workspace">
<header className="workspace-hero">
<div className="workspace-breadcrumb"><Link to="/meetings"><ArrowRight size={16} /> جلسات</Link><span>/</span><span>{meeting.title}</span></div>
<div className="workspace-title-row"><h1>{meeting.title}</h1><span className={`meeting-status status-${meeting.status}`}>{meeting.status}</span></div>
<p>{meeting.objective || 'هدف جلسه هنوز ثبت نشده است.'}</p>
<div className="workspace-context"><span>{formatJalaliDate(meeting.date)}</span><span>{meeting.start_time?.slice(0, 5) || 'بدون ساعت'} تا {meeting.end_time?.slice(0, 5) || 'نامشخص'}</span><span>{meeting.project?.title || 'جلسه عمومی'}</span>{meeting.sprint?.title && <Link to={`/sprints/${meeting.sprint_id}/workspace`}>{meeting.sprint.title}</Link>}</div>
</header>
<nav className="meeting-stage-tabs">
{stages.map(([key, label]) => <button type="button" key={key} className={stage === key ? 'active' : ''} onClick={() => setStage(key)}>{label}</button>)}
</nav>
<div className="meeting-workspace-layout">
<main>
{stage === 'before' && (
<div className="meeting-stage-content">
<section className="workspace-card">
<header><div><ClipboardList size={18} /><h2>آمادهسازی جلسه</h2></div></header>
<label className="form-group"><span className="form-label">هدف جلسه</span><textarea className="form-textarea" value={form.objective} onChange={(event) => setForm({ ...form, objective: event.target.value })} /></label>
<label className="form-group"><span className="form-label">دستور جلسه</span><textarea className="form-textarea meeting-agenda-input" value={form.agenda} onChange={(event) => setForm({ ...form, agenda: event.target.value })} /></label>
</section>
<section className="workspace-card"><header><div><Users size={18} /><h2>شرکتکنندگان</h2></div><span>{meeting.participants?.length || 0} نفر</span></header>
<div className="participant-grid">{(meeting.participants || []).map((person) => <div key={person.id}><span className="avatar avatar-sm">{person.name?.[0]}</span><strong>{person.name}</strong></div>)}</div>
{(meeting.participants || []).length === 0 && <div className="workspace-empty">شرکتکنندهای اضافه نشده است.</div>}
</section>
</div>
)}
{stage === 'during' && (
<div className="meeting-stage-content">
<section className="workspace-card live-notes-card">
<header><div><FileText size={18} /><h2>یادداشت زنده</h2></div><span className="live-indicator"><i /> جلسه در جریان</span></header>
<textarea className="form-textarea" value={form.notes} onChange={(event) => setForm({ ...form, notes: event.target.value })} placeholder="نکات، گفتگوها و جمع‌بندی‌های مهم را ثبت کنید..." />
</section>
<div className="meeting-capture-grid">
<button type="button" onClick={() => setQuickForm({ type: 'decision', title: '', description: '' })}><CheckCircle2 size={22} /><strong>ثبت تصمیم</strong><span>نتیجه تصمیمگیری را ثبت کنید</span></button>
<button type="button" onClick={() => setQuickForm({ type: 'action', title: '', owner_id: '', due_date: '', priority: 'medium' })}><Plus size={22} /><strong>ثبت اقدام</strong><span>Owner و سررسید مشخص کنید</span></button>
<button type="button" onClick={() => setQuickForm({ type: 'blocker', title: '', description: '', severity: 'medium' })}><Ban size={22} /><strong>ثبت مانع</strong><span>مانع را به Sprint بازگردانید</span></button>
</div>
</div>
)}
{stage === 'after' && (
<div className="meeting-stage-content">
<section className="workspace-card">
<header><div><FileText size={18} /><h2>خلاصه و صورتجلسه</h2></div></header>
<textarea className="form-textarea meeting-summary-input" value={form.summary} onChange={(event) => setForm({ ...form, summary: event.target.value })} placeholder="خلاصه ساختاریافته جلسه..." />
</section>
<ResultSection title="تصمیم‌ها" items={meeting.structured_decisions} />
<ResultSection title="اقدام‌ها" items={meeting.structured_action_items} />
<ResultSection title="موانع" items={meeting.blockers} />
</div>
)}
</main>
<aside className="meeting-context-panel">
<section className="workspace-card"><h3>اطلاعات جلسه</h3>
<dl><div><dt>نوع</dt><dd>{meeting.type?.name || meeting.meeting_type}</dd></div><div><dt>Owner</dt><dd>{meeting.owner?.name || meeting.creator?.name || '—'}</dd></div><div><dt>Facilitator</dt><dd>{meeting.facilitator?.name || '—'}</dd></div><div><dt>مکان</dt><dd>{meeting.location || '—'}</dd></div></dl>
</section>
<section className="workspace-card"><h3>خروجی فعلی</h3><dl><div><dt>تصمیم</dt><dd>{meeting.structured_decisions?.length || 0}</dd></div><div><dt>اقدام</dt><dd>{meeting.structured_action_items?.length || meeting.action_items?.length || 0}</dd></div><div><dt>مانع</dt><dd>{meeting.blockers?.length || 0}</dd></div><div><dt>پیوست</dt><dd>{meeting.files?.length || 0}</dd></div></dl></section>
</aside>
</div>
<div className="meeting-sticky-controls">
<button type="button" className="btn btn-outline" onClick={saveNotes} disabled={saving}><Save size={16} /> {saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
{meeting.status !== 'in_progress' && meeting.status !== 'completed' && <button type="button" className="btn btn-primary" onClick={startMeeting}><Play size={16} /> شروع جلسه</button>}
{meeting.status === 'in_progress' && <button type="button" className="btn btn-success" onClick={completeMeeting}><CheckCircle2 size={16} /> پایان جلسه</button>}
</div>
{quickForm && <QuickCapture meetingId={meeting.id} form={quickForm} setForm={setQuickForm} onClose={() => setQuickForm(null)} onSaved={load} />}
</div>
);
}
function ResultSection({ title, items = [] }) {
return <section className="workspace-card"><header><div><CheckCircle2 size={18} /><h2>{title}</h2></div><span>{items?.length || 0}</span></header>{!items?.length ? <div className="workspace-empty">موردی ثبت نشده است.</div> : items.map((item) => <div className="workspace-data-row" key={item.id}><div><strong>{item.title}</strong><small>{item.description || item.status}</small></div><span className="badge badge-primary">{item.status || item.severity}</span></div>)}</section>;
}
function QuickCapture({ meetingId, form, setForm, onClose, onSaved }) {
const [users, setUsers] = useState([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (form.type === 'action') api.get('/users', { params: { per_page: 100 } }).then(({ data }) => setUsers(data.data || [])).catch(() => {});
}, [form.type]);
const config = useMemo(() => ({
decision: { title: 'ثبت تصمیم', endpoint: 'decisions' },
action: { title: 'ثبت اقدام', endpoint: 'structured-action-items' },
blocker: { title: 'ثبت مانع', endpoint: 'blockers' },
})[form.type], [form.type]);
const save = async () => {
if (!form.title?.trim()) { toast.error('عنوان الزامی است'); return; }
if (form.type === 'action' && (!form.owner_id || !form.due_date)) { toast.error('Owner و سررسید الزامی است'); return; }
setSaving(true);
try {
const payload = { ...form }; delete payload.type;
await api.post(`/meetings/${meetingId}/${config.endpoint}`, payload);
toast.success('ثبت شد');
onClose();
await onSaved();
} catch (err) {
toast.error(err.response?.data?.message || 'ثبت مورد ناموفق بود');
} finally {
setSaving(false);
}
};
return (
<Modal title={config.title} onClose={onClose} footer={<><button className="btn btn-secondary" onClick={onClose}>انصراف</button><button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'در حال ثبت...' : 'ثبت'}</button></>}>
<div className="compact-modal-form">
<label className="form-group"><span className="form-label">عنوان</span><input className="form-input" value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></label>
{form.type !== 'action' && <label className="form-group"><span className="form-label">توضیحات</span><textarea className="form-textarea" value={form.description || ''} onChange={(event) => setForm({ ...form, description: event.target.value })} /></label>}
{form.type === 'action' && <><label className="form-group"><span className="form-label">Owner</span><select className="form-select" value={form.owner_id} onChange={(event) => setForm({ ...form, owner_id: event.target.value })}><option value="">انتخاب</option>{users.map((user) => <option key={user.id} value={user.id}>{user.name}</option>)}</select></label><label className="form-group"><span className="form-label">سررسید</span><input type="date" className="form-input" value={form.due_date} onChange={(event) => setForm({ ...form, due_date: event.target.value })} /></label></>}
{form.type === 'blocker' && <label className="form-group"><span className="form-label">شدت</span><select className="form-select" value={form.severity} onChange={(event) => setForm({ ...form, severity: event.target.value })}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="critical">بحرانی</option></select></label>}
</div>
</Modal>
);
}

مشاهده پرونده

@ -2,9 +2,9 @@ import { useState, useEffect } from 'react';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useAuth } from '../context/AuthContext';
import { Camera } from 'lucide-react';
import { BriefcaseBusiness, Camera, Check, ChevronLeft, ChevronRight, ShieldCheck, UserRound } from 'lucide-react';
export default function Profile() {
export default function Profile({ embedded = false }) {
const { user, refreshUser } = useAuth();
const [profile, setProfile] = useState({ name: '', email: '', phone: '', job_title: '', department: '', bio: '', avatar_url: '' });
const [passwords, setPasswords] = useState({ current_password: '', new_password: '', new_password_confirmation: '' });
@ -13,6 +13,7 @@ export default function Profile() {
const [avatarFile, setAvatarFile] = useState(null);
const [avatarPreview, setAvatarPreview] = useState('');
const [savingAvatar, setSavingAvatar] = useState(false);
const [wizardStep, setWizardStep] = useState(0);
useEffect(() => {
if (user) {
@ -85,13 +86,14 @@ export default function Profile() {
e.target.value = '';
};
return (
<div className="page-container" style={{ maxWidth: 720, margin: '0 auto' }}>
<div className="page-header">
</div>
const wizardSteps = [
{ label: 'هویت', icon: UserRound },
{ label: 'اطلاعات کاری', icon: BriefcaseBusiness },
{ label: 'امنیت', icon: ShieldCheck },
];
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem', marginBottom: '1.5rem' }}>
const identityHeader = (
<div className="profile-identity">
<label className={`profile-avatar-wrap ${savingAvatar ? 'uploading' : ''}`} title={savingAvatar ? 'در حال آپلود عکس' : 'تغییر عکس پروفایل'}>
{avatarPreview ? (
<img className="profile-avatar-img" src={avatarPreview} alt={user?.name || 'avatar'} />
@ -102,15 +104,96 @@ export default function Profile() {
)}
<input className="profile-avatar-input" type="file" accept="image/png,image/jpeg,image/webp" onChange={handleAvatarChange} disabled={savingAvatar} />
</label>
<div style={{ flex: 1 }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: '0.25rem' }}>{user?.name}</h2>
<div style={{ fontSize: '0.875rem', color: 'var(--gray-500)' }}>{user?.email}</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--gray-400)', marginTop: '0.25rem' }}>
{user?.job_title} {user?.job_title && user?.department ? '|' : ''} {user?.department}
<div>
<h2>{user?.name}</h2>
<div>{user?.email}</div>
<small>{user?.job_title} {user?.job_title && user?.department ? '|' : ''} {user?.department}</small>
</div>
</div>
);
if (embedded) {
return (
<div className="profile-wizard">
<div className="modal-wizard-steps" role="tablist" aria-label="مراحل پروفایل">
{wizardSteps.map((step, index) => {
const Icon = step.icon;
return (
<button
key={step.label}
type="button"
role="tab"
aria-selected={wizardStep === index}
className={wizardStep === index ? 'active' : wizardStep > index ? 'complete' : ''}
onClick={() => setWizardStep(index)}
>
<span>{wizardStep > index ? <Check size={16} /> : <Icon size={16} />}</span>
{step.label}
</button>
);
})}
</div>
<div className="profile-wizard-panel" role="tabpanel">
{wizardStep === 0 && (
<>
{identityHeader}
<form onSubmit={handleSaveProfile} className="profile-step-form">
<div className="grid grid-2">
<div className="form-group"><label className="form-label" htmlFor="profile-name">نام</label><input id="profile-name" className="form-input" value={profile.name} onChange={e => setProfile({...profile, name: e.target.value})} /></div>
<div className="form-group"><label className="form-label" htmlFor="profile-email">ایمیل</label><input id="profile-email" className="form-input" type="email" value={profile.email} onChange={e => setProfile({...profile, email: e.target.value})} /></div>
</div>
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره هویت'}</button>
</form>
</>
)}
{wizardStep === 1 && (
<form onSubmit={handleSaveProfile} className="profile-step-form">
<div className="grid grid-3">
<div className="form-group"><label className="form-label" htmlFor="profile-phone">شماره تماس</label><input id="profile-phone" className="form-input" value={profile.phone} onChange={e => setProfile({...profile, phone: e.target.value})} /></div>
<div className="form-group"><label className="form-label" htmlFor="profile-job">سمت</label><input id="profile-job" className="form-input" value={profile.job_title} onChange={e => setProfile({...profile, job_title: e.target.value})} /></div>
<div className="form-group"><label className="form-label" htmlFor="profile-department">دپارتمان</label><input id="profile-department" className="form-input" value={profile.department} onChange={e => setProfile({...profile, department: e.target.value})} /></div>
</div>
<div className="form-group"><label className="form-label" htmlFor="profile-bio">بیوگرافی</label><textarea id="profile-bio" className="form-textarea" value={profile.bio} onChange={e => setProfile({...profile, bio: e.target.value})} /></div>
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره اطلاعات کاری'}</button>
</form>
)}
{wizardStep === 2 && (
<form onSubmit={handleChangePassword} className="profile-step-form">
<div className="grid grid-3">
<div className="form-group"><label className="form-label" htmlFor="current-password">رمز عبور فعلی</label><input id="current-password" className="form-input" type="password" autoComplete="current-password" value={passwords.current_password} onChange={e => setPasswords({...passwords, current_password: e.target.value})} /></div>
<div className="form-group"><label className="form-label" htmlFor="new-password">رمز عبور جدید</label><input id="new-password" className="form-input" type="password" autoComplete="new-password" value={passwords.new_password} onChange={e => setPasswords({...passwords, new_password: e.target.value})} /></div>
<div className="form-group"><label className="form-label" htmlFor="confirm-password">تکرار رمز جدید</label><input id="confirm-password" className="form-input" type="password" autoComplete="new-password" value={passwords.new_password_confirmation} onChange={e => setPasswords({...passwords, new_password_confirmation: e.target.value})} /></div>
</div>
<button type="submit" className="btn btn-primary" disabled={savingPass}>{savingPass ? 'در حال تغییر...' : 'تغییر رمز عبور'}</button>
</form>
)}
</div>
<div className="profile-wizard-navigation">
<button type="button" className="btn btn-secondary" onClick={() => setWizardStep((step) => Math.max(0, step - 1))} disabled={wizardStep === 0}>
<ChevronRight size={17} />
قبلی
</button>
<span>مرحله {wizardStep + 1} از {wizardSteps.length}</span>
<button type="button" className="btn btn-secondary" onClick={() => setWizardStep((step) => Math.min(wizardSteps.length - 1, step + 1))} disabled={wizardStep === wizardSteps.length - 1}>
بعدی
<ChevronLeft size={17} />
</button>
</div>
</div>
);
}
return (
<div className="page-container" style={{ maxWidth: 720, margin: '0 auto' }}>
<div className="page-header" />
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
{identityHeader}
<form onSubmit={handleSaveProfile}>
<div className="grid grid-2">
<div className="form-group"><label className="form-label">نام</label><input className="form-input" value={profile.name} onChange={e => setProfile({...profile, name: e.target.value})} /></div>

مشاهده پرونده

@ -101,6 +101,8 @@ export default function Roles() {
const [editingRole, setEditingRole] = useState(null);
const [roleForm, setRoleForm] = useState(emptyRoleForm);
const [savingRole, setSavingRole] = useState(false);
const [roleWizardStep, setRoleWizardStep] = useState(0);
const [roleModuleIndex, setRoleModuleIndex] = useState(0);
const [deletingRole, setDeletingRole] = useState(null);
const [userRoleDrafts, setUserRoleDrafts] = useState({});
const [savingUserId, setSavingUserId] = useState(null);
@ -153,6 +155,16 @@ export default function Roles() {
}, {});
}, [permissions, permissionSearch]);
const rolePermissionModules = useMemo(() => {
const groups = permissions.reduce((acc, permission) => {
const module = permission.module || permission.name?.split('.')[0] || 'other';
if (!acc[module]) acc[module] = [];
acc[module].push(permission);
return acc;
}, {});
return Object.entries(groups).sort(([moduleA], [moduleB]) => getModuleLabel(moduleA).localeCompare(getModuleLabel(moduleB), 'fa'));
}, [permissions]);
const filteredUsers = useMemo(() => {
const q = userSearch.trim().toLowerCase();
return users.filter((user) => !q || user.name?.toLowerCase().includes(q) || user.email?.toLowerCase().includes(q));
@ -195,6 +207,8 @@ export default function Roles() {
const openCreateRole = () => {
setEditingRole(null);
setRoleForm(emptyRoleForm);
setRoleWizardStep(0);
setRoleModuleIndex(0);
setRoleModal(true);
};
@ -207,6 +221,8 @@ export default function Roles() {
guard_name: role.guard_name || 'web',
permissions: rolePermissionIds(role),
});
setRoleWizardStep(0);
setRoleModuleIndex(0);
setRoleModal(true);
};
@ -436,15 +452,28 @@ export default function Roles() {
{roleModal && (
<Modal title={editingRole ? 'ویرایش نقش' : 'نقش جدید'} size="xl" onClose={() => setRoleModal(false)} footer={
roleWizardStep === 0 ? (
<>
<button className="btn btn-secondary" onClick={() => setRoleModal(false)}>انصراف</button>
<button className="btn btn-primary" onClick={() => setRoleWizardStep(1)}>مرحله بعد: دسترسیها</button>
</>
) : (
<>
<button className="btn btn-secondary" onClick={() => setRoleWizardStep(0)}>مرحله قبل</button>
<button className="btn btn-primary" onClick={saveRole} disabled={savingRole}>
<Save size={16} />
{savingRole ? 'در حال ذخیره...' : 'ذخیره نقش'}
</button>
</>
)
}>
<form className="access-role-form" onSubmit={saveRole}>
<div className="modal-wizard-steps modal-wizard-steps-two" role="tablist" aria-label="مراحل تعریف نقش">
<button type="button" role="tab" aria-selected={roleWizardStep === 0} className={roleWizardStep === 0 ? 'active' : 'complete'} onClick={() => setRoleWizardStep(0)}><span>{roleWizardStep > 0 ? <Check size={16} /> : '۱'}</span>مشخصات نقش</button>
<button type="button" role="tab" aria-selected={roleWizardStep === 1} className={roleWizardStep === 1 ? 'active' : ''} onClick={() => setRoleWizardStep(1)}><span>۲</span>دسترسیها</button>
</div>
{roleWizardStep === 0 ? (
<div className="role-wizard-details">
<div className="grid grid-2">
<Field label="نام سیستمی">
<input className="form-input" value={roleForm.name} onChange={(e) => setRoleForm({ ...roleForm, name: e.target.value })} disabled={!!editingRole} placeholder="نام نقش" />
@ -456,20 +485,17 @@ export default function Roles() {
<Field label="توضیحات">
<textarea className="form-textarea" value={roleForm.description} onChange={(e) => setRoleForm({ ...roleForm, description: e.target.value })} />
</Field>
<div className="access-form-permissions">
</div>
) : (
<div className="access-form-permissions role-permission-wizard">
<div className="access-section-head tight">
<div>
<h3>دسترسیهای نقش</h3>
<h3>{rolePermissionModules[roleModuleIndex] ? getModuleLabel(rolePermissionModules[roleModuleIndex][0]) : 'دسترسی‌های نقش'}</h3>
<span>{roleForm.permissions.length} دسترسی انتخاب شده</span>
</div>
</div>
<PermissionMatrix
groups={permissions.reduce((acc, permission) => {
const module = permission.module || permission.name?.split('.')[0] || 'other';
if (!acc[module]) acc[module] = [];
acc[module].push(permission);
return acc;
}, {})}
groups={rolePermissionModules[roleModuleIndex] ? Object.fromEntries([rolePermissionModules[roleModuleIndex]]) : {}}
selectedIds={roleForm.permissions}
onToggle={toggleRoleFormPermission}
onToggleModule={(modulePermissions, checked) => {
@ -480,7 +506,13 @@ export default function Roles() {
}));
}}
/>
<div className="schedule-wizard-navigation">
<button type="button" className="btn btn-secondary btn-sm" disabled={roleModuleIndex === 0} onClick={() => setRoleModuleIndex((index) => Math.max(0, index - 1))}>ماژول قبلی</button>
<span>{rolePermissionModules.length ? roleModuleIndex + 1 : 0} از {rolePermissionModules.length}</span>
<button type="button" className="btn btn-secondary btn-sm" disabled={roleModuleIndex >= rolePermissionModules.length - 1} onClick={() => setRoleModuleIndex((index) => Math.min(rolePermissionModules.length - 1, index + 1))}>ماژول بعدی</button>
</div>
</div>
)}
</form>
</Modal>
)}

مشاهده پرونده

@ -18,6 +18,9 @@ import {
Trash2,
Users,
X,
CalendarDays,
TimerReset,
MessagesSquare,
} from 'lucide-react';
import api from '../services/api';
import ThemeAccentPicker from '../components/ThemeAccentPicker';
@ -34,6 +37,20 @@ const groupMeta = {
app: { label: 'برنامه', icon: SlidersHorizontal },
people: { label: 'منابع انسانی', icon: Users },
personalization: { label: 'شخصی‌سازی', icon: SlidersHorizontal },
calendar: { label: 'تقویم و زمان کاری', icon: CalendarDays },
sprint: { label: 'Sprint و Agile', icon: TimerReset },
meeting: { label: 'جلسات و یادآوری', icon: MessagesSquare },
};
const groupDescriptions = {
general: 'هویت، اطلاعات تماس و تنظیمات عمومی سازمان',
personalization: 'ظاهر سامانه، حالت نمایش و رنگ‌بندی شخصی',
notification: 'نوع‌ها، کانال‌ها و سیاست دریافت اعلان',
calendar: 'منطقه زمانی، روزهای کاری و رفتار تقویم',
sprint: 'قواعد سلامت Sprint و سیاست‌های Agile',
meeting: 'پیش‌فرض جلسه، یادآوری و ارزیابی اثربخشی',
security: 'کنترل‌های امنیتی و سیاست‌های دسترسی',
mail: 'ارسال ایمیل و زیرساخت SMTP',
};
const typeLabels = {
@ -59,6 +76,11 @@ const keyLabels = {
notification_types: 'نوع‌های اعلان',
smtp_settings: 'تنظیمات SMTP',
job_titles: 'عنوان‌های شغلی',
working_days: 'روزهای کاری سازمان',
organization_timezone: 'منطقه زمانی سازمان',
default_meeting_reminder_minutes: 'یادآوری پیش‌فرض جلسه',
meeting_effectiveness_enabled: 'ارزیابی اثربخشی جلسه',
sprint_health_override_requires_reason: 'الزام دلیل برای تغییر سلامت Sprint',
};
const emptySetting = {
@ -160,6 +182,30 @@ export default function Settings() {
missingType: settings.filter((setting) => !setting.type).length,
}), [settings, groups.length]);
const dirtySettings = useMemo(() => settings.filter((setting) => {
const original = toDraft(setting);
const draft = drafts[setting.id] || original;
return JSON.stringify({
value: normalizeByType(draft.type, draft.value),
group: draft.group,
type: draft.type,
}) !== JSON.stringify({
value: normalizeByType(original.type, original.value),
group: original.group,
type: original.type,
});
}), [settings, drafts]);
useEffect(() => {
const warnUnsaved = (event) => {
if (dirtySettings.length === 0) return;
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnUnsaved);
return () => window.removeEventListener('beforeunload', warnUnsaved);
}, [dirtySettings.length]);
const updateDraft = (id, patch) => {
setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
};
@ -217,12 +263,35 @@ export default function Settings() {
}
};
const saveAll = async () => {
if (dirtySettings.length === 0) return;
setSavingId('all');
try {
await Promise.all(dirtySettings.map((setting) => {
const draft = drafts[setting.id] || toDraft(setting);
return api.put(`/settings/${setting.id}`, {
value: normalizeByType(draft.type, draft.value),
group: draft.group,
type: draft.type,
});
}));
await fetchSettings();
toast.success(`${dirtySettings.length} تنظیم ذخیره شد`);
} catch (err) {
toast.error(err.response?.data?.message || 'ذخیره گروهی تنظیمات ناموفق بود');
} finally {
setSavingId(null);
}
};
const activeGroupTitle = activeGroup === 'personalization' ? 'شخصی‌سازی' : getGroupLabel(activeGroup);
return (
<div className="page-container settings-page">
<div className="page-header settings-header">
<div>
<h1>مرکز تنظیمات</h1>
<p className="settings-subtitle">تنظیمات شخصی و سیاستهای سازمان را از یک نقطه مدیریت کنید.</p>
</div>
<button className="btn btn-primary" onClick={() => setAddOpen(true)}>
<Plus size={16} />
@ -242,7 +311,7 @@ export default function Settings() {
<button className={`settings-nav-item ${activeGroup === 'personalization' ? 'active' : ''}`} onClick={() => setActiveGroup('personalization')}>
<span><SlidersHorizontal size={16} /> شخصیسازی</span>
</button>
{groups.map(({ key, count }) => {
{groups.map(({ key }) => {
const Icon = groupMeta[key]?.icon || SlidersHorizontal;
return (
<button key={key} className={`settings-nav-item ${activeGroup === key ? 'active' : ''}`} onClick={() => setActiveGroup(key)}>
@ -256,7 +325,7 @@ export default function Settings() {
<div className="settings-toolbar">
<div>
<h2>{activeGroupTitle}</h2>
<span>{activeGroup === 'personalization' ? 'ظاهر و رنگ‌بندی پنل' : `${visibleSettings.length} مورد`}</span>
<span>{groupDescriptions[activeGroup] || `${visibleSettings.length} مورد`}</span>
</div>
<div className="settings-search">
<Search size={16} />
@ -294,6 +363,13 @@ export default function Settings() {
</main>
</div>
{dirtySettings.length > 0 && (
<div className="settings-unsaved-bar" role="status">
<div><strong>{dirtySettings.length} تغییر ذخیرهنشده</strong><span>تغییرات تا زمان ذخیره روی سازمان اعمال نمیشوند.</span></div>
<div><button type="button" className="btn btn-secondary" onClick={() => setDrafts(Object.fromEntries(settings.map((setting) => [setting.id, toDraft(setting)])))}>لغو تغییرات</button><button type="button" className="btn btn-primary" onClick={saveAll} disabled={savingId === 'all'}><Save size={16} /> {savingId === 'all' ? 'در حال ذخیره...' : 'ذخیره همه'}</button></div>
</div>
)}
{addOpen && <div className="drawer-overlay" onClick={() => setAddOpen(false)} />}
{addOpen && (
<div className="drawer settings-drawer">

مشاهده پرونده

@ -0,0 +1,149 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import {
Activity, AlertTriangle, ArrowRight, Ban, CalendarDays, CheckCircle2,
Clock3, Gauge, ListChecks, MessageSquare, Plus, ShieldAlert, Target, Users,
} from 'lucide-react';
import api from '../services/api';
import { formatJalaliDate, formatJalaliDateTime } from '../utils/date';
const tabs = [
['overview', 'نمای کلی'], ['board', 'برد'], ['meetings', 'جلسات'], ['decisions', 'تصمیم‌ها'],
['action-items', 'اقدام‌ها'], ['blockers', 'موانع'], ['metrics', 'شاخص‌ها'], ['activity', 'فعالیت'],
];
const healthLabels = { on_track: 'روی برنامه', at_risk: 'در معرض ریسک', off_track: 'خارج از برنامه' };
function Empty({ text }) {
return <div className="workspace-empty"><ListChecks size={25} /><span>{text}</span></div>;
}
export default function SprintWorkspace() {
const { id } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [workspace, setWorkspace] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const activeTab = searchParams.get('tab') || 'overview';
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const { data } = await api.get(`/sprints/${id}/workspace`);
setWorkspace(data.data);
} catch {
setError('دریافت فضای کاری Sprint ناموفق بود.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { load(); }, [load]);
const sprint = workspace?.sprint;
const health = workspace?.health || {};
const progress = workspace?.progress || {};
const tasksByStatus = useMemo(() => (sprint?.tasks || []).reduce((result, task) => {
if (!result[task.status]) result[task.status] = [];
result[task.status].push(task);
return result;
}, {}), [sprint]);
if (loading) return <div className="page-container workspace-page"><div className="workspace-loading">{Array.from({ length: 8 }).map((_, index) => <div className="skeleton" key={index} />)}</div></div>;
if (error || !sprint) return <div className="page-container workspace-page"><div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={load}>تلاش دوباره</button></div></div>;
return (
<div className="page-container workspace-page">
<header className="workspace-hero">
<div className="workspace-breadcrumb"><Link to="/sprints"><ArrowRight size={16} /> Sprintها</Link><span>/</span><span>{sprint.title}</span></div>
<div className="workspace-hero-main">
<div>
<div className="workspace-title-row"><h1>{sprint.title}</h1><span className={`health-chip ${health.status === 'on_track' ? 'success' : health.status === 'at_risk' ? 'warning' : 'danger'}`}>{healthLabels[health.status]}</span></div>
<p>{sprint.goal || 'برای این Sprint هنوز هدفی ثبت نشده است.'}</p>
<div className="workspace-context"><span>{sprint.project?.title}</span><span>{formatJalaliDate(sprint.start_date)} تا {formatJalaliDate(sprint.end_date)}</span><span>{health.remaining_days} روز باقیمانده</span></div>
</div>
<div className="workspace-progress-ring" style={{ '--progress': `${health.progress || 0}%` }}>
<strong>{health.progress || 0}%</strong><span>پیشرفت</span>
</div>
</div>
<div className="workspace-quick-actions">
<Link className="btn btn-primary" to={`/meetings?sprint_id=${sprint.id}`}><Plus size={16} /> جلسه جدید</Link>
<Link className="btn btn-outline" to="/tasks"><Plus size={16} /> افزودن تسک</Link>
<button type="button" className="btn btn-outline" onClick={() => setSearchParams({ tab: 'blockers' })}><Ban size={16} /> مشاهده موانع</button>
</div>
</header>
<div className="workspace-kpis">
<article><Target size={19} /><span>کل تسکها</span><strong>{progress.total_tasks || 0}</strong></article>
<article><CheckCircle2 size={19} /><span>تکمیلشده</span><strong>{progress.completed_tasks || 0}</strong></article>
<article><Activity size={19} /><span>در حال انجام</span><strong>{progress.in_progress_tasks || 0}</strong></article>
<article className="danger"><Ban size={19} /><span>مسدود</span><strong>{progress.blocked_tasks || 0}</strong></article>
<article className="warning"><Clock3 size={19} /><span>عقبافتاده</span><strong>{progress.overdue_tasks || 0}</strong></article>
<article><Users size={19} /><span>اعضای تیم</span><strong>{sprint.members?.length || 0}</strong></article>
</div>
<nav className="workspace-tabs" aria-label="بخش‌های Sprint">
{tabs.map(([key, label]) => <button key={key} type="button" className={activeTab === key ? 'active' : ''} onClick={() => setSearchParams({ tab: key })}>{label}</button>)}
</nav>
<main className="workspace-content">
{activeTab === 'overview' && (
<div className="sprint-overview-grid">
<section className="workspace-card span-2">
<header><div><Gauge size={18} /><h2>سلامت Sprint</h2></div><span className={`health-chip ${health.status === 'on_track' ? 'success' : health.status === 'at_risk' ? 'warning' : 'danger'}`}>{healthLabels[health.status]}</span></header>
<div className="health-comparison">
<label>پیشرفت واقعی <span><i style={{ width: `${health.progress || 0}%` }} /></span><b>{health.progress || 0}%</b></label>
<label>پیشرفت مورد انتظار <span><i className="expected" style={{ width: `${health.expected_progress || 0}%` }} /></span><b>{health.expected_progress || 0}%</b></label>
</div>
{(health.open_blockers > 0 || health.overdue_tasks > 0) && <div className="workspace-alert"><AlertTriangle size={17} /> {health.open_blockers} مانع باز و {health.overdue_tasks} تسک عقبافتاده نیازمند توجه است.</div>}
</section>
<section className="workspace-card">
<header><div><CalendarDays size={18} /><h2>جلسه بعدی</h2></div></header>
{workspace.upcoming_meeting ? (
<Link className="upcoming-workspace-meeting" to={`/meetings/${workspace.upcoming_meeting.id}/workspace`}>
<strong>{workspace.upcoming_meeting.title}</strong>
<span>{formatJalaliDate(workspace.upcoming_meeting.date)} · {workspace.upcoming_meeting.start_time?.slice(0, 5) || 'تمام روز'}</span>
<small>{workspace.upcoming_meeting.location || 'مکان ثبت نشده'}</small>
</Link>
) : <Empty text="جلسه آینده‌ای ثبت نشده است." />}
</section>
<section className="workspace-card">
<header><div><Ban size={18} /><h2>موانع باز</h2></div><button onClick={() => setSearchParams({ tab: 'blockers' })}>همه</button></header>
{(workspace.blockers || []).filter((item) => !['resolved', 'closed'].includes(item.status)).slice(0, 4).map((item) => <div className="workspace-list-item" key={item.id}><span className={`severity ${item.severity}`} /><div><strong>{item.title}</strong><small>{item.status}</small></div></div>)}
{(workspace.blockers || []).length === 0 && <Empty text="مانع بازی وجود ندارد." />}
</section>
<section className="workspace-card">
<header><div><MessageSquare size={18} /><h2>تصمیمهای اخیر</h2></div><button onClick={() => setSearchParams({ tab: 'decisions' })}>همه</button></header>
{(workspace.decisions || []).slice(0, 4).map((item) => <div className="workspace-list-item" key={item.id}><CheckCircle2 size={16} /><div><strong>{item.title}</strong><small>{item.status}</small></div></div>)}
{(workspace.decisions || []).length === 0 && <Empty text="هنوز تصمیمی ثبت نشده است." />}
</section>
</div>
)}
{activeTab === 'board' && (
<div className="workspace-board">
{[['waiting', 'در انتظار'], ['todo', 'برای انجام'], ['in_progress', 'در حال انجام'], ['review', 'بازبینی'], ['done', 'انجام‌شده']].map(([status, label]) => (
<section key={status}><header><strong>{label}</strong><span>{tasksByStatus[status]?.length || 0}</span></header>
{(tasksByStatus[status] || []).map((task) => <article key={task.id}><strong>{task.title}</strong><small>{task.assignee?.name || 'بدون مسئول'}</small></article>)}
</section>
))}
</div>
)}
{activeTab === 'meetings' && <WorkspaceCollection items={workspace.meetings} empty="جلسه‌ای به این Sprint متصل نشده است." render={(meeting) => <Link className="workspace-data-row" to={`/meetings/${meeting.id}/workspace`}><div><strong>{meeting.title}</strong><small>{formatJalaliDateTime(`${meeting.date}T${meeting.start_time || '00:00:00'}`)}</small></div><span className="badge badge-info">{meeting.status}</span></Link>} />}
{activeTab === 'decisions' && <WorkspaceCollection items={workspace.decisions} empty="تصمیمی ثبت نشده است." render={(item) => <div className="workspace-data-row"><div><strong>{item.title}</strong><small>{item.description || item.rationale || 'بدون توضیح'}</small></div><span className="badge badge-primary">{item.status}</span></div>} />}
{activeTab === 'action-items' && <WorkspaceCollection items={workspace.action_items} empty="اقدامی ثبت نشده است." render={(item) => <div className="workspace-data-row"><div><strong>{item.title}</strong><small>سررسید: {formatJalaliDate(item.due_date)}</small></div><span className="badge badge-warning">{item.status}</span></div>} />}
{activeTab === 'blockers' && <WorkspaceCollection items={workspace.blockers} empty="مانعی ثبت نشده است." render={(item) => <div className="workspace-data-row"><div><strong>{item.title}</strong><small>{item.description || 'بدون توضیح'}</small></div><span className={`badge ${item.severity === 'critical' ? 'badge-danger' : 'badge-warning'}`}>{item.severity}</span></div>} />}
{activeTab === 'metrics' && <div className="workspace-card"><header><div><Gauge size={18} /><h2>شاخصهای Sprint</h2></div></header><div className="metrics-placeholder"><strong>{health.progress || 0}%</strong><span>نرخ تکمیل فعلی</span><strong>{health.expected_progress || 0}%</strong><span>پیشرفت مورد انتظار</span><strong>{workspace.scope_changes?.length || 0}</strong><span>تغییر محدوده</span></div></div>}
{activeTab === 'activity' && <Empty text="فعالیت‌های Sprint از Audit Log در این بخش نمایش داده خواهند شد." />}
</main>
</div>
);
}
function WorkspaceCollection({ items = [], empty, render }) {
return <section className="workspace-card workspace-collection">{items.length === 0 ? <Empty text={empty} /> : items.map(render)}</section>;
}

مشاهده پرونده

@ -7,8 +7,9 @@ import PriorityBadge from '../components/PriorityBadge';
import { CardSkeleton } from '../components/LoadingSkeleton';
import PersianDateInput from '../components/PersianDateInput';
import toast from 'react-hot-toast';
import { ArrowRight, BarChart3, CalendarDays, CheckCircle, Edit3, Flag, Play, Plus, RefreshCw, SquareKanban, Timer, Trash2, XCircle } from 'lucide-react';
import { ArrowRight, CalendarDays, CheckCircle, Edit3, Play, Plus, Timer, Trash2, XCircle } from 'lucide-react';
import { formatJalaliDate, isPastDate } from '../utils/date';
import { Link } from 'react-router-dom';
const initialForm = {
title: '',
@ -18,6 +19,7 @@ const initialForm = {
goal: '',
capacity_hours: '',
member_ids: [],
meeting_setup: [],
};
const statusLabels = {
@ -47,6 +49,7 @@ export default function Sprints() {
const [sprints, setSprints] = useState([]);
const [projects, setProjects] = useState([]);
const [users, setUsers] = useState([]);
const [meetingTypes, setMeetingTypes] = useState([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ project_id: '', status: '' });
const [selected, setSelected] = useState(null);
@ -57,6 +60,8 @@ export default function Sprints() {
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(initialForm);
const [saving, setSaving] = useState(false);
const [sprintFormStep, setSprintFormStep] = useState(0);
const [meetingScheduleIndex, setMeetingScheduleIndex] = useState(0);
const [deleting, setDeleting] = useState(null);
const [ending, setEnding] = useState(null);
const [endForm, setEndForm] = useState({ incomplete_action: 'backlog', target_sprint_id: '' });
@ -83,6 +88,7 @@ export default function Sprints() {
useEffect(() => {
api.get('/projects', { params: { per_page: 100 } }).then(({ data }) => setProjects(data.data || [])).catch(() => {});
api.get('/users', { params: { per_page: 200 } }).then(({ data }) => setUsers(data.data || [])).catch(() => {});
api.get('/meeting-types').then(({ data }) => setMeetingTypes(data.data || [])).catch(() => {});
}, []);
const plannedSprints = useMemo(() => (
@ -92,6 +98,8 @@ export default function Sprints() {
const openCreate = () => {
setEditing(null);
setForm(initialForm);
setSprintFormStep(0);
setMeetingScheduleIndex(0);
setShowForm(true);
};
@ -105,10 +113,47 @@ export default function Sprints() {
goal: sprint.goal || '',
capacity_hours: sprint.capacity_hours || '',
member_ids: (sprint.members || []).map((member) => member.id),
meeting_setup: [],
});
setSprintFormStep(0);
setMeetingScheduleIndex(0);
setShowForm(true);
};
const suggestedMeetingDate = (typeKey) => {
if (!form.start_date || !form.end_date) return form.start_date || form.end_date || '';
if (['sprint_review', 'sprint_retrospective'].includes(typeKey)) return form.end_date;
if (typeKey === 'backlog_refinement') {
const start = new Date(`${form.start_date}T00:00:00`);
const end = new Date(`${form.end_date}T00:00:00`);
return new Date((start.getTime() + end.getTime()) / 2).toISOString().slice(0, 10);
}
return form.start_date;
};
const toggleMeetingSetup = (type) => {
const exists = form.meeting_setup.some((item) => item.type_key === type.key);
if (exists) {
setForm({ ...form, meeting_setup: form.meeting_setup.filter((item) => item.type_key !== type.key) });
return;
}
setForm({
...form,
meeting_setup: [...form.meeting_setup, {
type_key: type.key,
title: type.default_title || type.name,
date: suggestedMeetingDate(type.key),
start_time: type.key === 'daily_standup' ? '09:30' : '10:00',
reminder_minutes: 30,
recurrence_rule: type.recurrence_rule || null,
}],
});
};
const updateMeetingSetup = (typeKey, patch) => {
setForm({ ...form, meeting_setup: form.meeting_setup.map((item) => item.type_key === typeKey ? { ...item, ...patch } : item) });
};
const saveSprint = async (e) => {
e.preventDefault();
if (!form.title.trim() || !form.project_id || !form.start_date || !form.end_date) {
@ -306,6 +351,7 @@ export default function Sprints() {
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ minWidth: 260 }}>
<h1 className="page-title" style={{ marginBottom: '0.5rem' }}>{selected.title}</h1>
<Link className="btn btn-primary btn-sm" to={`/sprints/${selected.id}/workspace`}>ورود به مرکز فرمان Sprint <ArrowRight size={15} /></Link>
<div style={{ color: 'var(--gray-600)', fontSize: '0.875rem', maxWidth: 720 }}>
{selected.goal || 'هدف Sprint ثبت نشده است'}
</div>
@ -514,13 +560,28 @@ export default function Sprints() {
function renderSprintForm() {
return (
<Modal title={editing ? 'ویرایش Sprint' : 'Sprint جدید'} onClose={() => setShowForm(false)} size="xl" footer={
!editing && sprintFormStep < 2 ? (
<>
<button className="btn btn-secondary" onClick={() => setShowForm(false)}>انصراف</button>
{sprintFormStep > 0 && <button className="btn btn-secondary" onClick={() => setSprintFormStep((step) => step - 1)}>مرحله قبل</button>}
<button className="btn btn-primary" onClick={() => setSprintFormStep((step) => step + 1)}>{sprintFormStep === 0 ? 'مرحله بعد: جلسات' : 'مرحله بعد: زمان‌بندی'}</button>
</>
) : (
<>
{!editing && <button className="btn btn-secondary" onClick={() => setSprintFormStep((step) => Math.max(0, step - 1))}>مرحله قبل</button>}
<button className="btn btn-primary" onClick={saveSprint} disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
</>
)
}>
<form onSubmit={saveSprint} className="compact-modal-form">
<div className="compact-modal-grid">
{!editing && (
<div className="modal-wizard-steps" role="tablist" aria-label="مراحل ساخت Sprint">
<button type="button" role="tab" aria-selected={sprintFormStep === 0} className={sprintFormStep === 0 ? 'active' : 'complete'} onClick={() => setSprintFormStep(0)}><span>{sprintFormStep > 0 ? <CheckCircle size={16} /> : '۱'}</span>اطلاعات Sprint</button>
<button type="button" role="tab" aria-selected={sprintFormStep === 1} className={sprintFormStep === 1 ? 'active' : sprintFormStep > 1 ? 'complete' : ''} onClick={() => setSprintFormStep(1)}><span>{sprintFormStep > 1 ? <CheckCircle size={16} /> : '۲'}</span>جلسات Sprint</button>
<button type="button" role="tab" aria-selected={sprintFormStep === 2} className={sprintFormStep === 2 ? 'active' : ''} onClick={() => setSprintFormStep(2)}><span>۳</span>زمانبندی</button>
</div>
)}
{(editing || sprintFormStep === 0) && <div className="compact-modal-grid modal-wizard-panel">
<Field label="عنوان Sprint" required><input className="form-input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></Field>
<Field label="پروژه مرتبط" required>
<select className="form-select" value={form.project_id} onChange={(e) => setForm({ ...form, project_id: e.target.value })}>
@ -539,7 +600,48 @@ export default function Sprints() {
<Field label="هدف Sprint" className="wide">
<textarea className="form-textarea" value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} placeholder="تا پایان این Sprint، نسخه اولیه پنل کاربر آماده تست داخلی شود." />
</Field>
</div>}
{!editing && sprintFormStep === 1 && (
<section className="sprint-meeting-setup">
<div className="sprint-meeting-setup-head">
<div><CalendarDays size={18} /><span><strong>راهاندازی جلسات Sprint</strong><small>جلسات استاندارد را انتخاب کنید؛ زمان پیشنهادی قابل ویرایش است.</small></span></div>
{form.meeting_setup.length === 0 && <span className="badge badge-gray">بدون جلسه</span>}
</div>
<div className="sprint-meeting-type-grid">
{meetingTypes.map((type) => {
const checked = form.meeting_setup.some((item) => item.type_key === type.key);
return <button type="button" key={type.id} className={checked ? 'active' : ''} onClick={() => toggleMeetingSetup(type)}><span>{checked ? <CheckCircle size={16} /> : <Plus size={16} />}</span><div><strong>{type.name}</strong><small>{type.suggested_duration ? `${type.suggested_duration} دقیقه` : 'مدت آزاد'}</small></div></button>;
})}
</div>
</section>
)}
{!editing && sprintFormStep === 2 && (
<section className="sprint-meeting-setup sprint-schedule-step">
<div className="sprint-meeting-setup-head">
<div><CalendarDays size={18} /><span><strong>زمانبندی جلسات</strong><small>هر جلسه را جداگانه تنظیم کنید.</small></span></div>
<span className="badge badge-gray">{form.meeting_setup.length} جلسه</span>
</div>
{form.meeting_setup.length > 0 ? (
<>
<div className="sprint-meeting-schedule">
{form.meeting_setup.slice(Math.min(meetingScheduleIndex, form.meeting_setup.length - 1), Math.min(meetingScheduleIndex, form.meeting_setup.length - 1) + 1).map((item) => (
<div key={item.type_key}>
<strong>{meetingTypes.find((type) => type.key === item.type_key)?.name || item.title}</strong>
<PersianDateInput value={item.date} onChange={(value) => updateMeetingSetup(item.type_key, { date: value })} />
<input className="form-input" type="time" value={item.start_time || ''} onChange={(event) => updateMeetingSetup(item.type_key, { start_time: event.target.value })} />
<input className="form-input" type="number" min="0" max="10080" value={item.reminder_minutes ?? 30} onChange={(event) => updateMeetingSetup(item.type_key, { reminder_minutes: Number(event.target.value) })} aria-label="یادآوری به دقیقه" />
</div>
))}
</div>
<div className="schedule-wizard-navigation">
<button type="button" className="btn btn-secondary btn-sm" disabled={meetingScheduleIndex === 0} onClick={() => setMeetingScheduleIndex((index) => Math.max(0, index - 1))}>جلسه قبلی</button>
<span>{Math.min(meetingScheduleIndex + 1, form.meeting_setup.length)} از {form.meeting_setup.length}</span>
<button type="button" className="btn btn-secondary btn-sm" disabled={meetingScheduleIndex >= form.meeting_setup.length - 1} onClick={() => setMeetingScheduleIndex((index) => Math.min(form.meeting_setup.length - 1, index + 1))}>جلسه بعدی</button>
</div>
</>
) : <div className="wizard-empty-state">جلسهای انتخاب نشده است؛ میتوانید Sprint را بدون جلسه ذخیره کنید.</div>}
</section>
)}
</form>
</Modal>
);

مشاهده پرونده

@ -226,7 +226,8 @@ export default function Team() {
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
</>
}>
<form onSubmit={handleSave}>
<form onSubmit={handleSave} className="compact-modal-form">
<div className="compact-modal-grid">
<div className="form-group">
<label className="form-label">نام</label>
<input className="form-input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
@ -269,6 +270,7 @@ export default function Team() {
{roles.map((r) => <option key={r.id} value={r.id}>{r.display_name || r.name}</option>)}
</select>
</div>
</div>
</form>
</Modal>
)}

مشاهده پرونده

@ -38,10 +38,13 @@ export default function PwaLayout() {
<PwaNotificationProvider>
<div className="pwa-shell" dir="rtl">
<header className="pwa-header">
<div className="pwa-header-brand">
<img src="/brand/project-mark.png" alt="" />
<div>
<span>اپلیکیشن موبایل</span>
<h1>{title}</h1>
</div>
</div>
{!isProfilePage && <PwaHeaderActions user={user} />}
</header>

مشاهده پرونده

@ -1,4 +1,4 @@
import { AlertTriangle, Bell, CheckCircle2, Clock3, MessageCircle, TimerReset, Trash2, UserRoundCheck } from 'lucide-react';
import { AlertTriangle, Archive, Bell, CheckCircle2, Clock3, MessageCircle, Trash2, UserRoundCheck } from 'lucide-react';
import { getPwaNotificationMessage, getPwaNotificationTitle, pwaNotificationTypeLabels } from './pwaNotificationUtils';
import { formatPwaNotificationTime } from './PwaNotificationTime';
import useBidirectionalNotificationSwipe from './useBidirectionalNotificationSwipe';
@ -16,20 +16,22 @@ const typeIcons = {
system: Bell,
};
export default function PwaNotificationCard({ notification, onOpen, onToggleUnread, onRemind, onDismiss }) {
export default function PwaNotificationCard({ notification, onOpen, onToggleUnread, onArchive, onDelete }) {
const Icon = typeIcons[notification.type] || Bell;
const read = notification.is_read || notification.read_at;
const { translateX, phase, dragging, exiting, direction, progress, swipeHandlers } = useBidirectionalNotificationSwipe({
rightAction: 'archive',
leftAction: 'delete',
onExitComplete: (action) => {
if (action === 'remind') onRemind(notification);
if (action === 'dismiss') onDismiss(notification);
if (action === 'archive') onArchive(notification);
if (action === 'delete') onDelete(notification);
},
});
return (
<article className={`pwa-notification-swipe ${phase} ${direction || ''} ${dragging ? 'dragging' : ''} ${exiting ? 'exiting' : ''}`} style={{ '--notification-swipe-progress': progress }}>
<div className="pwa-notification-swipe-action remind"><TimerReset size={18} /><span>یادآوری ۳۰ دقیقه دیگر</span></div>
<div className="pwa-notification-swipe-action dismiss"><Trash2 size={18} /><span>خروج از لیست</span></div>
<div className="pwa-notification-swipe-action archive"><Archive size={18} /><span>آرشیو</span></div>
<div className="pwa-notification-swipe-action delete"><Trash2 size={18} /><span>حذف</span></div>
<div
className={`pwa-notification-card ${read ? 'read' : 'unread'}`}
style={{ '--notification-card-translate-x': `${translateX}px` }}

مشاهده پرونده

@ -2,7 +2,7 @@ import { formatJalaliDateTime } from '../utils/date';
import PwaBottomSheet from './PwaBottomSheet';
import { getPwaNotificationMessage, getPwaNotificationTitle } from './pwaNotificationUtils';
export default function PwaNotificationDetailSheet({ notification, onClose }) {
export default function PwaNotificationDetailSheet({ notification, onClose, onNavigate }) {
return (
<PwaBottomSheet open={Boolean(notification)} title="جزئیات اعلان" onClose={onClose}>
{notification && (
@ -11,6 +11,7 @@ export default function PwaNotificationDetailSheet({ notification, onClose }) {
<h2>{getPwaNotificationTitle(notification)}</h2>
<p>{getPwaNotificationMessage(notification)}</p>
<small>{formatJalaliDateTime(notification.created_at)}</small>
<button type="button" className="pwa-primary-button" onClick={onNavigate}>رفتن به بخش مربوطه</button>
</div>
)}
</PwaBottomSheet>

مشاهده پرونده

@ -65,18 +65,7 @@ export default function PwaNotificationsPage() {
notify({ tone: 'error', title: 'اعلان', message: 'اعلان خوانده نشد.' });
}
const target = getPwaNotificationTarget(nextNotification);
if (target.available && target.path) {
navigate(target.path);
return;
}
if (nextNotification.type === 'system' || !target.available) {
setDetail(nextNotification);
if (!target.available && nextNotification.type !== 'system') {
notify({ tone: 'error', title: 'اعلان', message: 'مورد مربوط به این اعلان در دسترس نیست.' });
}
}
};
const markUnread = async (notification) => {
@ -94,28 +83,26 @@ export default function PwaNotificationsPage() {
setNotifications((current) => current.filter((item) => item.id !== notification.id));
};
const remindNotification = async (notification) => {
const archiveNotification = async (notification) => {
removeFromList(notification);
try {
const { data } = await api.patch(`/pwa/notifications/${notification.id}/remind`);
setUnread(data.meta?.unread_count || 0);
setUnreadCount(data.meta?.unread_count || 0);
notify({ tone: 'success', title: 'یادآوری', message: '۳۰ دقیقه دیگر یادآوری می‌شود.' });
await api.patch(`/notifications/${notification.id}/archive`);
refreshUnreadCount();
notify({ tone: 'success', title: 'اعلان', message: 'اعلان آرشیو شد.' });
} catch (err) {
notify({ tone: 'error', title: 'یادآوری', message: err.response?.data?.message || 'ثبت یادآوری انجام نشد.' });
notify({ tone: 'error', title: 'اعلان', message: err.response?.data?.message || 'آرشیو اعلان انجام نشد.' });
fetchNotifications(true);
}
};
const dismissNotification = async (notification) => {
const deleteNotification = async (notification) => {
removeFromList(notification);
try {
const { data } = await api.patch(`/pwa/notifications/${notification.id}/dismiss`);
setUnread(data.meta?.unread_count || 0);
setUnreadCount(data.meta?.unread_count || 0);
notify({ tone: 'success', title: 'اعلان', message: 'اعلان از لیست خارج شد.' });
await api.delete(`/notifications/${notification.id}`);
refreshUnreadCount();
notify({ tone: 'success', title: 'اعلان', message: 'اعلان حذف شد.' });
} catch (err) {
notify({ tone: 'error', title: 'اعلان', message: err.response?.data?.message || 'خروج اعلان از لیست انجام نشد.' });
notify({ tone: 'error', title: 'اعلان', message: err.response?.data?.message || 'حذف اعلان انجام نشد.' });
fetchNotifications(true);
}
};
@ -171,8 +158,8 @@ export default function PwaNotificationsPage() {
notification={notification}
onOpen={openNotification}
onToggleUnread={markUnread}
onRemind={remindNotification}
onDismiss={dismissNotification}
onArchive={archiveNotification}
onDelete={deleteNotification}
/>
))}
</div>
@ -183,7 +170,19 @@ export default function PwaNotificationsPage() {
</section>
)}
<PwaNotificationDetailSheet notification={detail} onClose={() => setDetail(null)} />
<PwaNotificationDetailSheet
notification={detail}
onClose={() => setDetail(null)}
onNavigate={() => {
const target = getPwaNotificationTarget(detail);
if (target.available && target.path) {
setDetail(null);
navigate(target.path);
} else {
notify({ tone: 'error', title: 'اعلان', message: 'مورد مربوط به این اعلان در دسترس نیست.' });
}
}}
/>
</div>
);
}

مشاهده پرونده

@ -9,7 +9,7 @@ const labels = {
denied: 'توسط مرورگر مسدود شده',
};
export default function PwaPushNotificationStatus({ pushReady = false }) {
export default function PwaPushNotificationStatus({ pushReady: _pushReady = false }) {
const { notify } = usePwaNotification();
const supported = typeof window !== 'undefined' && 'Notification' in window;
const [permission, setPermission] = useState(() => supported ? Notification.permission : 'unsupported');

برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر