harden access controls and prepare mysql launch
|
|
@ -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_USERNAME=root
|
||||||
# DB_PASSWORD=
|
# 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_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
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
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
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;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\LoginRequest;
|
|
||||||
use App\Http\Requests\ChangePasswordRequest;
|
use App\Http\Requests\ChangePasswordRequest;
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
use App\Http\Requests\UpdateProfileRequest;
|
use App\Http\Requests\UpdateProfileRequest;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ActivityLogService;
|
use App\Services\ActivityLogService;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -28,7 +31,7 @@ class AuthController extends Controller
|
||||||
|
|
||||||
$user = $this->findUserForLogin($identifier);
|
$user = $this->findUserForLogin($identifier);
|
||||||
|
|
||||||
if (!$user || !Hash::check($credentials['password'], $user->password)) {
|
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||||
$this->logAuthEvent(null, 'failed_login', 'تلاش ناموفق ورود', $request, $identifier);
|
$this->logAuthEvent(null, 'failed_login', 'تلاش ناموفق ورود', $request, $identifier);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -187,7 +190,7 @@ class AuthController extends Controller
|
||||||
try {
|
try {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if (!Hash::check($request->current_password, $user->password)) {
|
if (! Hash::check($request->current_password, $user->password)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'رمز عبور فعلی اشتباه است',
|
'message' => 'رمز عبور فعلی اشتباه است',
|
||||||
|
|
@ -195,10 +198,12 @@ class AuthController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->update(['password' => Hash::make($request->new_password)]);
|
$user->update(['password' => Hash::make($request->new_password)]);
|
||||||
|
$user->tokens()->delete();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'رمز عبور با موفقیت تغییر یافت',
|
'message' => 'رمز عبور با موفقیت تغییر یافت',
|
||||||
|
'reauthentication_required' => true,
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -210,9 +215,45 @@ class AuthController extends Controller
|
||||||
|
|
||||||
public function forgotPassword(Request $request): JsonResponse
|
public function forgotPassword(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$data = $request->validate(['email' => 'required|email']);
|
||||||
|
Password::sendResetLink(['email' => strtolower($data['email'])]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'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\Http\Resources\TaskResource;
|
||||||
use App\Models\BacklogItem;
|
use App\Models\BacklogItem;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class BacklogController extends Controller
|
class BacklogController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$query = BacklogItem::with(['project', 'assignedSprint', 'creator']);
|
$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')) {
|
if ($request->filled('project_id')) {
|
||||||
$query->where('project_id', $request->project_id);
|
$query->where('project_id', $request->project_id);
|
||||||
|
|
@ -31,8 +40,10 @@ class BacklogController extends Controller
|
||||||
$query->where('priority', $request->priority);
|
$query->where('priority', $request->priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = $request->input('per_page', 15);
|
$perPage = min(max((int) $request->input('per_page', 15), 1), 100);
|
||||||
$sortBy = $request->input('sort_by', 'created_at');
|
$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');
|
$sortDir = $request->input('sort_dir', 'desc');
|
||||||
$items = $query->orderBy($sortBy, $sortDir)->paginate($perPage);
|
$items = $query->orderBy($sortBy, $sortDir)->paginate($perPage);
|
||||||
|
|
||||||
|
|
@ -77,6 +88,13 @@ class BacklogController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $request->validated();
|
$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['created_by'] = $request->user()->id;
|
||||||
$item = BacklogItem::create($data);
|
$item = BacklogItem::create($data);
|
||||||
$item->load(['project', 'assignedSprint', 'creator']);
|
$item->load(['project', 'assignedSprint', 'creator']);
|
||||||
|
|
@ -109,7 +127,7 @@ class BacklogController extends Controller
|
||||||
|
|
||||||
$backlogItem->update($request->only([
|
$backlogItem->update($request->only([
|
||||||
'title', 'description', 'type', 'priority',
|
'title', 'description', 'type', 'priority',
|
||||||
'estimated_effort', 'status', 'assigned_sprint_id'
|
'estimated_effort', 'status', 'assigned_sprint_id',
|
||||||
]));
|
]));
|
||||||
$backlogItem->load(['project', 'assignedSprint', 'creator']);
|
$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' => 'رویدادهای تقویم',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,7 +98,7 @@ class ChecklistController extends Controller
|
||||||
public function toggleComplete(Checklist $checklist): JsonResponse
|
public function toggleComplete(Checklist $checklist): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$checklist->update(['is_completed' => !$checklist->is_completed]);
|
$checklist->update(['is_completed' => ! $checklist->is_completed]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\StoreCommentRequest;
|
use App\Http\Requests\StoreCommentRequest;
|
||||||
use App\Http\Resources\CommentResource;
|
use App\Http\Resources\CommentResource;
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use App\Models\Meeting;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ActivityLogService;
|
use App\Services\ActivityLogService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -17,12 +21,13 @@ class CommentController extends Controller
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected ActivityLogService $activityLogService,
|
protected ActivityLogService $activityLogService,
|
||||||
protected NotificationService $notificationService,
|
protected NotificationService $notificationService,
|
||||||
|
protected ResourceAccessService $resourceAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$query = Comment::with(['user', 'files']);
|
$query = $this->resourceAccess->comments($request->user())->with(['user', 'files']);
|
||||||
|
|
||||||
if ($request->filled('commentable_type')) {
|
if ($request->filled('commentable_type')) {
|
||||||
$query->where('commentable_type', $request->commentable_type);
|
$query->where('commentable_type', $request->commentable_type);
|
||||||
|
|
@ -57,11 +62,18 @@ class CommentController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $request->validated();
|
$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;
|
$data['user_id'] = $request->user()->id;
|
||||||
$comment = Comment::create($data);
|
$comment = Comment::create($data);
|
||||||
$comment->load('user');
|
$comment->load('user');
|
||||||
|
|
||||||
if (!empty($data['mentioned_user_id'])) {
|
if (! empty($data['mentioned_user_id'])) {
|
||||||
$mentionedUser = User::find($data['mentioned_user_id']);
|
$mentionedUser = User::find($data['mentioned_user_id']);
|
||||||
if ($mentionedUser && $mentionedUser->id !== $request->user()->id) {
|
if ($mentionedUser && $mentionedUser->id !== $request->user()->id) {
|
||||||
$this->notificationService->create(
|
$this->notificationService->create(
|
||||||
|
|
@ -80,7 +92,7 @@ class CommentController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
preg_match_all('/@(\w+)/', $comment->body, $matches);
|
preg_match_all('/@(\w+)/', $comment->body, $matches);
|
||||||
if (!empty($matches[1])) {
|
if (! empty($matches[1])) {
|
||||||
$mentionedUsers = User::whereIn('name', $matches[1])->get();
|
$mentionedUsers = User::whereIn('name', $matches[1])->get();
|
||||||
foreach ($mentionedUsers as $mentionedUser) {
|
foreach ($mentionedUsers as $mentionedUser) {
|
||||||
$mentionedUser->notifications()->create([
|
$mentionedUser->notifications()->create([
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,22 @@ class DashboardController extends Controller
|
||||||
], 500);
|
], 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,14 @@ class DepartmentController extends Controller
|
||||||
$departmentIds = $departments->pluck('id');
|
$departmentIds = $departments->pluck('id');
|
||||||
|
|
||||||
$tree = $departments
|
$tree = $departments
|
||||||
->filter(fn($department) => $department->parent_id === null || !$departmentIds->contains($department->parent_id))
|
->filter(fn ($department) => $department->parent_id === null || ! $departmentIds->contains($department->parent_id))
|
||||||
->values()
|
->values()
|
||||||
->map(fn($d) => $this->formatNode($d, $departments));
|
->map(fn ($d) => $this->formatNode($d, $departments));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $tree,
|
'data' => $tree,
|
||||||
'flat' => $departments->values()->map(fn($d) => [
|
'flat' => $departments->values()->map(fn ($d) => [
|
||||||
'id' => $d->id,
|
'id' => $d->id,
|
||||||
'name' => $d->name,
|
'name' => $d->name,
|
||||||
'type' => $d->type ?? 'department',
|
'type' => $d->type ?? 'department',
|
||||||
|
|
@ -50,6 +50,7 @@ class DepartmentController extends Controller
|
||||||
private function formatNode($dept, $all): array
|
private function formatNode($dept, $all): array
|
||||||
{
|
{
|
||||||
$children = $all->where('parent_id', $dept->id)->values();
|
$children = $all->where('parent_id', $dept->id)->values();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $dept->id,
|
'id' => $dept->id,
|
||||||
'name' => $dept->name,
|
'name' => $dept->name,
|
||||||
|
|
@ -62,7 +63,7 @@ class DepartmentController extends Controller
|
||||||
'is_active' => $dept->is_active,
|
'is_active' => $dept->is_active,
|
||||||
'sort_order' => $dept->sort_order,
|
'sort_order' => $dept->sort_order,
|
||||||
'users_count' => $dept->members()->count() ?: $dept->users()->count(),
|
'users_count' => $dept->members()->count() ?: $dept->users()->count(),
|
||||||
'children' => $children->map(fn($c) => $this->formatNode($c, $all)),
|
'children' => $children->map(fn ($c) => $this->formatNode($c, $all)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +83,7 @@ class DepartmentController extends Controller
|
||||||
$data = $request->only(['name', 'description', 'type', 'parent_id', 'manager_id', 'is_active', 'sort_order']);
|
$data = $request->only(['name', 'description', 'type', 'parent_id', 'manager_id', 'is_active', 'sort_order']);
|
||||||
$data['type'] = $data['type'] ?? 'department';
|
$data['type'] = $data['type'] ?? 'department';
|
||||||
$data['is_active'] = $data['is_active'] ?? true;
|
$data['is_active'] = $data['is_active'] ?? true;
|
||||||
if (!empty($data['manager_id'])) {
|
if (! empty($data['manager_id'])) {
|
||||||
$data['manager_changed_at'] = now();
|
$data['manager_changed_at'] = now();
|
||||||
}
|
}
|
||||||
$dept = Department::create($data);
|
$dept = Department::create($data);
|
||||||
|
|
@ -117,6 +118,7 @@ class DepartmentController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$department->load(['manager', 'parent', 'children']);
|
$department->load(['manager', 'parent', 'children']);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $department,
|
'data' => $department,
|
||||||
|
|
@ -152,11 +154,11 @@ class DepartmentController extends Controller
|
||||||
|
|
||||||
$changed = collect($payload)
|
$changed = collect($payload)
|
||||||
->except('manager_changed_at')
|
->except('manager_changed_at')
|
||||||
->filter(fn($value, $key) => array_key_exists($key, $old) && $old[$key] != $department->{$key})
|
->filter(fn ($value, $key) => array_key_exists($key, $old) && $old[$key] != $department->{$key})
|
||||||
->map(fn($value, $key) => ['old' => $old[$key], 'new' => $department->{$key}])
|
->map(fn ($value, $key) => ['old' => $old[$key], 'new' => $department->{$key}])
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if (!empty($changed)) {
|
if (! empty($changed)) {
|
||||||
$this->activityLogService->log(
|
$this->activityLogService->log(
|
||||||
$request->user()?->id,
|
$request->user()?->id,
|
||||||
array_key_exists('manager_id', $changed) ? 'change_department_manager' : 'update_department',
|
array_key_exists('manager_id', $changed) ? 'change_department_manager' : 'update_department',
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Resources\FileResource;
|
use App\Http\Resources\FileResource;
|
||||||
|
use App\Models\Comment;
|
||||||
use App\Models\File;
|
use App\Models\File;
|
||||||
|
use App\Models\Meeting;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -14,10 +18,12 @@ use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class FileController extends Controller
|
class FileController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$query = File::with('user');
|
$query = $this->resourceAccess->files($request->user())->with('user');
|
||||||
|
|
||||||
if ($request->filled('fileable_type')) {
|
if ($request->filled('fileable_type')) {
|
||||||
$query->where('fileable_type', $request->fileable_type);
|
$query->where('fileable_type', $request->fileable_type);
|
||||||
|
|
@ -66,17 +72,26 @@ class FileController extends Controller
|
||||||
$fileableId = $request->project_id;
|
$fileableId = $request->project_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$fileableType || !$fileableId) {
|
if (! $fileableType || ! $fileableId) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'انتخاب محل آپلود فایل الزامی است',
|
'message' => 'انتخاب محل آپلود فایل الزامی است',
|
||||||
], 422);
|
], 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');
|
$uploadedFile = $request->file('file');
|
||||||
$originalName = $uploadedFile->getClientOriginalName();
|
$originalName = $uploadedFile->getClientOriginalName();
|
||||||
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
||||||
$name = Str::uuid()->toString() . ($extension ? ".{$extension}" : '');
|
$name = Str::uuid()->toString().($extension ? ".{$extension}" : '');
|
||||||
$path = $uploadedFile->storeAs('files', $name, 'local');
|
$path = $uploadedFile->storeAs('files', $name, 'local');
|
||||||
|
|
||||||
$file = File::create([
|
$file = File::create([
|
||||||
|
|
@ -111,7 +126,7 @@ class FileController extends Controller
|
||||||
try {
|
try {
|
||||||
$disk = Storage::disk('local')->exists($file->path) ? 'local' : 'public';
|
$disk = Storage::disk('local')->exists($file->path) ? 'local' : 'public';
|
||||||
|
|
||||||
if (!Storage::disk($disk)->exists($file->path)) {
|
if (! Storage::disk($disk)->exists($file->path)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'فایل یافت نشد',
|
'message' => 'فایل یافت نشد',
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,34 @@ namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\StoreMeetingRequest;
|
use App\Http\Requests\StoreMeetingRequest;
|
||||||
use App\Http\Resources\MeetingResource;
|
|
||||||
use App\Http\Resources\MeetingActionItemResource;
|
use App\Http\Resources\MeetingActionItemResource;
|
||||||
|
use App\Http\Resources\MeetingResource;
|
||||||
use App\Http\Resources\TaskResource;
|
use App\Http\Resources\TaskResource;
|
||||||
|
use App\Models\ActionItem;
|
||||||
|
use App\Models\Blocker;
|
||||||
|
use App\Models\Decision;
|
||||||
use App\Models\Meeting;
|
use App\Models\Meeting;
|
||||||
use App\Models\MeetingActionItem;
|
use App\Models\MeetingActionItem;
|
||||||
|
use App\Models\MeetingType;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class MeetingController extends Controller
|
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
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$query = Meeting::with(['project', 'creator', 'participants']);
|
$query = $this->resourceAccess->meetings($request->user())
|
||||||
|
->with(['project', 'sprint', 'type', 'creator', 'participants']);
|
||||||
|
|
||||||
if ($request->filled('project_id')) {
|
if ($request->filled('project_id')) {
|
||||||
$query->where('project_id', $request->project_id);
|
$query->where('project_id', $request->project_id);
|
||||||
|
|
@ -36,6 +45,12 @@ class MeetingController extends Controller
|
||||||
if ($request->filled('meeting_type')) {
|
if ($request->filled('meeting_type')) {
|
||||||
$query->where('meeting_type', $request->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);
|
$perPage = $request->input('per_page', 15);
|
||||||
$sortBy = $request->input('sort_by', 'date');
|
$sortBy = $request->input('sort_by', 'date');
|
||||||
|
|
@ -64,7 +79,11 @@ class MeetingController extends Controller
|
||||||
public function show(Meeting $meeting): JsonResponse
|
public function show(Meeting $meeting): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|
@ -83,9 +102,17 @@ class MeetingController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $request->validated();
|
$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['created_by'] = $request->user()->id;
|
||||||
|
$data['owner_id'] ??= $request->user()->id;
|
||||||
$meeting = Meeting::create($data);
|
$meeting = Meeting::create($data);
|
||||||
$meeting->load(['project', 'creator']);
|
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner']);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|
@ -103,24 +130,43 @@ class MeetingController extends Controller
|
||||||
public function update(Request $request, Meeting $meeting): JsonResponse
|
public function update(Request $request, Meeting $meeting): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$request->validate([
|
$data = $request->validate([
|
||||||
'title' => 'sometimes|required|string|max:255',
|
'title' => 'sometimes|required|string|max:255',
|
||||||
'date' => 'sometimes|required|date',
|
'date' => 'sometimes|required|date',
|
||||||
'start_time' => 'nullable',
|
'start_time' => 'nullable',
|
||||||
'end_time' => 'nullable',
|
'end_time' => 'nullable',
|
||||||
'meeting_type' => 'sometimes|required|string|max:50',
|
'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',
|
'location' => 'nullable|string',
|
||||||
'meeting_link' => 'nullable|string',
|
'meeting_link' => 'nullable|url',
|
||||||
|
'objective' => 'nullable|string',
|
||||||
'agenda' => 'nullable|string',
|
'agenda' => 'nullable|string',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
|
'summary' => 'nullable|string',
|
||||||
'decisions' => '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([
|
$meeting->update(collect($data)->only([
|
||||||
'title', 'date', 'start_time', 'end_time', 'meeting_type',
|
'title', 'project_id', 'sprint_id', 'date', 'start_time', 'end_time',
|
||||||
'location', 'meeting_link', 'agenda', 'notes', 'decisions'
|
'meeting_type', 'meeting_type_id', 'status', 'location', 'meeting_link',
|
||||||
]));
|
'objective', 'agenda', 'notes', 'summary', 'decisions', 'owner_id',
|
||||||
$meeting->load(['project', 'creator']);
|
'facilitator_id', 'reminder_minutes', 'recurrence_rule',
|
||||||
|
])->all());
|
||||||
|
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner', 'facilitator']);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|
@ -277,6 +323,11 @@ class MeetingController extends Controller
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'project_id' => 'required|exists:projects,id',
|
'project_id' => 'required|exists:projects,id',
|
||||||
]);
|
]);
|
||||||
|
abort_unless(
|
||||||
|
$this->resourceAccess->projects($request->user())->whereKey($request->project_id)->exists(),
|
||||||
|
403,
|
||||||
|
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||||
|
);
|
||||||
|
|
||||||
$task = Task::create([
|
$task = Task::create([
|
||||||
'title' => $actionItem->title,
|
'title' => $actionItem->title,
|
||||||
|
|
@ -303,4 +354,131 @@ class MeetingController extends Controller
|
||||||
], 500);
|
], 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 {
|
try {
|
||||||
$query = $request->user()->notifications();
|
$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);
|
$perPage = $request->input('per_page', 15);
|
||||||
$notifications = $query->orderBy('created_at', 'desc')->paginate($perPage);
|
$notifications = $query->orderBy('created_at', 'desc')->paginate($perPage);
|
||||||
|
|
@ -27,7 +33,8 @@ class NotificationController extends Controller
|
||||||
'last_page' => $notifications->lastPage(),
|
'last_page' => $notifications->lastPage(),
|
||||||
'per_page' => $notifications->perPage(),
|
'per_page' => $notifications->perPage(),
|
||||||
'total' => $notifications->total(),
|
'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' => 'لیست اعلانها',
|
'message' => 'لیست اعلانها',
|
||||||
]);
|
]);
|
||||||
|
|
@ -107,4 +114,55 @@ class NotificationController extends Controller
|
||||||
], 500);
|
], 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\ActivityLogService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\ProjectProgressService;
|
use App\Services\ProjectProgressService;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -20,12 +21,14 @@ class ProjectController extends Controller
|
||||||
protected ActivityLogService $activityLogService,
|
protected ActivityLogService $activityLogService,
|
||||||
protected NotificationService $notificationService,
|
protected NotificationService $notificationService,
|
||||||
protected ProjectProgressService $projectProgressService,
|
protected ProjectProgressService $projectProgressService,
|
||||||
|
protected ResourceAccessService $resourceAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$query = Project::with(['projectManager', 'creator', 'department']);
|
$query = $this->resourceAccess->projects($request->user())
|
||||||
|
->with(['projectManager', 'creator', 'department']);
|
||||||
|
|
||||||
if ($request->filled('status')) {
|
if ($request->filled('status')) {
|
||||||
$query->where('status', $request->status);
|
$query->where('status', $request->status);
|
||||||
|
|
@ -225,7 +228,7 @@ class ProjectController extends Controller
|
||||||
|
|
||||||
$project->members()->syncWithoutDetaching([
|
$project->members()->syncWithoutDetaching([
|
||||||
$request->user_id => [
|
$request->user_id => [
|
||||||
'role_in_project' => $request->role_in_project ?? 'member',
|
'role_in_project' => $request->role_in_project ?? 'member',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,16 @@ namespace App\Http\Controllers\Api;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Resources\CommentResource;
|
use App\Http\Resources\CommentResource;
|
||||||
use App\Http\Resources\FileResource;
|
use App\Http\Resources\FileResource;
|
||||||
use App\Models\Notification;
|
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
use App\Models\File;
|
use App\Models\File;
|
||||||
|
use App\Models\Notification;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Sprint;
|
use App\Models\Sprint;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ProjectProgressService;
|
use App\Services\ProjectProgressService;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -22,13 +23,16 @@ use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class PwaController extends Controller
|
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
|
public function profile(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user()->loadMissing(['roles.permissions', 'dept', 'departments']);
|
$user = $request->user()->loadMissing(['roles.permissions', 'dept', 'departments']);
|
||||||
$permissions = $user->roles
|
$permissions = $user->roles
|
||||||
->flatMap(fn($role) => $role->permissions->pluck('name'))
|
->flatMap(fn ($role) => $role->permissions->pluck('name'))
|
||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
|
|
@ -44,14 +48,14 @@ class PwaController extends Controller
|
||||||
'department' => $user->dept?->name ?: $user->department,
|
'department' => $user->dept?->name ?: $user->department,
|
||||||
'status' => $user->status ?: 'active',
|
'status' => $user->status ?: 'active',
|
||||||
'avatar' => $user->avatar,
|
'avatar' => $user->avatar,
|
||||||
'avatar_url' => $user->avatar ? asset('storage/' . $user->avatar) : null,
|
'avatar_url' => $user->avatar ? asset('storage/'.$user->avatar) : null,
|
||||||
'roles' => $user->roles->map(fn($role) => [
|
'roles' => $user->roles->map(fn ($role) => [
|
||||||
'id' => $role->id,
|
'id' => $role->id,
|
||||||
'name' => $role->name,
|
'name' => $role->name,
|
||||||
'display_name' => $role->display_name,
|
'display_name' => $role->display_name,
|
||||||
])->values(),
|
])->values(),
|
||||||
'primary_role' => $user->roles->first()?->display_name ?: $user->roles->first()?->name,
|
'primary_role' => $user->roles->first()?->display_name ?: $user->roles->first()?->name,
|
||||||
'departments' => $user->departments->map(fn($department) => [
|
'departments' => $user->departments->map(fn ($department) => [
|
||||||
'id' => $department->id,
|
'id' => $department->id,
|
||||||
'name' => $department->name,
|
'name' => $department->name,
|
||||||
'role_in_team' => $department->pivot?->role_in_team,
|
'role_in_team' => $department->pivot?->role_in_team,
|
||||||
|
|
@ -77,8 +81,8 @@ class PwaController extends Controller
|
||||||
->with(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar'])
|
->with(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar'])
|
||||||
->withCount([
|
->withCount([
|
||||||
'tasks',
|
'tasks',
|
||||||
'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'),
|
'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'),
|
||||||
'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery
|
'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery
|
||||||
->whereDate('due_date', '<', now()->toDateString())
|
->whereDate('due_date', '<', now()->toDateString())
|
||||||
->whereNotIn('status', ['done', 'canceled', 'cancelled']),
|
->whereNotIn('status', ['done', 'canceled', 'cancelled']),
|
||||||
]);
|
]);
|
||||||
|
|
@ -88,12 +92,12 @@ class PwaController extends Controller
|
||||||
'active' => $query->where('status', 'active'),
|
'active' => $query->where('status', 'active'),
|
||||||
'waiting' => $query->whereIn('status', ['waiting', 'pending']),
|
'waiting' => $query->whereIn('status', ['waiting', 'pending']),
|
||||||
'completed' => $query->whereIn('status', ['completed', 'done']),
|
'completed' => $query->whereIn('status', ['completed', 'done']),
|
||||||
'delayed' => $query->whereHas('tasks', fn($taskQuery) => $taskQuery
|
'delayed' => $query->whereHas('tasks', fn ($taskQuery) => $taskQuery
|
||||||
->whereDate('due_date', '<', now()->toDateString())
|
->whereDate('due_date', '<', now()->toDateString())
|
||||||
->whereNotIn('status', ['done', 'canceled', 'cancelled'])),
|
->whereNotIn('status', ['done', 'canceled', 'cancelled'])),
|
||||||
'mine' => $query->where(function ($scopeQuery) use ($user) {
|
'mine' => $query->where(function ($scopeQuery) use ($user) {
|
||||||
$scopeQuery->where('project_manager_id', $user->id)
|
$scopeQuery->where('project_manager_id', $user->id)
|
||||||
->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id));
|
->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id));
|
||||||
}),
|
}),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
@ -114,7 +118,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => collect($projects->items())->map(fn(Project $project) => $this->projectCard($project))->values(),
|
'data' => collect($projects->items())->map(fn (Project $project) => $this->projectCard($project))->values(),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'current_page' => $projects->currentPage(),
|
'current_page' => $projects->currentPage(),
|
||||||
'last_page' => $projects->lastPage(),
|
'last_page' => $projects->lastPage(),
|
||||||
|
|
@ -133,7 +137,7 @@ class PwaController extends Controller
|
||||||
$project->load([
|
$project->load([
|
||||||
'projectManager:id,name,job_title,avatar',
|
'projectManager:id,name,job_title,avatar',
|
||||||
'members:id,name,job_title,avatar',
|
'members:id,name,job_title,avatar',
|
||||||
'tasks' => fn($taskQuery) => $taskQuery
|
'tasks' => fn ($taskQuery) => $taskQuery
|
||||||
->with(['assignee:id,name,job_title'])
|
->with(['assignee:id,name,job_title'])
|
||||||
->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end")
|
->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end")
|
||||||
->orderByRaw('case when due_date is null then 1 else 0 end')
|
->orderByRaw('case when due_date is null then 1 else 0 end')
|
||||||
|
|
@ -141,11 +145,11 @@ class PwaController extends Controller
|
||||||
->limit(8),
|
->limit(8),
|
||||||
])->loadCount([
|
])->loadCount([
|
||||||
'tasks',
|
'tasks',
|
||||||
'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'),
|
'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'),
|
||||||
'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery
|
'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery
|
||||||
->whereDate('due_date', '<', $today)
|
->whereDate('due_date', '<', $today)
|
||||||
->whereNotIn('status', ['done', 'canceled', 'cancelled']),
|
->whereNotIn('status', ['done', 'canceled', 'cancelled']),
|
||||||
'tasks as today_tasks_count' => fn($taskQuery) => $taskQuery->whereDate('due_date', $today),
|
'tasks as today_tasks_count' => fn ($taskQuery) => $taskQuery->whereDate('due_date', $today),
|
||||||
'members',
|
'members',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -154,7 +158,7 @@ class PwaController extends Controller
|
||||||
->latest()
|
->latest()
|
||||||
->limit(6)
|
->limit(6)
|
||||||
->get(['id', 'action', 'description', 'created_at'])
|
->get(['id', 'action', 'description', 'created_at'])
|
||||||
->map(fn(ActivityLog $activity) => [
|
->map(fn (ActivityLog $activity) => [
|
||||||
'id' => $activity->id,
|
'id' => $activity->id,
|
||||||
'action' => $activity->action,
|
'action' => $activity->action,
|
||||||
'description' => $activity->description,
|
'description' => $activity->description,
|
||||||
|
|
@ -167,12 +171,12 @@ class PwaController extends Controller
|
||||||
'description' => $project->description,
|
'description' => $project->description,
|
||||||
'members_count' => $project->members_count,
|
'members_count' => $project->members_count,
|
||||||
'today_tasks_count' => $project->today_tasks_count,
|
'today_tasks_count' => $project->today_tasks_count,
|
||||||
'tasks' => $project->tasks->map(fn(Task $task) => $this->taskCard($task))->values(),
|
'tasks' => $project->tasks->map(fn (Task $task) => $this->taskCard($task))->values(),
|
||||||
'members' => $project->members->map(fn(User $member) => [
|
'members' => $project->members->map(fn (User $member) => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'job_title' => $member->job_title,
|
'job_title' => $member->job_title,
|
||||||
'avatar_url' => $member->avatar ? asset('storage/' . $member->avatar) : null,
|
'avatar_url' => $member->avatar ? asset('storage/'.$member->avatar) : null,
|
||||||
'role_in_project' => $member->pivot?->role_in_project,
|
'role_in_project' => $member->pivot?->role_in_project,
|
||||||
])->values(),
|
])->values(),
|
||||||
'activities' => $activities,
|
'activities' => $activities,
|
||||||
|
|
@ -199,8 +203,8 @@ class PwaController extends Controller
|
||||||
$project->load(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar'])
|
$project->load(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar'])
|
||||||
->loadCount([
|
->loadCount([
|
||||||
'tasks',
|
'tasks',
|
||||||
'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'),
|
'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'),
|
||||||
'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery
|
'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery
|
||||||
->whereDate('due_date', '<', now()->toDateString())
|
->whereDate('due_date', '<', now()->toDateString())
|
||||||
->whereNotIn('status', ['done', 'canceled', 'cancelled']),
|
->whereNotIn('status', ['done', 'canceled', 'cancelled']),
|
||||||
]);
|
]);
|
||||||
|
|
@ -239,7 +243,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => collect($notifications->items())->map(fn(Notification $notification) => $this->notificationPayload($notification))->values(),
|
'data' => collect($notifications->items())->map(fn (Notification $notification) => $this->notificationPayload($notification))->values(),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'current_page' => $notifications->currentPage(),
|
'current_page' => $notifications->currentPage(),
|
||||||
'last_page' => $notifications->lastPage(),
|
'last_page' => $notifications->lastPage(),
|
||||||
|
|
@ -360,7 +364,7 @@ class PwaController extends Controller
|
||||||
$search = trim($request->input('search'));
|
$search = trim($request->input('search'));
|
||||||
$query->where(function ($innerQuery) use ($search) {
|
$query->where(function ($innerQuery) use ($search) {
|
||||||
$innerQuery->where('title', 'like', "%{$search}%")
|
$innerQuery->where('title', 'like', "%{$search}%")
|
||||||
->orWhereHas('project', fn($projectQuery) => $projectQuery->where('title', 'like', "%{$search}%"));
|
->orWhereHas('project', fn ($projectQuery) => $projectQuery->where('title', 'like', "%{$search}%"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,7 +381,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => collect($tasks->items())->map(fn(Task $task) => $this->taskCard($task))->values(),
|
'data' => collect($tasks->items())->map(fn (Task $task) => $this->taskCard($task))->values(),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'current_page' => $tasks->currentPage(),
|
'current_page' => $tasks->currentPage(),
|
||||||
'last_page' => $tasks->lastPage(),
|
'last_page' => $tasks->lastPage(),
|
||||||
|
|
@ -467,7 +471,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
$uploadedFile = $request->file('file');
|
$uploadedFile = $request->file('file');
|
||||||
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
||||||
$name = Str::uuid()->toString() . ($extension ? ".{$extension}" : '');
|
$name = Str::uuid()->toString().($extension ? ".{$extension}" : '');
|
||||||
$path = $uploadedFile->storeAs('files', $name, 'local');
|
$path = $uploadedFile->storeAs('files', $name, 'local');
|
||||||
|
|
||||||
$file = File::create([
|
$file = File::create([
|
||||||
|
|
@ -506,7 +510,7 @@ class PwaController extends Controller
|
||||||
{
|
{
|
||||||
$user = $request->user()->loadMissing(['roles.permissions']);
|
$user = $request->user()->loadMissing(['roles.permissions']);
|
||||||
|
|
||||||
if (!$user->hasPermission('tasks.create')) {
|
if (! $user->hasPermission('tasks.create')) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'شما مجوز ایجاد تسک را ندارید.',
|
'message' => 'شما مجوز ایجاد تسک را ندارید.',
|
||||||
|
|
@ -515,11 +519,11 @@ class PwaController extends Controller
|
||||||
|
|
||||||
$projectQuery = Project::query()
|
$projectQuery = Project::query()
|
||||||
->with(['members:id,name,job_title,avatar', 'projectManager:id,name,job_title,avatar'])
|
->with(['members:id,name,job_title,avatar', 'projectManager:id,name,job_title,avatar'])
|
||||||
->when(!$user->hasPermission('reports.view'), function ($query) use ($user) {
|
->when(! $user->hasPermission('reports.view'), function ($query) use ($user) {
|
||||||
$query->where(function ($scopeQuery) use ($user) {
|
$query->where(function ($scopeQuery) use ($user) {
|
||||||
$scopeQuery->where('project_manager_id', $user->id)
|
$scopeQuery->where('project_manager_id', $user->id)
|
||||||
->orWhere('created_by', $user->id)
|
->orWhere('created_by', $user->id)
|
||||||
->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id));
|
->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id));
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
|
|
@ -529,15 +533,15 @@ class PwaController extends Controller
|
||||||
|
|
||||||
$projects = $projectQuery->limit(100)->get();
|
$projects = $projectQuery->limit(100)->get();
|
||||||
$projectUserIds = $projects
|
$projectUserIds = $projects
|
||||||
->flatMap(fn(Project $project) => $project->members->pluck('id')->push($project->project_manager_id))
|
->flatMap(fn (Project $project) => $project->members->pluck('id')->push($project->project_manager_id))
|
||||||
->filter()
|
->filter()
|
||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->when(!$user->hasPermission('reports.view'), function ($query) use ($user, $projectUserIds) {
|
->when(! $user->hasPermission('reports.view'), function ($query) use ($user, $projectUserIds) {
|
||||||
$query->where('id', $user->id)
|
$query->where('id', $user->id)
|
||||||
->when($projectUserIds->isNotEmpty(), fn($innerQuery) => $innerQuery->orWhereIn('id', $projectUserIds));
|
->when($projectUserIds->isNotEmpty(), fn ($innerQuery) => $innerQuery->orWhereIn('id', $projectUserIds));
|
||||||
})
|
})
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
$query->where('status', 'active')->orWhereNull('status');
|
$query->where('status', 'active')->orWhereNull('status');
|
||||||
|
|
@ -549,7 +553,7 @@ class PwaController extends Controller
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'projects' => $projects->map(fn(Project $project) => [
|
'projects' => $projects->map(fn (Project $project) => [
|
||||||
'id' => $project->id,
|
'id' => $project->id,
|
||||||
'title' => $project->title,
|
'title' => $project->title,
|
||||||
'members' => $project->members
|
'members' => $project->members
|
||||||
|
|
@ -557,13 +561,13 @@ class PwaController extends Controller
|
||||||
->filter()
|
->filter()
|
||||||
->unique('id')
|
->unique('id')
|
||||||
->values()
|
->values()
|
||||||
->map(fn(User $member) => [
|
->map(fn (User $member) => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'job_title' => $member->job_title,
|
'job_title' => $member->job_title,
|
||||||
]),
|
]),
|
||||||
])->values(),
|
])->values(),
|
||||||
'users' => $users->map(fn(User $optionUser) => [
|
'users' => $users->map(fn (User $optionUser) => [
|
||||||
'id' => $optionUser->id,
|
'id' => $optionUser->id,
|
||||||
'name' => $optionUser->name,
|
'name' => $optionUser->name,
|
||||||
'job_title' => $optionUser->job_title,
|
'job_title' => $optionUser->job_title,
|
||||||
|
|
@ -585,13 +589,13 @@ class PwaController extends Controller
|
||||||
|
|
||||||
$query = Sprint::query()->with('project:id,title,status');
|
$query = Sprint::query()->with('project:id,title,status');
|
||||||
|
|
||||||
if (!$user->hasPermission('reports.view')) {
|
if (! $user->hasPermission('reports.view')) {
|
||||||
$visibleProjectIds = $this->visibleProjectsQuery($user)->pluck('id');
|
$visibleProjectIds = $this->visibleProjectsQuery($user)->pluck('id');
|
||||||
|
|
||||||
$query->where(function ($scopeQuery) use ($user, $visibleProjectIds) {
|
$query->where(function ($scopeQuery) use ($user, $visibleProjectIds) {
|
||||||
$scopeQuery
|
$scopeQuery
|
||||||
->when($visibleProjectIds->isNotEmpty(), fn($innerQuery) => $innerQuery->whereIn('project_id', $visibleProjectIds))
|
->when($visibleProjectIds->isNotEmpty(), fn ($innerQuery) => $innerQuery->whereIn('project_id', $visibleProjectIds))
|
||||||
->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id))
|
->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id))
|
||||||
->orWhereHas('tasks', function ($taskQuery) use ($user) {
|
->orWhereHas('tasks', function ($taskQuery) use ($user) {
|
||||||
$taskQuery
|
$taskQuery
|
||||||
->where('assignee_id', $user->id)
|
->where('assignee_id', $user->id)
|
||||||
|
|
@ -611,7 +615,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $sprints->map(fn(Sprint $sprint) => $this->pwaSprintPayload($sprint, $user))->values(),
|
'data' => $sprints->map(fn (Sprint $sprint) => $this->pwaSprintPayload($sprint, $user))->values(),
|
||||||
'message' => 'اسپرینتهای موبایل',
|
'message' => 'اسپرینتهای موبایل',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +648,7 @@ class PwaController extends Controller
|
||||||
->orderBy('due_date')
|
->orderBy('due_date')
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get()
|
->get()
|
||||||
->map(fn(Task $task) => $this->taskPreview($task))
|
->map(fn (Task $task) => $this->taskPreview($task))
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$notifications = Notification::query()
|
$notifications = Notification::query()
|
||||||
|
|
@ -652,7 +656,7 @@ class PwaController extends Controller
|
||||||
->latest()
|
->latest()
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get()
|
->get()
|
||||||
->map(fn(Notification $notification) => [
|
->map(fn (Notification $notification) => [
|
||||||
'id' => $notification->id,
|
'id' => $notification->id,
|
||||||
'type' => $notification->type,
|
'type' => $notification->type,
|
||||||
'title' => $notification->title,
|
'title' => $notification->title,
|
||||||
|
|
@ -666,7 +670,7 @@ class PwaController extends Controller
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$permissions = $user->roles
|
$permissions = $user->roles
|
||||||
->flatMap(fn($role) => $role->permissions->pluck('name'))
|
->flatMap(fn ($role) => $role->permissions->pluck('name'))
|
||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
|
|
@ -677,8 +681,8 @@ class PwaController extends Controller
|
||||||
$manageableProjectIds = $this->manageableProjectIds($user);
|
$manageableProjectIds = $this->manageableProjectIds($user);
|
||||||
|
|
||||||
$teamTaskQuery = Task::query()
|
$teamTaskQuery = Task::query()
|
||||||
->when($manageableProjectIds->isNotEmpty(), fn($query) => $query->whereIn('project_id', $manageableProjectIds))
|
->when($manageableProjectIds->isNotEmpty(), fn ($query) => $query->whereIn('project_id', $manageableProjectIds))
|
||||||
->when($manageableProjectIds->isEmpty() && !$user->hasPermission('reports.view'), fn($query) => $query->whereRaw('1 = 0'));
|
->when($manageableProjectIds->isEmpty() && ! $user->hasPermission('reports.view'), fn ($query) => $query->whereRaw('1 = 0'));
|
||||||
|
|
||||||
$managerSummary = [
|
$managerSummary = [
|
||||||
'overdue_team_tasks' => (clone $teamTaskQuery)
|
'overdue_team_tasks' => (clone $teamTaskQuery)
|
||||||
|
|
@ -689,7 +693,7 @@ class PwaController extends Controller
|
||||||
'active_sprint' => Sprint::query()
|
'active_sprint' => Sprint::query()
|
||||||
->with('project:id,title')
|
->with('project:id,title')
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->when($manageableProjectIds->isNotEmpty(), fn($query) => $query->whereIn('project_id', $manageableProjectIds))
|
->when($manageableProjectIds->isNotEmpty(), fn ($query) => $query->whereIn('project_id', $manageableProjectIds))
|
||||||
->latest()
|
->latest()
|
||||||
->first()?->only(['id', 'title', 'project_id', 'start_date', 'end_date']),
|
->first()?->only(['id', 'title', 'project_id', 'start_date', 'end_date']),
|
||||||
'delayed_members' => (clone $teamTaskQuery)
|
'delayed_members' => (clone $teamTaskQuery)
|
||||||
|
|
@ -756,11 +760,11 @@ class PwaController extends Controller
|
||||||
|
|
||||||
$permissionSet = $permissions->flip();
|
$permissionSet = $permissions->flip();
|
||||||
$summary = collect($items)
|
$summary = collect($items)
|
||||||
->filter(fn($item) => collect($item['permissions'])->contains(fn($permission) => $permissionSet->has($permission)))
|
->filter(fn ($item) => collect($item['permissions'])->contains(fn ($permission) => $permissionSet->has($permission)))
|
||||||
->map(fn($item) => ['label' => $item['label']])
|
->map(fn ($item) => ['label' => $item['label']])
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$isAdmin = $user->roles->contains(fn($role) => in_array($role->name, ['admin', 'super_admin', 'system_admin'], true))
|
$isAdmin = $user->roles->contains(fn ($role) => in_array($role->name, ['admin', 'super_admin', 'system_admin'], true))
|
||||||
|| $permissionSet->has('roles.edit')
|
|| $permissionSet->has('roles.edit')
|
||||||
|| $permissionSet->has('settings.edit');
|
|| $permissionSet->has('settings.edit');
|
||||||
|
|
||||||
|
|
@ -784,18 +788,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
private function visibleTasksQuery(User $user)
|
private function visibleTasksQuery(User $user)
|
||||||
{
|
{
|
||||||
if ($user->hasPermission('reports.view')) {
|
return $this->resourceAccess->tasks($user);
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function abortUnlessTaskVisible(Request $request, Task $task): void
|
private function abortUnlessTaskVisible(Request $request, Task $task): void
|
||||||
|
|
@ -807,15 +800,7 @@ class PwaController extends Controller
|
||||||
|
|
||||||
private function visibleProjectsQuery(User $user)
|
private function visibleProjectsQuery(User $user)
|
||||||
{
|
{
|
||||||
if ($user->hasPermission('reports.view')) {
|
return $this->resourceAccess->projects($user);
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function abortUnlessProjectVisible(Request $request, Project $project): void
|
private function abortUnlessProjectVisible(Request $request, Project $project): void
|
||||||
|
|
@ -844,10 +829,10 @@ class PwaController extends Controller
|
||||||
'overdue' => $project->overdue_tasks_count ?? 0,
|
'overdue' => $project->overdue_tasks_count ?? 0,
|
||||||
],
|
],
|
||||||
'members_preview' => $project->relationLoaded('members')
|
'members_preview' => $project->relationLoaded('members')
|
||||||
? $project->members->take(4)->map(fn(User $member) => [
|
? $project->members->take(4)->map(fn (User $member) => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'avatar_url' => $member->avatar ? asset('storage/' . $member->avatar) : null,
|
'avatar_url' => $member->avatar ? asset('storage/'.$member->avatar) : null,
|
||||||
])->values()
|
])->values()
|
||||||
: [],
|
: [],
|
||||||
];
|
];
|
||||||
|
|
@ -928,7 +913,7 @@ class PwaController extends Controller
|
||||||
{
|
{
|
||||||
$today = now()->toDateString();
|
$today = now()->toDateString();
|
||||||
$tasks = $this->visibleTasksQuery($user)
|
$tasks = $this->visibleTasksQuery($user)
|
||||||
->whereHas('sprints', fn($sprintQuery) => $sprintQuery->where('sprints.id', $sprint->id))
|
->whereHas('sprints', fn ($sprintQuery) => $sprintQuery->where('sprints.id', $sprint->id))
|
||||||
->with(['project:id,title,status', 'assignee:id,name,job_title'])
|
->with(['project:id,title,status', 'assignee:id,name,job_title'])
|
||||||
->withCount(['comments', 'files'])
|
->withCount(['comments', 'files'])
|
||||||
->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end")
|
->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end")
|
||||||
|
|
@ -940,7 +925,7 @@ class PwaController extends Controller
|
||||||
$done = $tasks->where('status', 'done')->count();
|
$done = $tasks->where('status', 'done')->count();
|
||||||
$inProgress = $tasks->whereIn('status', ['in_progress', 'review'])->count();
|
$inProgress = $tasks->whereIn('status', ['in_progress', 'review'])->count();
|
||||||
$overdue = $tasks
|
$overdue = $tasks
|
||||||
->filter(fn(Task $task) => $task->due_date && $task->due_date->format('Y-m-d') < $today && !in_array($task->status, ['done', 'canceled', 'cancelled'], true))
|
->filter(fn (Task $task) => $task->due_date && $task->due_date->format('Y-m-d') < $today && ! in_array($task->status, ['done', 'canceled', 'cancelled'], true))
|
||||||
->count();
|
->count();
|
||||||
$progress = $total > 0 ? (int) round(($done / $total) * 100) : 0;
|
$progress = $total > 0 ? (int) round(($done / $total) * 100) : 0;
|
||||||
$remainingDays = $sprint->end_date ? now()->startOfDay()->diffInDays($sprint->end_date->copy()->startOfDay(), false) : null;
|
$remainingDays = $sprint->end_date ? now()->startOfDay()->diffInDays($sprint->end_date->copy()->startOfDay(), false) : null;
|
||||||
|
|
@ -964,7 +949,7 @@ class PwaController extends Controller
|
||||||
'in_progress' => $inProgress,
|
'in_progress' => $inProgress,
|
||||||
'overdue' => $overdue,
|
'overdue' => $overdue,
|
||||||
],
|
],
|
||||||
'tasks' => $tasks->map(fn(Task $task) => $this->taskCard($task))->values(),
|
'tasks' => $tasks->map(fn (Task $task) => $this->taskCard($task))->values(),
|
||||||
'capabilities' => [
|
'capabilities' => [
|
||||||
'can_create_task' => $user->hasPermission('tasks.create'),
|
'can_create_task' => $user->hasPermission('tasks.create'),
|
||||||
'can_update_task_status' => $user->hasPermission('tasks.edit'),
|
'can_update_task_status' => $user->hasPermission('tasks.edit'),
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->reportService->delayedTasks($request->only([
|
$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([
|
return response()->json([
|
||||||
|
|
@ -53,7 +53,7 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->reportService->teamPerformance($request->only([
|
$data = $this->reportService->teamPerformance($request->only([
|
||||||
'date_from', 'date_to', 'user_id'
|
'date_from', 'date_to', 'user_id',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -91,7 +91,7 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->reportService->sprintProgress($request->only([
|
$data = $this->reportService->sprintProgress($request->only([
|
||||||
'project_id', 'status'
|
'project_id', 'status',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -111,7 +111,7 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->reportService->timeEstimate($request->only([
|
$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([
|
return response()->json([
|
||||||
|
|
@ -131,7 +131,7 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->reportService->recentActivities($request->only([
|
$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([
|
return response()->json([
|
||||||
|
|
@ -151,7 +151,7 @@ class ReportController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->reportService->riskyProjects($request->only([
|
$data = $this->reportService->riskyProjects($request->only([
|
||||||
'status', 'project_id'
|
'status', 'project_id',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\StoreRoleRequest;
|
use App\Http\Requests\StoreRoleRequest;
|
||||||
use App\Http\Requests\UpdateRoleRequest;
|
use App\Http\Requests\UpdateRoleRequest;
|
||||||
use App\Http\Resources\RoleResource;
|
|
||||||
use App\Http\Resources\PermissionResource;
|
use App\Http\Resources\PermissionResource;
|
||||||
|
use App\Http\Resources\RoleResource;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -95,7 +95,7 @@ class RoleController extends Controller
|
||||||
$permissionIds = $data['permissions'] ?? null;
|
$permissionIds = $data['permissions'] ?? null;
|
||||||
unset($data['permissions']);
|
unset($data['permissions']);
|
||||||
|
|
||||||
if (array_key_exists('guard_name', $data) && !$data['guard_name']) {
|
if (array_key_exists('guard_name', $data) && ! $data['guard_name']) {
|
||||||
$data['guard_name'] = 'web';
|
$data['guard_name'] = 'web';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,21 @@
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\BacklogItemResource;
|
||||||
|
use App\Http\Resources\MeetingResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\TaskResource;
|
use App\Http\Resources\TaskResource;
|
||||||
use App\Http\Resources\UserResource;
|
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\BacklogItem;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SearchController extends Controller
|
class SearchController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||||
|
|
||||||
public function search(Request $request): JsonResponse
|
public function search(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -25,11 +25,25 @@ class SearchController extends Controller
|
||||||
$keyword = $request->q;
|
$keyword = $request->q;
|
||||||
$limit = $request->input('limit', 5);
|
$limit = $request->input('limit', 5);
|
||||||
|
|
||||||
$projects = Project::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
$projects = $this->resourceAccess->projects($request->user())
|
||||||
$tasks = Task::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||||
$users = User::where('name', 'like', "%{$keyword}%")->orWhere('email', 'like', "%{$keyword}%")->limit($limit)->get();
|
$tasks = $this->resourceAccess->tasks($request->user())
|
||||||
$meetings = Meeting::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||||
$backlogItems = BacklogItem::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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,12 @@ namespace App\Http\Controllers\Api;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Resources\SprintResource;
|
use App\Http\Resources\SprintResource;
|
||||||
use App\Http\Resources\TaskResource;
|
use App\Http\Resources\TaskResource;
|
||||||
|
use App\Models\Meeting;
|
||||||
|
use App\Models\MeetingType;
|
||||||
use App\Models\Sprint;
|
use App\Models\Sprint;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -14,12 +18,15 @@ use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class SprintController extends Controller
|
class SprintController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||||
|
|
||||||
private array $statuses = ['planning', 'active', 'completed', 'cancelled'];
|
private array $statuses = ['planning', 'active', 'completed', 'cancelled'];
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$query = Sprint::with(['project', 'tasks', 'members']);
|
$query = $this->resourceAccess->sprints($request->user())
|
||||||
|
->with(['project', 'tasks', 'members']);
|
||||||
|
|
||||||
if ($request->filled('project_id')) {
|
if ($request->filled('project_id')) {
|
||||||
$query->where('project_id', $request->project_id);
|
$query->where('project_id', $request->project_id);
|
||||||
|
|
@ -28,7 +35,7 @@ class SprintController extends Controller
|
||||||
$query->where('status', $request->status);
|
$query->where('status', $request->status);
|
||||||
}
|
}
|
||||||
if ($request->boolean('mine')) {
|
if ($request->boolean('mine')) {
|
||||||
$query->whereHas('members', fn($q) => $q->where('users.id', $request->user()->id));
|
$query->whereHas('members', fn ($q) => $q->where('users.id', $request->user()->id));
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = min((int) $request->input('per_page', 15), 100);
|
$perPage = min((int) $request->input('per_page', 15), 100);
|
||||||
|
|
@ -81,15 +88,48 @@ class SprintController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->validatedSprintData($request);
|
$data = $this->validatedSprintData($request);
|
||||||
|
abort_unless(
|
||||||
|
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||||
|
403,
|
||||||
|
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||||
|
);
|
||||||
$memberIds = $data['member_ids'] ?? [];
|
$memberIds = $data['member_ids'] ?? [];
|
||||||
|
$meetingSetup = $data['meeting_setup'] ?? [];
|
||||||
unset($data['member_ids']);
|
unset($data['member_ids']);
|
||||||
|
unset($data['meeting_setup']);
|
||||||
|
|
||||||
$data['created_by'] = $request->user()->id;
|
$data['created_by'] = $request->user()->id;
|
||||||
$data['status'] = 'planning';
|
$data['status'] = 'planning';
|
||||||
|
|
||||||
$sprint = DB::transaction(function () use ($data, $memberIds) {
|
$sprint = DB::transaction(function () use ($data, $memberIds, $meetingSetup, $request) {
|
||||||
$sprint = Sprint::create($data);
|
$sprint = Sprint::create($data);
|
||||||
$sprint->members()->sync($memberIds);
|
$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;
|
return $sprint;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -111,6 +151,13 @@ class SprintController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $this->validatedSprintData($request, true);
|
$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;
|
$memberIds = $data['member_ids'] ?? null;
|
||||||
unset($data['member_ids']);
|
unset($data['member_ids']);
|
||||||
|
|
||||||
|
|
@ -206,7 +253,7 @@ class SprintController extends Controller
|
||||||
'status' => 'required|string|in:waiting,todo,in_progress,review,done',
|
'status' => 'required|string|in:waiting,todo,in_progress,review,done',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$sprint->tasks()->where('tasks.id', $task->id)->exists()) {
|
if (! $sprint->tasks()->where('tasks.id', $task->id)->exists()) {
|
||||||
return response()->json(['success' => false, 'message' => 'این تسک در Sprint انتخابشده نیست'], 404);
|
return response()->json(['success' => false, 'message' => 'این تسک در Sprint انتخابشده نیست'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,7 +274,7 @@ class SprintController extends Controller
|
||||||
public function updateStatus(Request $request, Sprint $sprint): JsonResponse
|
public function updateStatus(Request $request, Sprint $sprint): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$request->validate(['status' => 'required|string|in:' . implode(',', $this->statuses)]);
|
$request->validate(['status' => 'required|string|in:'.implode(',', $this->statuses)]);
|
||||||
$sprint->update(['status' => $request->status]);
|
$sprint->update(['status' => $request->status]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -330,7 +377,7 @@ class SprintController extends Controller
|
||||||
public function cancel(Sprint $sprint): JsonResponse
|
public function cancel(Sprint $sprint): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (!in_array($sprint->status, ['planning', 'active'], true)) {
|
if (! in_array($sprint->status, ['planning', 'active'], true)) {
|
||||||
return response()->json(['success' => false, 'message' => 'این Sprint قابل لغو نیست'], 422);
|
return response()->json(['success' => false, 'message' => 'این Sprint قابل لغو نیست'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,8 +411,8 @@ class SprintController extends Controller
|
||||||
'completion_percentage' => $totalTasks > 0 ? round(($completedTasks / $totalTasks) * 100, 2) : 0,
|
'completion_percentage' => $totalTasks > 0 ? round(($completedTasks / $totalTasks) * 100, 2) : 0,
|
||||||
'completed_by_member' => $tasks
|
'completed_by_member' => $tasks
|
||||||
->where('status', 'done')
|
->where('status', 'done')
|
||||||
->groupBy(fn($task) => $task->assignee?->name ?? 'بدون مسئول')
|
->groupBy(fn ($task) => $task->assignee?->name ?? 'بدون مسئول')
|
||||||
->map(fn($items, $name) => ['name' => $name, 'completed' => $items->count()])
|
->map(fn ($items, $name) => ['name' => $name, 'completed' => $items->count()])
|
||||||
->values(),
|
->values(),
|
||||||
'unfinished_tasks' => TaskResource::collection($tasks->where('status', '!=', 'done')->values()),
|
'unfinished_tasks' => TaskResource::collection($tasks->where('status', '!=', 'done')->values()),
|
||||||
],
|
],
|
||||||
|
|
@ -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
|
private function validatedSprintData(Request $request, bool $partial = false): array
|
||||||
{
|
{
|
||||||
$required = $partial ? 'sometimes|required' : 'required';
|
$required = $partial ? 'sometimes|required' : 'required';
|
||||||
|
|
@ -420,6 +527,18 @@ class SprintController extends Controller
|
||||||
'end_date' => "{$required}|date|after_or_equal:start_date",
|
'end_date' => "{$required}|date|after_or_equal:start_date",
|
||||||
'member_ids' => 'nullable|array',
|
'member_ids' => 'nullable|array',
|
||||||
'member_ids.*' => 'exists:users,id',
|
'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\ActivityLogService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\ProjectProgressService;
|
use App\Services\ProjectProgressService;
|
||||||
|
use App\Services\ResourceAccessService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -20,12 +21,15 @@ class TaskController extends Controller
|
||||||
protected ActivityLogService $activityLogService,
|
protected ActivityLogService $activityLogService,
|
||||||
protected NotificationService $notificationService,
|
protected NotificationService $notificationService,
|
||||||
protected ProjectProgressService $projectProgressService,
|
protected ProjectProgressService $projectProgressService,
|
||||||
|
protected ResourceAccessService $resourceAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
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')) {
|
if ($request->filled('project_id')) {
|
||||||
$query->where('project_id', $request->project_id);
|
$query->where('project_id', $request->project_id);
|
||||||
|
|
@ -92,6 +96,11 @@ class TaskController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
abort_unless(
|
||||||
|
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||||
|
403,
|
||||||
|
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||||
|
);
|
||||||
$data['reporter_id'] = $request->user()->id;
|
$data['reporter_id'] = $request->user()->id;
|
||||||
$data['created_by'] = $request->user()->id;
|
$data['created_by'] = $request->user()->id;
|
||||||
$task = Task::create($data);
|
$task = Task::create($data);
|
||||||
|
|
@ -142,8 +151,16 @@ class TaskController extends Controller
|
||||||
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
|
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
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;
|
$oldStatus = $task->status;
|
||||||
$task->update($request->validated());
|
$task->update($validated);
|
||||||
|
|
||||||
if ($task->wasChanged('status')) {
|
if ($task->wasChanged('status')) {
|
||||||
$this->activityLogService->log(
|
$this->activityLogService->log(
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\StoreUserRequest;
|
use App\Http\Requests\StoreUserRequest;
|
||||||
use App\Http\Requests\UpdateUserRequest;
|
use App\Http\Requests\UpdateUserRequest;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\WorkloadService;
|
use App\Services\WorkloadService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -26,7 +26,7 @@ class UserController extends Controller
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,13 +103,13 @@ class UserController extends Controller
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
if (!empty($data['password'])) {
|
if (! empty($data['password'])) {
|
||||||
$data['password'] = Hash::make($data['password']);
|
$data['password'] = Hash::make($data['password']);
|
||||||
} else {
|
} else {
|
||||||
unset($data['password']);
|
unset($data['password']);
|
||||||
}
|
}
|
||||||
$user->update($data);
|
$user->update($data);
|
||||||
if (!empty($data['role_id'])) {
|
if (! empty($data['role_id'])) {
|
||||||
$user->roles()->sync([$data['role_id']]);
|
$user->roles()->sync([$data['role_id']]);
|
||||||
}
|
}
|
||||||
$user->load(['role', 'roles.permissions']);
|
$user->load(['role', 'roles.permissions']);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ class EnsureUserHasPermission
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Unauthenticated.',
|
'message' => 'Unauthenticated.',
|
||||||
|
|
@ -23,7 +23,7 @@ class EnsureUserHasPermission
|
||||||
|
|
||||||
$isAdmin = $user->roles->contains('name', 'admin');
|
$isAdmin = $user->roles->contains('name', 'admin');
|
||||||
|
|
||||||
if (!$isAdmin && !$user->hasPermission($permission)) {
|
if (! $isAdmin && ! $user->hasPermission($permission)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'شما دسترسی لازم برای انجام این عملیات را ندارید',
|
'message' => 'شما دسترسی لازم برای انجام این عملیات را ندارید',
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class SecurityHeaders
|
||||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
|
||||||
if (!$response->headers->has('Content-Security-Policy')) {
|
if (! $response->headers->has('Content-Security-Policy')) {
|
||||||
$response->headers->set(
|
$response->headers->set(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
"default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'"
|
"default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class ChangePasswordRequest extends FormRequest
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'current_password' => 'required',
|
'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 [
|
return [
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
'project_id' => 'nullable|exists:projects,id',
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'sprint_id' => 'nullable|exists:sprints,id',
|
||||||
|
'meeting_type_id' => 'nullable|exists:meeting_types,id',
|
||||||
'date' => 'required|date',
|
'date' => 'required|date',
|
||||||
'start_time' => 'nullable',
|
'start_time' => 'nullable|date_format:H:i',
|
||||||
'end_time' => 'nullable',
|
'end_time' => 'nullable|date_format:H:i|after:start_time',
|
||||||
'meeting_type' => 'required|string|max:50',
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class StoreTaskRequest extends FormRequest
|
||||||
$user = $this->user()?->loadMissing('roles.permissions');
|
$user = $this->user()?->loadMissing('roles.permissions');
|
||||||
$project = Project::with('members:id')->find($this->input('project_id'));
|
$project = Project::with('members:id')->find($this->input('project_id'));
|
||||||
|
|
||||||
if (!$user || !$project) {
|
if (! $user || ! $project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ class StoreTaskRequest extends FormRequest
|
||||||
|| (int) $project->created_by === (int) $user->id
|
|| (int) $project->created_by === (int) $user->id
|
||||||
|| $project->members->contains('id', $user->id);
|
|| $project->members->contains('id', $user->id);
|
||||||
|
|
||||||
if (!$canUseProject) {
|
if (! $canUseProject) {
|
||||||
$validator->errors()->add('project_id', 'شما به این پروژه دسترسی ندارید.');
|
$validator->errors()->add('project_id', 'شما به این پروژه دسترسی ندارید.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ class StoreTaskRequest extends FormRequest
|
||||||
|| (int) $project->project_manager_id === (int) $assignee->id
|
|| (int) $project->project_manager_id === (int) $assignee->id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$assigneeAllowed) {
|
if (! $assigneeAllowed) {
|
||||||
$validator->errors()->add('assignee_id', 'مسئول انتخابشده برای این پروژه مجاز نیست.');
|
$validator->errors()->add('assignee_id', 'مسئول انتخابشده برای این پروژه مجاز نیست.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class UpdateProfileRequest extends FormRequest
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|email|unique:users,email,' . $this->user()->id,
|
'email' => 'required|email|unique:users,email,'.$this->user()->id,
|
||||||
'phone' => 'nullable|string|max:50',
|
'phone' => 'nullable|string|max:50',
|
||||||
'job_title' => 'nullable|string|max:255',
|
'job_title' => 'nullable|string|max:255',
|
||||||
'department' => 'nullable|string|max:255',
|
'department' => 'nullable|string|max:255',
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ class UpdateRoleRequest extends FormRequest
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$roleId = $this->route('role');
|
$roleId = $this->route('role');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => 'sometimes|required|string|max:255|unique:roles,name,' . $roleId,
|
'name' => 'sometimes|required|string|max:255|unique:roles,name,'.$roleId,
|
||||||
'display_name' => 'sometimes|required|string|max:255',
|
'display_name' => 'sometimes|required|string|max:255',
|
||||||
'description' => 'nullable|string|max:1000',
|
'description' => 'nullable|string|max:1000',
|
||||||
'guard_name' => 'nullable|string|max:50',
|
'guard_name' => 'nullable|string|max:50',
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,10 @@ class UpdateUserRequest extends FormRequest
|
||||||
{
|
{
|
||||||
$userId = $this->route('user');
|
$userId = $this->route('user');
|
||||||
$userId = is_object($userId) ? $userId->id : $userId;
|
$userId = is_object($userId) ? $userId->id : $userId;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => 'sometimes|required|string|max:255',
|
'name' => 'sometimes|required|string|max:255',
|
||||||
'email' => 'sometimes|required|email|unique:users,email,' . $userId,
|
'email' => 'sometimes|required|email|unique:users,email,'.$userId,
|
||||||
'phone' => 'nullable|string|max:50',
|
'phone' => 'nullable|string|max:50',
|
||||||
'job_title' => 'nullable|string|max:255',
|
'job_title' => 'nullable|string|max:255',
|
||||||
'department' => 'nullable|string|max:255',
|
'department' => 'nullable|string|max:255',
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,38 @@ class MeetingResource extends JsonResource
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'project_id' => $this->project_id,
|
'project_id' => $this->project_id,
|
||||||
'project' => new ProjectResource($this->whenLoaded('project')),
|
'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'),
|
'date' => $this->date?->format('Y-m-d'),
|
||||||
'start_time' => $this->start_time,
|
'start_time' => $this->start_time,
|
||||||
'end_time' => $this->end_time,
|
'end_time' => $this->end_time,
|
||||||
'location' => $this->location,
|
'location' => $this->location,
|
||||||
'meeting_link' => $this->meeting_link,
|
'meeting_link' => $this->meeting_link,
|
||||||
'meeting_type' => $this->meeting_type,
|
'meeting_type' => $this->meeting_type,
|
||||||
|
'status' => $this->status ?? 'scheduled',
|
||||||
|
'objective' => $this->objective,
|
||||||
'agenda' => $this->agenda,
|
'agenda' => $this->agenda,
|
||||||
'notes' => $this->notes,
|
'notes' => $this->notes,
|
||||||
|
'summary' => $this->summary,
|
||||||
'decisions' => $this->decisions,
|
'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')),
|
'participants' => UserResource::collection($this->whenLoaded('participants')),
|
||||||
'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')),
|
'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')),
|
||||||
'created_by' => $this->created_by,
|
'created_by' => $this->created_by,
|
||||||
'creator' => new UserResource($this->whenLoaded('creator')),
|
'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,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,20 @@ class NotificationResource extends JsonResource
|
||||||
{
|
{
|
||||||
public function toArray(Request $request): array
|
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 [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'type' => $this->type,
|
'type' => $this->type,
|
||||||
|
|
@ -20,6 +34,16 @@ class NotificationResource extends JsonResource
|
||||||
'notifiable_id' => $this->notifiable_id,
|
'notifiable_id' => $this->notifiable_id,
|
||||||
'is_read' => $this->is_read,
|
'is_read' => $this->is_read,
|
||||||
'read_at' => $this->read_at,
|
'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,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class ProjectResource extends JsonResource
|
||||||
'project_manager_id' => $this->project_manager_id,
|
'project_manager_id' => $this->project_manager_id,
|
||||||
'project_manager' => new UserResource($this->whenLoaded('projectManager')),
|
'project_manager' => new UserResource($this->whenLoaded('projectManager')),
|
||||||
'department_id' => $this->department_id,
|
'department_id' => $this->department_id,
|
||||||
'department' => $this->whenLoaded('department', fn() => [
|
'department' => $this->whenLoaded('department', fn () => [
|
||||||
'id' => $this->department?->id,
|
'id' => $this->department?->id,
|
||||||
'name' => $this->department?->name,
|
'name' => $this->department?->name,
|
||||||
'type' => $this->department?->type,
|
'type' => $this->department?->type,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ class SprintResource extends JsonResource
|
||||||
{
|
{
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$totalTasks = $this->whenLoaded('tasks', fn() => $this->tasks->count(), 0);
|
$totalTasks = $this->whenLoaded('tasks', fn () => $this->tasks->count(), 0);
|
||||||
$completedTasks = $this->whenLoaded('tasks', fn() => $this->tasks->where('status', 'done')->count(), 0);
|
$completedTasks = $this->whenLoaded('tasks', fn () => $this->tasks->where('status', 'done')->count(), 0);
|
||||||
$remainingTasks = max($totalTasks - $completedTasks, 0);
|
$remainingTasks = max($totalTasks - $completedTasks, 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -25,7 +25,7 @@ class SprintResource extends JsonResource
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'tasks' => TaskResource::collection($this->whenLoaded('tasks')),
|
'tasks' => TaskResource::collection($this->whenLoaded('tasks')),
|
||||||
'members' => UserResource::collection($this->whenLoaded('members')),
|
'members' => UserResource::collection($this->whenLoaded('members')),
|
||||||
'retrospective' => $this->whenLoaded('retrospective', fn() => [
|
'retrospective' => $this->whenLoaded('retrospective', fn () => [
|
||||||
'went_well' => $this->retrospective?->went_well,
|
'went_well' => $this->retrospective?->went_well,
|
||||||
'problems' => $this->retrospective?->problems,
|
'problems' => $this->retrospective?->problems,
|
||||||
'improvements' => $this->retrospective?->improvements,
|
'improvements' => $this->retrospective?->improvements,
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ class UserResource extends JsonResource
|
||||||
'job_title' => $this->job_title,
|
'job_title' => $this->job_title,
|
||||||
'department' => $this->department,
|
'department' => $this->department,
|
||||||
'department_id' => $this->department_id,
|
'department_id' => $this->department_id,
|
||||||
'department_name' => $this->when($this->relationLoaded('dept'), fn() => $this->dept?->name),
|
'department_name' => $this->when($this->relationLoaded('dept'), fn () => $this->dept?->name),
|
||||||
'departments' => $this->when($this->relationLoaded('departments'), fn() => $this->departments->map(fn($department) => [
|
'departments' => $this->when($this->relationLoaded('departments'), fn () => $this->departments->map(fn ($department) => [
|
||||||
'id' => $department->id,
|
'id' => $department->id,
|
||||||
'name' => $department->name,
|
'name' => $department->name,
|
||||||
'type' => $department->type,
|
'type' => $department->type,
|
||||||
|
|
@ -29,7 +29,7 @@ class UserResource extends JsonResource
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'skills' => $this->skills,
|
'skills' => $this->skills,
|
||||||
'avatar' => $this->avatar,
|
'avatar' => $this->avatar,
|
||||||
'avatar_url' => $this->avatar ? asset('storage/' . $this->avatar) : null,
|
'avatar_url' => $this->avatar ? asset('storage/'.$this->avatar) : null,
|
||||||
'role_id' => $this->role_id,
|
'role_id' => $this->role_id,
|
||||||
'role' => new RoleResource($this->whenLoaded('role')),
|
'role' => new RoleResource($this->whenLoaded('role')),
|
||||||
'roles' => RoleResource::collection($this->whenLoaded('roles')),
|
'roles' => RoleResource::collection($this->whenLoaded('roles')),
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
protected $fillable = [
|
||||||
'user_id', 'action', 'description', 'subject_type', 'subject_id',
|
'user_id', 'action', 'description', 'subject_type', 'subject_id',
|
||||||
'project_id', 'task_id', 'properties'
|
'project_id', 'task_id', 'properties',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class BacklogItem extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'title', 'description', 'type', 'project_id', 'priority',
|
'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
|
public function project(): BelongsTo
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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
|
class Meeting extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'title', 'project_id', 'date', 'start_time', 'end_time', 'location',
|
'title', 'project_id', 'sprint_id', 'meeting_type_id', 'date', 'start_time',
|
||||||
'meeting_link', 'meeting_type', 'agenda', 'notes', 'decisions', 'created_by'
|
'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 = [
|
protected $casts = [
|
||||||
'date' => 'date',
|
'date' => 'date',
|
||||||
|
'recurrence_rule' => 'array',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
|
|
@ -29,6 +34,26 @@ class Meeting extends Model
|
||||||
return $this->belongsTo(User::class, 'created_by');
|
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
|
public function participants(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps();
|
return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps();
|
||||||
|
|
@ -39,6 +64,21 @@ class Meeting extends Model
|
||||||
return $this->hasMany(MeetingActionItem::class);
|
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
|
public function comments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(Comment::class, 'commentable');
|
return $this->morphMany(Comment::class, 'commentable');
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
class MeetingActionItem extends Model
|
class MeetingActionItem extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
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 = [
|
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;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Notification extends Model
|
class Notification extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id', 'title', 'body', 'type', 'notifiable_type', 'notifiable_id',
|
'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 = [
|
protected $casts = [
|
||||||
|
|
@ -17,5 +20,6 @@ class Notification extends Model
|
||||||
'read_at' => 'datetime',
|
'read_at' => 'datetime',
|
||||||
'remind_at' => 'datetime',
|
'remind_at' => 'datetime',
|
||||||
'dismissed_at' => 'datetime',
|
'dismissed_at' => 'datetime',
|
||||||
|
'archived_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class Project extends Model
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date',
|
'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date',
|
||||||
'priority', 'status', 'progress', 'risk_level', 'budget', 'estimated_hours',
|
'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 = [
|
protected $casts = [
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class Setting extends Model
|
class Setting extends Model
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,29 @@ class Sprint extends Model
|
||||||
{
|
{
|
||||||
return $this->hasMany(BacklogItem::class, 'assigned_sprint_id');
|
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 = [
|
protected $fillable = [
|
||||||
'title', 'description', 'project_id', 'assignee_id', 'reporter_id',
|
'title', 'description', 'project_id', 'assignee_id', 'reporter_id',
|
||||||
'priority', 'status', 'blocker_type', 'blocker_note', 'start_date', 'due_date', 'estimated_time',
|
'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 = [
|
protected $casts = [
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ class User extends Authenticatable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +74,7 @@ class User extends Authenticatable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
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;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\ActionItem;
|
||||||
use App\Models\Task;
|
|
||||||
use App\Models\Meeting;
|
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
|
use App\Models\Blocker;
|
||||||
|
use App\Models\Meeting;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\Sprint;
|
use App\Models\Sprint;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class DashboardService
|
class DashboardService
|
||||||
|
|
@ -34,7 +37,7 @@ class DashboardService
|
||||||
->orderBy('due_date')
|
->orderBy('due_date')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get()
|
->get()
|
||||||
->map(fn($t) => [
|
->map(fn ($t) => [
|
||||||
'id' => $t->id,
|
'id' => $t->id,
|
||||||
'title' => $t->title,
|
'title' => $t->title,
|
||||||
'project' => $t->project?->title,
|
'project' => $t->project?->title,
|
||||||
|
|
@ -46,7 +49,7 @@ class DashboardService
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get()
|
->get()
|
||||||
->map(fn($log) => [
|
->map(fn ($log) => [
|
||||||
'id' => $log->id,
|
'id' => $log->id,
|
||||||
'description' => $log->description,
|
'description' => $log->description,
|
||||||
'user' => $log->user?->name ?? '?',
|
'user' => $log->user?->name ?? '?',
|
||||||
|
|
@ -67,7 +70,7 @@ class DashboardService
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$sprint = Sprint::with(['project', 'tasks.assignee'])
|
$sprint = Sprint::with(['project', 'tasks.assignee'])
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereHas('members', fn($query) => $query->where('users.id', $user->id))
|
->whereHas('members', fn ($query) => $query->where('users.id', $user->id))
|
||||||
->orderBy('end_date')
|
->orderBy('end_date')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|
@ -84,7 +87,7 @@ class DashboardService
|
||||||
'my_tasks' => $sprint->tasks
|
'my_tasks' => $sprint->tasks
|
||||||
->where('assignee_id', $user->id)
|
->where('assignee_id', $user->id)
|
||||||
->values()
|
->values()
|
||||||
->map(fn($task) => [
|
->map(fn ($task) => [
|
||||||
'id' => $task->id,
|
'id' => $task->id,
|
||||||
'title' => $task->title,
|
'title' => $task->title,
|
||||||
'status' => $task->status,
|
'status' => $task->status,
|
||||||
|
|
@ -94,7 +97,7 @@ class DashboardService
|
||||||
}
|
}
|
||||||
|
|
||||||
$todayMeetings = Meeting::whereDate('date', Carbon::today())->count();
|
$todayMeetings = Meeting::whereDate('date', Carbon::today())->count();
|
||||||
$teamMembers = \App\Models\User::count();
|
$teamMembers = User::count();
|
||||||
|
|
||||||
$projectProgresses = Project::where('is_archived', false)
|
$projectProgresses = Project::where('is_archived', false)
|
||||||
->select(['id', 'title', 'progress', 'status'])
|
->select(['id', 'title', 'progress', 'status'])
|
||||||
|
|
@ -159,4 +162,161 @@ class DashboardService
|
||||||
'monthly_tasks' => $monthlyTasks,
|
'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) {
|
if ($totalTasks === 0) {
|
||||||
$project->update(['progress' => 0]);
|
$project->update(['progress' => 0]);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Models\Sprint;
|
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Sprint;
|
||||||
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
|
@ -13,12 +13,13 @@ class ReportService
|
||||||
{
|
{
|
||||||
protected function applyDateFilters($query, $filters, $dateField = 'created_at')
|
protected function applyDateFilters($query, $filters, $dateField = 'created_at')
|
||||||
{
|
{
|
||||||
if (!empty($filters['date_from'])) {
|
if (! empty($filters['date_from'])) {
|
||||||
$query->where($dateField, '>=', $filters['date_from']);
|
$query->where($dateField, '>=', $filters['date_from']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['date_to'])) {
|
if (! empty($filters['date_to'])) {
|
||||||
$query->where($dateField, '<=', $filters['date_to']);
|
$query->where($dateField, '<=', $filters['date_to']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,17 +27,17 @@ class ReportService
|
||||||
{
|
{
|
||||||
$query = Project::query();
|
$query = Project::query();
|
||||||
|
|
||||||
if (!empty($filters['status'])) {
|
if (! empty($filters['status'])) {
|
||||||
$query->where('status', $filters['status']);
|
$query->where('status', $filters['status']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['project_id'])) {
|
if (! empty($filters['project_id'])) {
|
||||||
$query->where('id', $filters['project_id']);
|
$query->where('id', $filters['project_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$projects = $query->get();
|
$projects = $query->get();
|
||||||
$grouped = $projects->groupBy('status');
|
$grouped = $projects->groupBy('status');
|
||||||
|
|
||||||
return $grouped->map(fn($items, $status) => [
|
return $grouped->map(fn ($items, $status) => [
|
||||||
'status' => $status === 'planning' ? 'برنامهریزی' : ($status === 'in_progress' ? 'در حال انجام' : ($status === 'done' ? 'تکمیل شده' : ($status === 'on_hold' ? 'متوقف' : $status))),
|
'status' => $status === 'planning' ? 'برنامهریزی' : ($status === 'in_progress' ? 'در حال انجام' : ($status === 'done' ? 'تکمیل شده' : ($status === 'on_hold' ? 'متوقف' : $status))),
|
||||||
'count' => $items->count(),
|
'count' => $items->count(),
|
||||||
'label' => $status,
|
'label' => $status,
|
||||||
|
|
@ -52,17 +53,17 @@ class ReportService
|
||||||
|
|
||||||
$query = $this->applyDateFilters($query, $filters, 'due_date');
|
$query = $this->applyDateFilters($query, $filters, 'due_date');
|
||||||
|
|
||||||
if (!empty($filters['project_id'])) {
|
if (! empty($filters['project_id'])) {
|
||||||
$query->where('project_id', $filters['project_id']);
|
$query->where('project_id', $filters['project_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['user_id'])) {
|
if (! empty($filters['user_id'])) {
|
||||||
$query->where('assignee_id', $filters['user_id']);
|
$query->where('assignee_id', $filters['user_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['status'])) {
|
if (! empty($filters['status'])) {
|
||||||
$query->where('status', $filters['status']);
|
$query->where('status', $filters['status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->orderBy('due_date')->get()->map(fn($t) => [
|
return $query->orderBy('due_date')->get()->map(fn ($t) => [
|
||||||
'id' => $t->id,
|
'id' => $t->id,
|
||||||
'title' => $t->title,
|
'title' => $t->title,
|
||||||
'project' => ['title' => $t->project?->title],
|
'project' => ['title' => $t->project?->title],
|
||||||
|
|
@ -79,15 +80,15 @@ class ReportService
|
||||||
public function teamPerformance($filters = [])
|
public function teamPerformance($filters = [])
|
||||||
{
|
{
|
||||||
$query = User::with(['tasks' => function ($q) use ($filters) {
|
$query = User::with(['tasks' => function ($q) use ($filters) {
|
||||||
if (!empty($filters['date_from'])) {
|
if (! empty($filters['date_from'])) {
|
||||||
$q->where('created_at', '>=', $filters['date_from']);
|
$q->where('created_at', '>=', $filters['date_from']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['date_to'])) {
|
if (! empty($filters['date_to'])) {
|
||||||
$q->where('created_at', '<=', $filters['date_to']);
|
$q->where('created_at', '<=', $filters['date_to']);
|
||||||
}
|
}
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
if (!empty($filters['user_id'])) {
|
if (! empty($filters['user_id'])) {
|
||||||
$query->where('id', $filters['user_id']);
|
$query->where('id', $filters['user_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +99,7 @@ class ReportService
|
||||||
$completedTasks = $user->tasks->where('status', 'done')->count();
|
$completedTasks = $user->tasks->where('status', 'done')->count();
|
||||||
$delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count();
|
$delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count();
|
||||||
$inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count();
|
$inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'completed' => $completedTasks,
|
'completed' => $completedTasks,
|
||||||
|
|
@ -112,7 +114,7 @@ class ReportService
|
||||||
{
|
{
|
||||||
$query = User::query();
|
$query = User::query();
|
||||||
|
|
||||||
if (!empty($filters['user_id'])) {
|
if (! empty($filters['user_id'])) {
|
||||||
$query->where('id', $filters['user_id']);
|
$query->where('id', $filters['user_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,10 +142,10 @@ class ReportService
|
||||||
{
|
{
|
||||||
$query = Sprint::with(['project:id,title', 'tasks']);
|
$query = Sprint::with(['project:id,title', 'tasks']);
|
||||||
|
|
||||||
if (!empty($filters['project_id'])) {
|
if (! empty($filters['project_id'])) {
|
||||||
$query->where('project_id', $filters['project_id']);
|
$query->where('project_id', $filters['project_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['status'])) {
|
if (! empty($filters['status'])) {
|
||||||
$query->where('status', $filters['status']);
|
$query->where('status', $filters['status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +154,7 @@ class ReportService
|
||||||
return $sprints->map(function ($sprint) {
|
return $sprints->map(function ($sprint) {
|
||||||
$totalTasks = $sprint->tasks->count();
|
$totalTasks = $sprint->tasks->count();
|
||||||
$completedTasks = $sprint->tasks->where('status', 'done')->count();
|
$completedTasks = $sprint->tasks->where('status', 'done')->count();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $sprint->id,
|
'id' => $sprint->id,
|
||||||
'title' => $sprint->title,
|
'title' => $sprint->title,
|
||||||
|
|
@ -171,13 +174,13 @@ class ReportService
|
||||||
$query = Task::with('project');
|
$query = Task::with('project');
|
||||||
$query = $this->applyDateFilters($query, $filters);
|
$query = $this->applyDateFilters($query, $filters);
|
||||||
|
|
||||||
if (!empty($filters['project_id'])) {
|
if (! empty($filters['project_id'])) {
|
||||||
$query->where('project_id', $filters['project_id']);
|
$query->where('project_id', $filters['project_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['user_id'])) {
|
if (! empty($filters['user_id'])) {
|
||||||
$query->where('assignee_id', $filters['user_id']);
|
$query->where('assignee_id', $filters['user_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['status'])) {
|
if (! empty($filters['status'])) {
|
||||||
$query->where('status', $filters['status']);
|
$query->where('status', $filters['status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,6 +188,7 @@ class ReportService
|
||||||
|
|
||||||
return $tasks->map(function ($task) {
|
return $tasks->map(function ($task) {
|
||||||
$diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0);
|
$diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $task->id,
|
'id' => $task->id,
|
||||||
'title' => $task->title,
|
'title' => $task->title,
|
||||||
|
|
@ -205,17 +209,17 @@ class ReportService
|
||||||
$query = ActivityLog::with('user');
|
$query = ActivityLog::with('user');
|
||||||
$query = $this->applyDateFilters($query, $filters);
|
$query = $this->applyDateFilters($query, $filters);
|
||||||
|
|
||||||
if (!empty($filters['project_id'])) {
|
if (! empty($filters['project_id'])) {
|
||||||
$query->where('project_id', $filters['project_id']);
|
$query->where('project_id', $filters['project_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['user_id'])) {
|
if (! empty($filters['user_id'])) {
|
||||||
$query->where('user_id', $filters['user_id']);
|
$query->where('user_id', $filters['user_id']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['action'])) {
|
if (! empty($filters['action'])) {
|
||||||
$query->where('action', $filters['action']);
|
$query->where('action', $filters['action']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->orderBy('created_at', 'desc')->limit(50)->get()->map(fn($log) => [
|
return $query->orderBy('created_at', 'desc')->limit(50)->get()->map(fn ($log) => [
|
||||||
'id' => $log->id,
|
'id' => $log->id,
|
||||||
'description' => $log->description,
|
'description' => $log->description,
|
||||||
'user' => $log->user?->name ?? '?',
|
'user' => $log->user?->name ?? '?',
|
||||||
|
|
@ -229,14 +233,14 @@ class ReportService
|
||||||
{
|
{
|
||||||
$query = Project::whereIn('risk_level', ['high', 'critical'])->where('is_archived', false);
|
$query = Project::whereIn('risk_level', ['high', 'critical'])->where('is_archived', false);
|
||||||
|
|
||||||
if (!empty($filters['status'])) {
|
if (! empty($filters['status'])) {
|
||||||
$query->where('status', $filters['status']);
|
$query->where('status', $filters['status']);
|
||||||
}
|
}
|
||||||
if (!empty($filters['project_id'])) {
|
if (! empty($filters['project_id'])) {
|
||||||
$query->where('id', $filters['project_id']);
|
$query->where('id', $filters['project_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->with('projectManager:id,name')->get()->map(fn($p) => [
|
return $query->with('projectManager:id,name')->get()->map(fn ($p) => [
|
||||||
'id' => $p->id,
|
'id' => $p->id,
|
||||||
'title' => $p->title,
|
'title' => $p->title,
|
||||||
'risk_level' => $p->risk_level,
|
'risk_level' => $p->risk_level,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class WorkloadService
|
class WorkloadService
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use App\Http\Middleware\EnsureResourceAccess;
|
||||||
use App\Http\Middleware\EnsureUserHasPermission;
|
use App\Http\Middleware\EnsureUserHasPermission;
|
||||||
use App\Http\Middleware\SecurityHeaders;
|
use App\Http\Middleware\SecurityHeaders;
|
||||||
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -19,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
$middleware->append(SecurityHeaders::class);
|
$middleware->append(SecurityHeaders::class);
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'permission' => EnsureUserHasPermission::class,
|
'permission' => EnsureUserHasPermission::class,
|
||||||
|
'resource.access' => EnsureResourceAccess::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->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' => [
|
'mariadb' => [
|
||||||
'driver' => 'mariadb',
|
'driver' => 'mariadb',
|
||||||
'url' => env('DB_URL'),
|
'url' => env('DB_URL'),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ return [
|
||||||
|
|
||||||
'guard' => ['web'],
|
'guard' => ['web'],
|
||||||
|
|
||||||
'expiration' => null,
|
'expiration' => (int) env('SANCTUM_TOKEN_EXPIRATION', 480),
|
||||||
|
|
||||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,13 @@ return new class extends Migration
|
||||||
$roleIds = DB::table('roles')->pluck('id', 'name');
|
$roleIds = DB::table('roles')->pluck('id', 'name');
|
||||||
$permissionIds = DB::table('permissions')->pluck('id', 'name');
|
$permissionIds = DB::table('permissions')->pluck('id', 'name');
|
||||||
$syncRole = function (string $roleName, array $modules, array $actions) use ($roleIds, $permissionIds, $now) {
|
$syncRole = function (string $roleName, array $modules, array $actions) use ($roleIds, $permissionIds, $now) {
|
||||||
if (!$roleIds->has($roleName)) {
|
if (! $roleIds->has($roleName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($permissionIds as $permissionName => $permissionId) {
|
foreach ($permissionIds as $permissionName => $permissionId) {
|
||||||
[$module, $action] = explode('.', $permissionName);
|
[$module, $action] = explode('.', $permissionName);
|
||||||
if (!in_array($module, $modules, true) || !in_array($action, $actions, true)) {
|
if (! in_array($module, $modules, true) || ! in_array($action, $actions, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ return new class extends Migration
|
||||||
$legacyId = DB::table('roles')->where('name', 'team_member')->value('id');
|
$legacyId = DB::table('roles')->where('name', 'team_member')->value('id');
|
||||||
$specialistId = DB::table('roles')->where('name', 'specialist')->value('id');
|
$specialistId = DB::table('roles')->where('name', 'specialist')->value('id');
|
||||||
|
|
||||||
if (!$legacyId || !$specialistId) {
|
if (! $legacyId || ! $specialistId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ return new class extends Migration
|
||||||
DB::table('users')->whereNull('status')->update(['status' => 'active']);
|
DB::table('users')->whereNull('status')->update(['status' => 'active']);
|
||||||
|
|
||||||
$specialistId = DB::table('roles')->where('name', 'specialist')->value('id');
|
$specialistId = DB::table('roles')->where('name', 'specialist')->value('id');
|
||||||
if (!$specialistId) {
|
if (! $specialistId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ return new class extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('sprints', function (Blueprint $table) {
|
Schema::table('sprints', function (Blueprint $table) {
|
||||||
if (!Schema::hasColumn('sprints', 'capacity_hours')) {
|
if (! Schema::hasColumn('sprints', 'capacity_hours')) {
|
||||||
$table->decimal('capacity_hours', 8, 2)->nullable()->after('goal');
|
$table->decimal('capacity_hours', 8, 2)->nullable()->after('goal');
|
||||||
}
|
}
|
||||||
if (!Schema::hasColumn('sprints', 'completed_at')) {
|
if (! Schema::hasColumn('sprints', 'completed_at')) {
|
||||||
$table->timestamp('completed_at')->nullable()->after('status');
|
$table->timestamp('completed_at')->nullable()->after('status');
|
||||||
}
|
}
|
||||||
if (!Schema::hasColumn('sprints', 'completion_summary')) {
|
if (! Schema::hasColumn('sprints', 'completion_summary')) {
|
||||||
$table->json('completion_summary')->nullable()->after('completed_at');
|
$table->json('completion_summary')->nullable()->after('completed_at');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ return new class extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('departments', function (Blueprint $table) {
|
Schema::table('departments', function (Blueprint $table) {
|
||||||
if (!Schema::hasColumn('departments', 'type')) {
|
if (! Schema::hasColumn('departments', 'type')) {
|
||||||
$table->string('type')->default('department')->after('description');
|
$table->string('type')->default('department')->after('description');
|
||||||
}
|
}
|
||||||
if (!Schema::hasColumn('departments', 'manager_changed_at')) {
|
if (! Schema::hasColumn('departments', 'manager_changed_at')) {
|
||||||
$table->timestamp('manager_changed_at')->nullable()->after('manager_id');
|
$table->timestamp('manager_changed_at')->nullable()->after('manager_id');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ return new class extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('projects', function (Blueprint $table) {
|
Schema::table('projects', function (Blueprint $table) {
|
||||||
if (!Schema::hasColumn('projects', 'department_id')) {
|
if (! Schema::hasColumn('projects', 'department_id')) {
|
||||||
$table->foreignId('department_id')->nullable()->after('project_manager_id')->constrained('departments')->onDelete('set null');
|
$table->foreignId('department_id')->nullable()->after('project_manager_id')->constrained('departments')->onDelete('set null');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ return new class extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('tasks', function (Blueprint $table) {
|
Schema::table('tasks', function (Blueprint $table) {
|
||||||
if (!Schema::hasColumn('tasks', 'blocker_type')) {
|
if (! Schema::hasColumn('tasks', 'blocker_type')) {
|
||||||
$table->string('blocker_type')->nullable()->after('status');
|
$table->string('blocker_type')->nullable()->after('status');
|
||||||
}
|
}
|
||||||
if (!Schema::hasColumn('tasks', 'blocker_note')) {
|
if (! Schema::hasColumn('tasks', 'blocker_note')) {
|
||||||
$table->text('blocker_note')->nullable()->after('blocker_type');
|
$table->text('blocker_note')->nullable()->after('blocker_type');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ return new class extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('notifications', function (Blueprint $table) {
|
Schema::table('notifications', function (Blueprint $table) {
|
||||||
if (!Schema::hasColumn('notifications', 'notifiable_type')) {
|
if (! Schema::hasColumn('notifications', 'notifiable_type')) {
|
||||||
$table->string('notifiable_type')->nullable()->after('type');
|
$table->string('notifiable_type')->nullable()->after('type');
|
||||||
}
|
}
|
||||||
if (!Schema::hasColumn('notifications', 'notifiable_id')) {
|
if (! Schema::hasColumn('notifications', 'notifiable_id')) {
|
||||||
$table->unsignedBigInteger('notifiable_id')->nullable()->after('notifiable_type');
|
$table->unsignedBigInteger('notifiable_id')->nullable()->after('notifiable_type');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ return new class extends Migration
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('settings', function (Blueprint $table) {
|
Schema::table('settings', function (Blueprint $table) {
|
||||||
if (!Schema::hasColumn('settings', 'type')) {
|
if (! Schema::hasColumn('settings', 'type')) {
|
||||||
$table->string('type', 50)->nullable()->after('group');
|
$table->string('type', 50)->nullable()->after('group');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
|
@ -12,22 +12,25 @@ class DatabaseSeeder extends Seeder
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
DB::statement('PRAGMA foreign_keys = OFF');
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
$this->call([
|
try {
|
||||||
RolePermissionSeeder::class,
|
$this->call([
|
||||||
UserSeeder::class,
|
RolePermissionSeeder::class,
|
||||||
ProjectSeeder::class,
|
UserSeeder::class,
|
||||||
TaskSeeder::class,
|
ProjectSeeder::class,
|
||||||
SprintSeeder::class,
|
TaskSeeder::class,
|
||||||
BacklogSeeder::class,
|
SprintSeeder::class,
|
||||||
MeetingSeeder::class,
|
BacklogSeeder::class,
|
||||||
CommentSeeder::class,
|
MeetingTypeSeeder::class,
|
||||||
ActivityLogSeeder::class,
|
MeetingSeeder::class,
|
||||||
NotificationSeeder::class,
|
CommentSeeder::class,
|
||||||
SettingSeeder::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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,7 +68,7 @@ class RolePermissionSeeder extends Seeder
|
||||||
];
|
];
|
||||||
$permissions[] = [
|
$permissions[] = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'display_name' => $displayName . ' ' . $moduleDisplay[$module],
|
'display_name' => $displayName.' '.$moduleDisplay[$module],
|
||||||
'guard_name' => 'web',
|
'guard_name' => 'web',
|
||||||
'module' => $module,
|
'module' => $module,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,50 @@ class SettingSeeder extends Seeder
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_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) {
|
foreach ($settings as $setting) {
|
||||||
DB::table('settings')->insert($setting);
|
DB::table('settings')->updateOrInsert(['key' => $setting['key']], $setting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,34 @@
|
||||||
<?php
|
<?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\ActivityLogController;
|
||||||
use App\Http\Controllers\Api\NotificationController;
|
use App\Http\Controllers\Api\AuthController;
|
||||||
use App\Http\Controllers\Api\ReportController;
|
use App\Http\Controllers\Api\BacklogController;
|
||||||
use App\Http\Controllers\Api\SearchController;
|
use App\Http\Controllers\Api\CalendarController;
|
||||||
use App\Http\Controllers\Api\RoleController;
|
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\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\PermissionController;
|
||||||
use App\Http\Controllers\Api\SettingController;
|
use App\Http\Controllers\Api\ProjectController;
|
||||||
use App\Http\Controllers\Api\PwaController;
|
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('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
||||||
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->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::post('/logout', [AuthController::class, 'logout']);
|
||||||
Route::get('/user', [AuthController::class, 'user']);
|
Route::get('/user', [AuthController::class, 'user']);
|
||||||
Route::put('/user/profile', [AuthController::class, 'updateProfile']);
|
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/summary', [DashboardController::class, 'summary'])->middleware('permission:reports.view');
|
||||||
Route::get('/dashboard/charts', [DashboardController::class, 'charts'])->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::get('/users', [UserController::class, 'index'])->middleware(['permission:team.view', 'throttle:sensitive']);
|
||||||
Route::post('/users', [UserController::class, 'store'])->middleware(['permission:team.create', '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::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::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::delete('/users/{user}', [UserController::class, 'destroy'])->middleware(['permission:team.delete', 'throttle:sensitive']);
|
||||||
Route::patch('/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']);
|
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::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}/workload', [UserController::class, 'updateWorkload'])->middleware('permission:team.view');
|
||||||
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats']);
|
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats'])->middleware('permission:team.view');
|
||||||
|
|
||||||
Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view');
|
Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view');
|
||||||
Route::post('/projects', [ProjectController::class, 'store'])->middleware('permission:projects.create');
|
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('/my-tasks', [TaskController::class, 'myTasks']);
|
||||||
Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']);
|
Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']);
|
||||||
|
|
||||||
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow();
|
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow()->middleware('permission:tasks.edit');
|
||||||
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete']);
|
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete'])->middleware('permission:tasks.edit');
|
||||||
|
|
||||||
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow();
|
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow()->middleware('permission:tasks.edit');
|
||||||
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus']);
|
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus'])->middleware('permission:tasks.edit');
|
||||||
|
|
||||||
Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view');
|
Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view');
|
||||||
Route::post('/sprints', [SprintController::class, 'store'])->middleware('permission:sprints.create');
|
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}', [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::put('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit');
|
||||||
Route::patch('/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');
|
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::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::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::get('/meetings', [MeetingController::class, 'index'])->middleware('permission:meetings.view');
|
||||||
Route::post('/meetings', [MeetingController::class, 'store'])->middleware('permission:meetings.create');
|
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}', [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::put('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit');
|
||||||
Route::patch('/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::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::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::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');
|
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::get('/notifications', [NotificationController::class, 'index'])->middleware('permission:notifications.view');
|
||||||
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->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::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->middleware('permission:notifications.view');
|
||||||
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view');
|
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class AuthTest extends TestCase
|
class AuthTest extends TestCase
|
||||||
|
|
@ -91,4 +93,41 @@ class AuthTest extends TestCase
|
||||||
->getJson('/api/user')
|
->getJson('/api/user')
|
||||||
->assertUnauthorized();
|
->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;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\File;
|
use App\Models\File;
|
||||||
|
use App\Models\Meeting;
|
||||||
|
use App\Models\MeetingActionItem;
|
||||||
|
use App\Models\Permission;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
@ -84,4 +88,118 @@ class SecurityHardeningTest extends TestCase
|
||||||
->assertForbidden()
|
->assertForbidden()
|
||||||
->assertJsonPath('success', false);
|
->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)
|
$this->withToken($token)
|
||||||
->getJson('/api/settings')
|
->getJson('/api/settings')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.0.value', 'شرکت پیشگام');
|
->assertJsonFragment([
|
||||||
|
'key' => 'company_name',
|
||||||
|
'value' => 'شرکت پیشگام',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->withToken($token)
|
$this->withToken($token)
|
||||||
->putJson("/api/settings/{$plain->id}", [
|
->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.
|
||||||
|
|
@ -10,6 +10,8 @@ lerna-debug.log*
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# 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">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>مدیریت پروژه | سامانه مدیریت پروژههای سازمانی</title>
|
<title>مدیریت پروژه | سامانه مدیریت پروژههای سازمانی</title>
|
||||||
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css" rel="stylesheet" type="text/css" />
|
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css" rel="stylesheet" type="text/css" />
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"recharts": "^3.9.0"
|
"recharts": "^3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
|
@ -434,6 +435,22 @@
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"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": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.12.0",
|
"version": "2.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
|
@ -1824,6 +1841,53 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
"recharts": "^3.9.0"
|
"recharts": "^3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
پس از عرض: | ارتفاع: | اندازه: 51 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 330 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 45 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 873 B |
|
پس از عرض: | ارتفاع: | اندازه: 2.5 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.3 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.3 MiB |
|
|
@ -7,13 +7,19 @@
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"start_url": "/pwa",
|
"start_url": "/pwa",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"theme_color": "#2563eb",
|
"theme_color": "#061a3a",
|
||||||
"background_color": "#f8fafc",
|
"background_color": "#061a3a",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.svg",
|
"src": "/brand/app-icon-192.png",
|
||||||
"sizes": "any",
|
"sizes": "192x192",
|
||||||
"type": "image/svg+xml",
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/brand/app-icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,61 +3,65 @@ import { Toaster } from 'react-hot-toast';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Header from './components/Header';
|
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 Profile from './pages/Profile';
|
||||||
import Organization from './pages/Organization';
|
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||||
import PwaLayout from './pwa/PwaLayout';
|
|
||||||
import { defaultRouteForAppMode, isStandalonePwa } from './utils/appMode';
|
import { defaultRouteForAppMode, isStandalonePwa } from './utils/appMode';
|
||||||
import './styles/pwa.css';
|
import './styles/pwa.css';
|
||||||
|
|
||||||
const pageTitles = {
|
const Login = lazy(() => import('./pages/Login'));
|
||||||
'/': 'داشبورد',
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
'/projects': 'پروژهها',
|
const Projects = lazy(() => import('./pages/Projects'));
|
||||||
'/tasks': 'تسکها',
|
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'));
|
||||||
'/kanban': 'برد کاری',
|
const Tasks = lazy(() => import('./pages/Tasks'));
|
||||||
'/backlog': 'بکلاگ',
|
const Kanban = lazy(() => import('./pages/Kanban'));
|
||||||
'/sprints': 'اسپرینتها',
|
const Backlog = lazy(() => import('./pages/Backlog'));
|
||||||
'/team': 'اعضای تیم',
|
const Sprints = lazy(() => import('./pages/Sprints'));
|
||||||
'/meetings': 'جلسات',
|
const Team = lazy(() => import('./pages/Team'));
|
||||||
'/files': 'فایلها',
|
const MeetingList = lazy(() => import('./pages/MeetingList'));
|
||||||
'/reports': 'گزارشها',
|
const Files = lazy(() => import('./pages/Files'));
|
||||||
'/notifications': 'اعلانها',
|
const Reports = lazy(() => import('./pages/Reports'));
|
||||||
'/roles': 'نقشها و دسترسیها',
|
const Notifications = lazy(() => import('./pages/Notifications'));
|
||||||
'/organization': 'ساختار سازمانی',
|
const Roles = lazy(() => import('./pages/Roles'));
|
||||||
'/settings': 'تنظیمات',
|
const Settings = lazy(() => import('./pages/Settings'));
|
||||||
'/profile': 'پروفایل کاربری',
|
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 }) {
|
function PageFallback() {
|
||||||
const { user, loading } = useAuth();
|
return <div className="page-loading" role="status">در حال بارگذاری...</div>;
|
||||||
if (loading) return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>در حال بارگذاری...</div>;
|
|
||||||
if (!user) return <Navigate to="/login" />;
|
|
||||||
return children;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppLayout({ children, title }) {
|
function AppLayout({ children, title }) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
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 (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
|
||||||
<div className="main-content" style={{ marginRight: collapsed ? '72px' : 'var(--sidebar-width)', transition: 'margin-right 0.2s ease' }}>
|
{mobileNavOpen && <button type="button" className="mobile-sidebar-overlay" aria-label="بستن منوی اصلی" onClick={() => setMobileNavOpen(false)} />}
|
||||||
<Header title={title} />
|
<div className={`main-content ${collapsed ? 'sidebar-collapsed' : ''}`}>
|
||||||
|
<Header title={title} collapsed={collapsed} mobileNavOpen={mobileNavOpen} onToggleMobileNav={toggleMobileNav} />
|
||||||
<div className="main-inner">{children}</div>
|
<div className="main-inner">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,10 +81,12 @@ function AppRoutes() {
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="/login" element={<Login />} />
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,27 +94,31 @@ function AppRoutes() {
|
||||||
if (isStandalonePwa() && !isPwaPath) return <Navigate to="/pwa" replace />;
|
if (isStandalonePwa() && !isPwaPath) return <Navigate to="/pwa" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Route path="/login" element={<Navigate to={defaultRouteForAppMode()} replace />} />
|
<Routes>
|
||||||
<Route path="/pwa/*" element={<PwaLayout />} />
|
<Route path="/login" element={<Navigate to={defaultRouteForAppMode()} replace />} />
|
||||||
<Route path="/" element={<DefaultAppRoute />} />
|
<Route path="/pwa/*" element={<PwaLayout />} />
|
||||||
<Route path="/projects" element={<AppLayout title="پروژهها"><Projects /></AppLayout>} />
|
<Route path="/" element={<DefaultAppRoute />} />
|
||||||
<Route path="/projects/:id" element={<AppLayout title="جزئیات پروژه"><ProjectDetail /></AppLayout>} />
|
<Route path="/projects" element={<AppLayout title="پروژهها"><Projects /></AppLayout>} />
|
||||||
<Route path="/tasks" element={<AppLayout title="تسکها"><Tasks /></AppLayout>} />
|
<Route path="/projects/:id" element={<AppLayout title="جزئیات پروژه"><ProjectDetail /></AppLayout>} />
|
||||||
<Route path="/kanban" element={<AppLayout title="برد کاری"><Kanban /></AppLayout>} />
|
<Route path="/tasks" element={<AppLayout title="تسکها"><Tasks /></AppLayout>} />
|
||||||
<Route path="/backlog" element={<AppLayout title="بکلاگ"><Backlog /></AppLayout>} />
|
<Route path="/kanban" element={<AppLayout title="برد کاری"><Kanban /></AppLayout>} />
|
||||||
<Route path="/sprints" element={<AppLayout title="اسپرینتها"><Sprints /></AppLayout>} />
|
<Route path="/backlog" element={<AppLayout title="بکلاگ"><Backlog /></AppLayout>} />
|
||||||
<Route path="/team" element={<AppLayout title="اعضای تیم"><Team /></AppLayout>} />
|
<Route path="/sprints" element={<AppLayout title="اسپرینتها"><Sprints /></AppLayout>} />
|
||||||
<Route path="/meetings" element={<AppLayout title="جلسات"><MeetingList /></AppLayout>} />
|
<Route path="/sprints/:id/workspace" element={<AppLayout title="مرکز فرمان Sprint"><SprintWorkspace /></AppLayout>} />
|
||||||
<Route path="/files" element={<AppLayout title="فایلها"><Files /></AppLayout>} />
|
<Route path="/team" element={<AppLayout title="اعضای تیم"><Team /></AppLayout>} />
|
||||||
<Route path="/reports" element={<AppLayout title="گزارشها"><Reports /></AppLayout>} />
|
<Route path="/meetings" element={<AppLayout title="جلسات"><MeetingList /></AppLayout>} />
|
||||||
<Route path="/notifications" element={<AppLayout title="اعلانها"><Notifications /></AppLayout>} />
|
<Route path="/meetings/:id/workspace" element={<AppLayout title="فضای کاری جلسه"><MeetingWorkspace /></AppLayout>} />
|
||||||
<Route path="/roles" element={<AppLayout title="نقشها و دسترسیها"><Roles /></AppLayout>} />
|
<Route path="/files" element={<AppLayout title="فایلها"><Files /></AppLayout>} />
|
||||||
<Route path="/organization" element={<AppLayout title="ساختار سازمانی"><Organization /></AppLayout>} />
|
<Route path="/reports" element={<AppLayout title="گزارشها"><Reports /></AppLayout>} />
|
||||||
<Route path="/settings" element={<AppLayout title="تنظیمات"><Settings /></AppLayout>} />
|
<Route path="/notifications" element={<AppLayout title="اعلانها"><Notifications /></AppLayout>} />
|
||||||
<Route path="/profile" element={<AppLayout title="پروفایل کاربری"><Profile /></AppLayout>} />
|
<Route path="/roles" element={<AppLayout title="نقشها و دسترسیها"><Roles /></AppLayout>} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="/organization" element={<AppLayout title="ساختار سازمانی"><Organization /></AppLayout>} />
|
||||||
</Routes>
|
<Route path="/settings" element={<AppLayout title="تنظیمات"><Settings /></AppLayout>} />
|
||||||
|
<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 { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Bell, CalendarDays, LogOut, Menu, Search, Settings } from 'lucide-react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { Search, Bell, CheckCheck } from 'lucide-react';
|
|
||||||
import ThemeModeToggle from './ThemeModeToggle';
|
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 { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [activeLayer, setActiveLayer] = useState(null);
|
||||||
|
const [previewItem, setPreviewItem] = useState(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
|
||||||
const [notifications, setNotifications] = useState([]);
|
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
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 {
|
try {
|
||||||
const { data } = await api.get('/notifications', { params: { per_page: 8 } });
|
const { data } = await api.get('/notifications', { params: { per_page: 1, unread: 1 } });
|
||||||
setNotifications(data.data || []);
|
setUnreadCount(data.meta?.unread_count || 0);
|
||||||
setUnreadCount(data.meta?.unread_count ?? (data.data || []).filter((item) => !item.read_at).length);
|
} catch {
|
||||||
} catch {}
|
setUnreadCount(0);
|
||||||
};
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchNotifications();
|
|
||||||
const timer = setInterval(fetchNotifications, 60000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSearch = async (e) => {
|
useEffect(() => {
|
||||||
const q = e.target.value;
|
fetchUnreadCount();
|
||||||
setSearchQuery(q);
|
const timer = window.setInterval(fetchUnreadCount, 60000);
|
||||||
if (q.length < 2) { setSearchResults([]); return; }
|
return () => window.clearInterval(timer);
|
||||||
|
}, [fetchUnreadCount]);
|
||||||
|
|
||||||
|
const handleSearch = async (event) => {
|
||||||
|
const query = event.target.value;
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query.length < 2) { setSearchResults([]); return; }
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/search?q=${q}`);
|
const { data } = await api.get('/search', { params: { q: query } });
|
||||||
setSearchResults(res.data.data || []);
|
setSearchResults(data.data || []);
|
||||||
} catch {} finally { setSearching(false); }
|
} catch {
|
||||||
};
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
const notificationUrl = (notification) => notification.data?.url || (notification.type?.includes('meeting') ? '/meetings' : notification.type?.includes('task') || notification.type === 'mention' ? '/kanban' : '/notifications');
|
setSearching(false);
|
||||||
|
|
||||||
const markNotificationRead = async (notification) => {
|
|
||||||
if (!notification.read_at) {
|
|
||||||
try {
|
|
||||||
await api.post(`/notifications/${notification.id}/read`);
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
setShowNotifications(false);
|
|
||||||
fetchNotifications();
|
|
||||||
navigate(notificationUrl(notification));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllNotificationsRead = async () => {
|
const toggleLayer = (name) => setActiveLayer((current) => current === name ? null : name);
|
||||||
try {
|
|
||||||
await api.post('/notifications/read-all');
|
|
||||||
fetchNotifications();
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPreview = (item) => {
|
||||||
|
closeLayer();
|
||||||
|
setPreviewItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header style={{
|
<>
|
||||||
position: 'fixed', top: 0, left: 0, right: 'var(--sidebar-width)', height: 'var(--header-height)',
|
<header className="app-header" style={{ right: collapsed ? 72 : 'var(--sidebar-width)' }}>
|
||||||
background: 'var(--surface)', borderBottom: '1px solid var(--gray-200)', display: 'flex', alignItems: 'center',
|
<h1>{title}</h1>
|
||||||
justifyContent: 'space-between', padding: '0 1.5rem', zIndex: 50, gap: '1rem',
|
<div className="header-search">
|
||||||
}}>
|
<input value={searchQuery} onChange={handleSearch} placeholder="جستجوی پروژه، تسک، کاربر..." className="form-input" />
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--gray-900)' }}>{title}</h2>
|
<Search size={18} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flex: 1, maxWidth: 400, position: 'relative' }}>
|
{searching && <span className="header-searching">...</span>}
|
||||||
<input
|
{searchResults.length > 0 && (
|
||||||
value={searchQuery}
|
<div className="header-search-results">
|
||||||
onChange={handleSearch}
|
{searchResults.slice(0, 5).map((item, index) => (
|
||||||
placeholder="جستجوی پروژه، تسک، کاربر..."
|
<Link key={`${item.url}-${index}`} to={item.url || '#'} onClick={() => { setSearchResults([]); setSearchQuery(''); }}>
|
||||||
className="form-input"
|
<strong>{item.title || item.name}</strong>
|
||||||
style={{ paddingRight: '2.5rem' }}
|
{item.type && <small>{item.type}</small>}
|
||||||
/>
|
</Link>
|
||||||
<Search size={18} style={{ position: 'absolute', right: '0.75rem', color: 'var(--gray-400)' }} />
|
|
||||||
{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}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
||||||
<ThemeModeToggle compact />
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button className="header-bell" onClick={() => { setShowNotifications(!showNotifications); if (!showNotifications) fetchNotifications(); }} title="اعلانها">
|
|
||||||
<Bell size={20} />
|
|
||||||
{unreadCount > 0 && <span className="header-bell-dot" />}
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<div onClick={() => setShowMenu(!showMenu)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
<div className="header-actions">
|
||||||
{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>}
|
<button
|
||||||
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--gray-700)' }}>{user?.name}</span>
|
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>
|
</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 }}>
|
<ThemeModeToggle compact />
|
||||||
<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)}>
|
|
||||||
پروفایل کاربری
|
<div className="header-layer">
|
||||||
</Link>
|
<button ref={notificationButtonRef} type="button" className={`header-icon-button ${activeLayer === 'notifications' ? 'active' : ''}`} onClick={() => toggleLayer('notifications')} aria-label="اعلانها" aria-expanded={activeLayer === 'notifications'}>
|
||||||
<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)}>
|
<Bell size={20} />
|
||||||
|
{unreadCount > 0 && <span className="header-badge">{unreadCount > 99 ? '۹۹+' : unreadCount}</span>}
|
||||||
|
</button>
|
||||||
|
{activeLayer === 'notifications' && (
|
||||||
|
<div ref={notificationPanelRef} className="header-popover notification-layer">
|
||||||
|
<NotificationCenter onOpenNotification={openPreview} onCountChange={setUnreadCount} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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} />
|
||||||
تنظیمات
|
تنظیمات
|
||||||
</Link>
|
</button>
|
||||||
<button onClick={handleLogout} style={{ display: 'block', width: '100%', textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--danger)' }}>
|
<button type="button" className="btn btn-danger profile-logout-button" onClick={handleLogout}>
|
||||||
خروج
|
<LogOut size={17} />
|
||||||
|
خروج از حساب
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Profile embedded />
|
||||||
</header>
|
</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 }) {
|
export default function Modal({ title, children, onClose, footer, size }) {
|
||||||
|
const modalRef = useRef(null);
|
||||||
|
const lastFocusedRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 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);
|
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]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<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">
|
<div className="modal-header">
|
||||||
<h3 className="modal-title">{title}</h3>
|
<h3 className="modal-title">{title}</h3>
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
<button type="button" className="modal-close" onClick={onClose} aria-label="بستن"><X size={20} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">{children}</div>
|
<div className="modal-body">{children}</div>
|
||||||
{footer && <div className="modal-footer">{footer}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||