اولین کامیت پروژه vernova
This commit is contained in:
parent
c1fc3a0e69
commit
b6b2057df2
57
.env.example
57
.env.example
@ -1,54 +1,32 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME=Projectra
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_TIMEZONE=UTC
|
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=en
|
|
||||||
APP_FALLBACK_LOCALE=en
|
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
|
||||||
APP_MAINTENANCE_STORE=database
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=single
|
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=projectra
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
BROADCAST_DRIVER=log
|
||||||
SESSION_LIFETIME=120
|
CACHE_DRIVER=file
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
SESSION_DRIVER=file
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
CACHE_STORE=database
|
SANCTUM_STATEFUL_DOMAINS=localhost:8000,localhost
|
||||||
CACHE_PREFIX=
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=mailpit
|
||||||
REDIS_CLIENT=phpredis
|
MAIL_PORT=1025
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
|
||||||
MAIL_HOST=127.0.0.1
|
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
@ -62,3 +40,8 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Projectra Custom Settings
|
||||||
|
DEFAULT_LOCALE=fa
|
||||||
|
DEFAULT_CALENDAR=jalali
|
||||||
|
DEFAULT_CURRENCY=IRR
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,3 @@
|
|||||||
/.phpunit.cache
|
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
@ -8,6 +7,7 @@
|
|||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// 1. EXPENSES — add missing columns
|
||||||
|
// ============================================================
|
||||||
|
Schema::table('expenses', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('expenses', 'requested_by')) {
|
||||||
|
$table->foreignId('requested_by')->nullable()->after('receipt_path')->constrained('users')->nullOnDelete();
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('expenses', 'approval_date')) {
|
||||||
|
$table->timestamp('approval_date')->nullable()->after('approved_by');
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('expenses', 'amount_usd')) {
|
||||||
|
$table->decimal('amount_usd', 15, 2)->nullable()->after('amount');
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('expenses', 'original_amount')) {
|
||||||
|
$table->decimal('original_amount', 15, 0)->nullable()->after('amount_usd');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add 'paid' to expenses status enum if needed
|
||||||
|
// Note: MySQL requires altering the enum column directly
|
||||||
|
if (Schema::hasColumn('expenses', 'status')) {
|
||||||
|
\DB::statement("ALTER TABLE expenses MODIFY COLUMN status ENUM('pending','approved','rejected','paid') DEFAULT 'pending'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. PETTY CASHES — add receipt_path column
|
||||||
|
// ============================================================
|
||||||
|
Schema::table('petty_cashes', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('petty_cashes', 'receipt_path')) {
|
||||||
|
$table->string('receipt_path')->nullable()->after('request_date');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. AUDIT LOGS — add url and method columns (for middleware)
|
||||||
|
// ============================================================
|
||||||
|
Schema::table('audit_logs', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('audit_logs', 'url')) {
|
||||||
|
$table->string('url')->nullable()->after('user_agent');
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('audit_logs', 'method')) {
|
||||||
|
$table->string('method', 10)->nullable()->after('url');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 3. Audit Logs
|
||||||
|
Schema::table('audit_logs', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('audit_logs', 'method')) {
|
||||||
|
$table->dropColumn('method');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('audit_logs', 'url')) {
|
||||||
|
$table->dropColumn('url');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Petty Cashes
|
||||||
|
Schema::table('petty_cashes', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('petty_cashes', 'receipt_path')) {
|
||||||
|
$table->dropColumn('receipt_path');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Expenses — revert status enum
|
||||||
|
\DB::statement("ALTER TABLE expenses MODIFY COLUMN status ENUM('pending','approved','rejected') DEFAULT 'pending'");
|
||||||
|
|
||||||
|
Schema::table('expenses', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('expenses', 'original_amount')) {
|
||||||
|
$table->dropColumn('original_amount');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('expenses', 'amount_usd')) {
|
||||||
|
$table->dropColumn('amount_usd');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('expenses', 'approval_date')) {
|
||||||
|
$table->dropColumn('approval_date');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('expenses', 'requested_by')) {
|
||||||
|
$table->dropConstrainedForeignId('requested_by');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
40
AppServiceProvider.php
Normal file
40
AppServiceProvider.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use App\Models\Item;
|
||||||
|
use App\Models\PettyCash;
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
use App\Policies\ItemPolicy;
|
||||||
|
use App\Policies\PettyCashPolicy;
|
||||||
|
use App\Policies\InventoryTransactionPolicy;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if ($locale = Session::get('locale')) {
|
||||||
|
if (in_array($locale, array_keys(config('projectra.supported_locales')))) {
|
||||||
|
App::setLocale($locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app()->environment('production')) {
|
||||||
|
\URL::forceScheme('https');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register policies explicitly (ensures discovery even without AuthServiceProvider)
|
||||||
|
Gate::policy(Item::class, ItemPolicy::class);
|
||||||
|
Gate::policy(PettyCash::class, PettyCashPolicy::class);
|
||||||
|
Gate::policy(InventoryTransaction::class, InventoryTransactionPolicy::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
AuditLog.php
Normal file
49
AuditLog.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AuditLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'tenant_id',
|
||||||
|
'model_type',
|
||||||
|
'model_id',
|
||||||
|
'action',
|
||||||
|
'old_values',
|
||||||
|
'new_values',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'url',
|
||||||
|
'method',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'old_values' => 'array',
|
||||||
|
'new_values' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the auditable model (polymorphic-like relationship using model_type/model_id).
|
||||||
|
*/
|
||||||
|
public function subject()
|
||||||
|
{
|
||||||
|
if ($this->model_type && class_exists($this->model_type)) {
|
||||||
|
return $this->morphTo(null, 'model_type', 'model_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
AuditLogMiddleware.php
Normal file
171
AuditLogMiddleware.php
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class AuditLogMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request and log mutating actions to the audit log.
|
||||||
|
*
|
||||||
|
* Automatically logs all POST, PUT, PATCH, DELETE requests with
|
||||||
|
* user_id, tenant_id, model info, action, old/new values, and request metadata.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$method = $request->method();
|
||||||
|
$isMutating = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
|
// For PUT/PATCH/DELETE, capture the current state before the request runs
|
||||||
|
$oldValues = null;
|
||||||
|
$modelType = null;
|
||||||
|
$modelId = null;
|
||||||
|
|
||||||
|
if ($isMutating && in_array($method, ['PUT', 'PATCH', 'DELETE'])) {
|
||||||
|
$resolvedModel = $this->resolveRouteModel($request);
|
||||||
|
|
||||||
|
if ($resolvedModel) {
|
||||||
|
$modelType = get_class($resolvedModel);
|
||||||
|
$modelId = $resolvedModel->getKey();
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
$oldValues = $resolvedModel->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the request
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Only log if the request was successful (2xx status)
|
||||||
|
if ($isMutating && $response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
|
||||||
|
$action = $this->determineAction($method, $request);
|
||||||
|
$newValues = null;
|
||||||
|
|
||||||
|
// For POST/PUT/PATCH, capture new values from the model
|
||||||
|
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
|
||||||
|
$resolvedModel = $this->resolveRouteModel($request);
|
||||||
|
|
||||||
|
if ($resolvedModel) {
|
||||||
|
$modelType = get_class($resolvedModel);
|
||||||
|
$modelId = $resolvedModel->getKey();
|
||||||
|
$newValues = $resolvedModel->toArray();
|
||||||
|
} elseif ($method === 'POST') {
|
||||||
|
// For POST with resource creation, try to get the model from the response redirect
|
||||||
|
$createdModel = $this->extractCreatedModel($request, $response);
|
||||||
|
if ($createdModel) {
|
||||||
|
$modelType = get_class($createdModel);
|
||||||
|
$modelId = $createdModel->getKey();
|
||||||
|
$newValues = $createdModel->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out sensitive fields from values
|
||||||
|
$sensitiveFields = ['password', 'password_hash', 'remember_token', 'national_code', 'bank_account', 'base_salary'];
|
||||||
|
$oldValues = $this->filterSensitiveFields($oldValues, $sensitiveFields);
|
||||||
|
$newValues = $this->filterSensitiveFields($newValues, $sensitiveFields);
|
||||||
|
|
||||||
|
try {
|
||||||
|
AuditLog::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'tenant_id' => session('tenant_id'),
|
||||||
|
'model_type' => $modelType,
|
||||||
|
'model_id' => $modelId,
|
||||||
|
'action' => $action,
|
||||||
|
'old_values' => $oldValues,
|
||||||
|
'new_values' => $newValues,
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
'url' => $request->fullUrl(),
|
||||||
|
'method' => $method,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently fail — audit logging should never break the request
|
||||||
|
logger()->error('Audit log failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Eloquent model from the current route.
|
||||||
|
*/
|
||||||
|
protected function resolveRouteModel(Request $request): ?\Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
$route = $request->route();
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($route->parameters() as $parameter) {
|
||||||
|
if ($parameter instanceof \Illuminate\Database\Eloquent\Model) {
|
||||||
|
return $parameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the action label based on HTTP method.
|
||||||
|
*/
|
||||||
|
protected function determineAction(string $method, Request $request): string
|
||||||
|
{
|
||||||
|
// Check if the route name suggests an approve/reject action
|
||||||
|
$routeName = $request->route()?->getName() ?? '';
|
||||||
|
|
||||||
|
if (str_contains($routeName, 'approve')) {
|
||||||
|
return 'approve';
|
||||||
|
}
|
||||||
|
if (str_contains($routeName, 'reject')) {
|
||||||
|
return 'reject';
|
||||||
|
}
|
||||||
|
if (str_contains($routeName, 'settle')) {
|
||||||
|
return 'settle';
|
||||||
|
}
|
||||||
|
if (str_contains($routeName, 'status')) {
|
||||||
|
return 'status_change';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($method) {
|
||||||
|
'POST' => 'create',
|
||||||
|
'PUT', 'PATCH' => 'update',
|
||||||
|
'DELETE' => 'delete',
|
||||||
|
default => strtolower($method),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract a newly created model from a redirect response.
|
||||||
|
*/
|
||||||
|
protected function extractCreatedModel(Request $request, $response): ?\Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
// If the response redirects to a show route, the model was just created
|
||||||
|
// We can't easily extract it from the redirect, so we fall back to null
|
||||||
|
// The model type/id will be populated by a subsequent GET if needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out sensitive fields from an array of values.
|
||||||
|
*/
|
||||||
|
protected function filterSensitiveFields(?array $values, array $sensitiveFields): ?array
|
||||||
|
{
|
||||||
|
if ($values === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sensitiveFields as $field) {
|
||||||
|
unset($values[$field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Employee.php
Normal file
73
Employee.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Employee extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'national_code',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'phone',
|
||||||
|
'job_title',
|
||||||
|
'contract_type',
|
||||||
|
'hire_date',
|
||||||
|
'termination_date',
|
||||||
|
'status',
|
||||||
|
'base_salary',
|
||||||
|
'currency_id',
|
||||||
|
'bank_account',
|
||||||
|
'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'hire_date' => 'date',
|
||||||
|
'termination_date' => 'date',
|
||||||
|
'national_code' => 'encrypted',
|
||||||
|
'bank_account' => 'encrypted',
|
||||||
|
'base_salary' => 'encrypted',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workLogs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function financials()
|
||||||
|
{
|
||||||
|
return $this->hasMany(EmployeeFinancial::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expenses()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Expense::class, 'employee_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFullNameAttribute()
|
||||||
|
{
|
||||||
|
return $this->first_name . ' ' . $this->last_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
Expense.php
Normal file
113
Expense.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Expense extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'petty_cash_id',
|
||||||
|
'employee_id',
|
||||||
|
'currency_id',
|
||||||
|
'amount',
|
||||||
|
'amount_usd',
|
||||||
|
'original_amount',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'expense_date',
|
||||||
|
'receipt_path',
|
||||||
|
'requested_by',
|
||||||
|
'approved_by',
|
||||||
|
'approval_date',
|
||||||
|
'approved_at',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'amount_usd' => 'decimal:2',
|
||||||
|
'original_amount' => 'decimal:2',
|
||||||
|
'expense_date' => 'date',
|
||||||
|
'approval_date' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pettyCash()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PettyCash::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employee()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'requested_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approvedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for approvedBy (backward compatibility).
|
||||||
|
*/
|
||||||
|
public function approver()
|
||||||
|
{
|
||||||
|
return $this->approvedBy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by category.
|
||||||
|
*/
|
||||||
|
public function scopeByCategory($query, $category)
|
||||||
|
{
|
||||||
|
return $query->where('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by status.
|
||||||
|
*/
|
||||||
|
public function scopeByStatus($query, $status)
|
||||||
|
{
|
||||||
|
return $query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status label for display.
|
||||||
|
*/
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('expense.pending'),
|
||||||
|
'approved' => __('expense.approved'),
|
||||||
|
'rejected' => __('expense.rejected'),
|
||||||
|
'paid' => __('expense.paid'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
465
INSTALL-WINDOWS-XAMPP.md
Normal file
465
INSTALL-WINDOWS-XAMPP.md
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
# 🏗️ Vernova - راهنمای نصب روی ویندوز + XAMPP + VSCode
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
### ۱. نصب XAMPP
|
||||||
|
دانلود XAMPP با PHP 8.2+ از:
|
||||||
|
👉 https://www.apachefriends.org/download.html
|
||||||
|
|
||||||
|
نسخهای که PHP 8.2 یا 8.3 داره رو انتخاب کنید.
|
||||||
|
|
||||||
|
بعد از نصب، مطمئن بشید XAMPP در مسیر زیر هست:
|
||||||
|
```
|
||||||
|
C:\xampp
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. نصب Composer
|
||||||
|
دانلود از:
|
||||||
|
👉 https://getcomposer.org/download/
|
||||||
|
|
||||||
|
⚠️ حتماً موقع نصب، مسیر PHP رو بهش بدید:
|
||||||
|
```
|
||||||
|
C:\xampp\php\php.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. نصب Node.js
|
||||||
|
دانلود LTS از:
|
||||||
|
👉 https://nodejs.org/
|
||||||
|
|
||||||
|
### ۴. نصب Git (اختیاری ولی پیشنهادی)
|
||||||
|
👉 https://git-scm.com/download/win
|
||||||
|
|
||||||
|
### ۵. نصب VSCode
|
||||||
|
👉 https://code.visualstudio.com/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱: بررسی پیشنیازها
|
||||||
|
|
||||||
|
CMD (Command Prompt) رو باز کنید و بزنید:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
php -v
|
||||||
|
```
|
||||||
|
باید PHP 8.2+ نشون بده. اگه نشناخت:
|
||||||
|
```
|
||||||
|
سیستم > متغیرهای محیطی > Path > اضافه کنید:
|
||||||
|
C:\xampp\php
|
||||||
|
```
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
composer --version
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
همه باید جواب بده. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۲: فعالکردن اکستنشنهای PHP
|
||||||
|
|
||||||
|
فایل زیر رو با Notepad یا VSCode باز کنید:
|
||||||
|
```
|
||||||
|
C:\xampp\php\php.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
این خطوط رو پیدا کنید و سمیکالن (;) اولشون رو حذف کنید:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
; قبل:
|
||||||
|
;extension=intl
|
||||||
|
;extension=pdo_mysql
|
||||||
|
;extension=mbstring
|
||||||
|
;extension=fileinfo
|
||||||
|
;extension=openssl
|
||||||
|
;extension=curl
|
||||||
|
|
||||||
|
; بعد:
|
||||||
|
extension=intl
|
||||||
|
extension=pdo_mysql
|
||||||
|
extension=mbstring
|
||||||
|
extension=fileinfo
|
||||||
|
extension=openssl
|
||||||
|
extension=curl
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **اکستنشن `intl` خیلی مهمه!** برای تبدیل اعداد و ارز لازمه.
|
||||||
|
|
||||||
|
بعد از تغییر، Apache رو از پنل XAMPP ریاستارت کنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۳: ساخت دیتابیس
|
||||||
|
|
||||||
|
1. پنل XAMPP → **MySQL** رو Start کنید
|
||||||
|
2. مرورگر رو باز کنید: `http://localhost/phpmyadmin`
|
||||||
|
3. روی **New** کلیک کنید
|
||||||
|
4. نام دیتابیس: `projectra`
|
||||||
|
5. Collation: `utf8mb4_unicode_ci`
|
||||||
|
6. **Create** رو بزنید
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۴: ساخت پروژه Laravel
|
||||||
|
|
||||||
|
CMD رو باز کنید:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd C:\xampp\htdocs
|
||||||
|
|
||||||
|
composer create-project laravel/laravel projectra
|
||||||
|
```
|
||||||
|
|
||||||
|
این مرحله ممکنه ۳-۵ دقیقه طول بکشه. ⏳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۵: کپی فایلهای Projectra
|
||||||
|
|
||||||
|
فایل ZIP (`projectra-laravel.zip`) رو Extract کنید.
|
||||||
|
|
||||||
|
### فایلهایی که باید کپی بشن:
|
||||||
|
|
||||||
|
```
|
||||||
|
از ZIP → به C:\xampp\htdocs\projectra\
|
||||||
|
```
|
||||||
|
|
||||||
|
| پوشه/فایل | عملیات |
|
||||||
|
|-----------|---------|
|
||||||
|
| `app/` | جایگزین (overwrite) |
|
||||||
|
| `config/projectra.php` | کپی |
|
||||||
|
| `config/projectra_app_additions.php` | کپی |
|
||||||
|
| `database/migrations/` | جایگزین |
|
||||||
|
| `database/seeders/` | جایگزین |
|
||||||
|
| `resources/views/` | جایگزین |
|
||||||
|
| `resources/lang/` | جایگزین |
|
||||||
|
| `resources/css/` | جایگزین |
|
||||||
|
| `resources/js/` | جایگزین |
|
||||||
|
| `routes/web.php` | جایگزین |
|
||||||
|
| `routes/api.php` | جایگزین |
|
||||||
|
| `composer.json` | جایگزین |
|
||||||
|
| `package.json` | جایگزین |
|
||||||
|
| `vite.config.js` | جایگزین |
|
||||||
|
| `tailwind.config.js` | جایگزین |
|
||||||
|
| `postcss.config.js` | جایگزین |
|
||||||
|
| `.env.example` | جایگزین |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۶: نصب وابستگیها
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd C:\xampp\htdocs\projectra
|
||||||
|
|
||||||
|
:: نصب پکیجهای PHP
|
||||||
|
composer install
|
||||||
|
|
||||||
|
:: نصب پکیجهای Frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
اگه `composer install` ارور داد پکیجها رو پیدا نمیکنه:
|
||||||
|
```cmd
|
||||||
|
composer update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۷: تنظیم فایل .env
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
copy .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
فایل `.env` رو با VSCode باز کنید و این بخش رو ویرایش کنید:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=projectra
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 در XAMPP، کاربر پیشفرض MySQL `root` هست و رمز عبور **خالیه** (بدون رمز).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۸: تنظیم config/app.php
|
||||||
|
|
||||||
|
فایل `config/app.php` رو باز کنید:
|
||||||
|
|
||||||
|
### اضافه کردن Providers:
|
||||||
|
```php
|
||||||
|
'providers' => [
|
||||||
|
// ... provider های موجود ...
|
||||||
|
|
||||||
|
// Vernova
|
||||||
|
Spatie\Permission\PermissionServiceProvider::class,
|
||||||
|
Morilog\Jalali\JalaliServiceProvider::class,
|
||||||
|
Maatwebsite\Excel\ExcelServiceProvider::class,
|
||||||
|
Barryvdh\DomPDF\ServiceProvider::class,
|
||||||
|
App\Providers\BladeServiceProvider::class,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### اضافه کردن Aliases:
|
||||||
|
```php
|
||||||
|
'aliases' => [
|
||||||
|
// ... alias های موجود ...
|
||||||
|
|
||||||
|
// Vernova
|
||||||
|
'Jalali' => Morilog\Jalali\JalaliFacade::class,
|
||||||
|
'Excel' => Maatwebsite\Excel\Facades\Excel::class,
|
||||||
|
'PDF' => Barryvdh\DomPDF\Facade\Pdf::class,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### تغییر locale پیشفرض:
|
||||||
|
```php
|
||||||
|
'locale' => env('APP_LOCALE', 'fa'),
|
||||||
|
'fallback_locale' => 'en',
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۹: تنظیم autoload
|
||||||
|
|
||||||
|
فایل `composer.json` رو باز کنید. در بخش `autoload` اضافه کنید:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"app/Helpers/helpers.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس:
|
||||||
|
```cmd
|
||||||
|
composer dump-autoload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱۰: اجرای Migration و Seeder
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
php artisan migrate
|
||||||
|
php artisan db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
اگه ارور `Specified key was too long` گرفتید، در `AppServiceProvider.php` در متد `boot()` اضافه کنید:
|
||||||
|
```php
|
||||||
|
Schema::defaultStringLength(191);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱۱: ساخت Assetها
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
برای توسعه (با auto-refresh):
|
||||||
|
```cmd
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱۲: اجرای پروژه 🚀
|
||||||
|
|
||||||
|
### روش ۱: با artisan (پیشنهادی)
|
||||||
|
```cmd
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
مرورگر: `http://localhost:8000`
|
||||||
|
|
||||||
|
### روش ۲: با Apache (XAMPP)
|
||||||
|
1. Apache رو در XAMPP استارت کنید
|
||||||
|
2. مرورگر: `http://localhost/projectra/public`
|
||||||
|
|
||||||
|
> 💡 برای روش ۲، بهتره Virtual Host تنظیم کنید (مرحله بعدی).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 تنظیم Virtual Host (اختیاری ولی حرفهای)
|
||||||
|
|
||||||
|
فایل زیر رو باز کنید:
|
||||||
|
```
|
||||||
|
C:\xampp\apache\conf\extra\httpd-vhosts.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
اضافه کنید:
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
DocumentRoot "C:/xampp/htdocs/projectra/public"
|
||||||
|
ServerName projectra.test
|
||||||
|
<Directory "C:/xampp/htdocs/projectra/public">
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
فایل hosts ویندوز رو باز کنید (با Notepad به صورت Run as Administrator):
|
||||||
|
```
|
||||||
|
C:\Windows\System32\drivers\etc\hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
اضافه کنید:
|
||||||
|
```
|
||||||
|
127.0.0.1 projectra.test
|
||||||
|
```
|
||||||
|
|
||||||
|
Apache رو ریاستارت کنید و بعد:
|
||||||
|
👉 `http://projectra.test`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 حسابهای پیشفرض
|
||||||
|
|
||||||
|
| نقش | ایمیل | رمز عبور |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Super Admin | admin@projectra.ir | password |
|
||||||
|
| Admin (سازندگی سپید) | admin@sepideh.ir | password |
|
||||||
|
| Project Manager | manager@sepideh.ir | password |
|
||||||
|
| Admin (Global) | admin@global.com | password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ تنظیمات VSCode
|
||||||
|
|
||||||
|
### اکستنشنهای پیشنهادی:
|
||||||
|
|
||||||
|
| اکستنشن | کاربرد |
|
||||||
|
|---------|---------|
|
||||||
|
| **Laravel Blade** | رنگبندی Blade templates |
|
||||||
|
| **PHP Intelephense** | autocomplete و تحلیل PHP |
|
||||||
|
| **Tailwind CSS IntelliSense** | autocomplete کلاسهای Tailwind |
|
||||||
|
| **Alpine.js IntelliSense** | autocomplete Alpine.js |
|
||||||
|
| **Laravel Artisan** | اجرای دستورات Artisan از VSCode |
|
||||||
|
| **PHP Debug** | دیباگ PHP با Xdebug |
|
||||||
|
| **Prettier** | فرمتبندی کد |
|
||||||
|
|
||||||
|
### تنظیم Xdebug (اختیاری):
|
||||||
|
|
||||||
|
فایل `C:\xampp\php\php.ini` اضافه کنید:
|
||||||
|
```ini
|
||||||
|
[xdebug]
|
||||||
|
zend_extension=xdebug
|
||||||
|
xdebug.mode=debug
|
||||||
|
xdebug.start_with_request=trigger
|
||||||
|
xdebug.client_port=9003
|
||||||
|
```
|
||||||
|
|
||||||
|
در VSCode فایل `.vscode/launch.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Listen for Xdebug",
|
||||||
|
"type": "php",
|
||||||
|
"request": "launch",
|
||||||
|
"port": 9003
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ مشکلات رایج ویندوز
|
||||||
|
|
||||||
|
### ۱. ارور "php not recognized"
|
||||||
|
```
|
||||||
|
Path اضافه کنید: C:\xampp\php
|
||||||
|
سیستم > متغیرهای محیطی > Path > Edit > New
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. ارور "composer not recognized"
|
||||||
|
```
|
||||||
|
Path اضافه کنید: C:\ProgramData\ComposerSetup\bin
|
||||||
|
یا Composer رو دوباره نصب کنید
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. ارور "SSL Certificate" در composer
|
||||||
|
```cmd
|
||||||
|
composer config -g -- disable-tls false
|
||||||
|
composer config -g -- secure-http false
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. ارور "Allowed memory size exhausted"
|
||||||
|
```cmd
|
||||||
|
php -d memory_limit=-1 C:\path\to\composer.phar install
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵. ارور "npm ERR!"
|
||||||
|
```cmd
|
||||||
|
npm cache clean --force
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۶. ارور مخفف نشدن صفحه (blank page)
|
||||||
|
```cmd
|
||||||
|
php artisan config:clear
|
||||||
|
php artisan cache:clear
|
||||||
|
php artisan view:clear
|
||||||
|
php artisan route:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۷. ارور permission در storage
|
||||||
|
```cmd
|
||||||
|
icacls storage /grant Everyone:F /T
|
||||||
|
icacls bootstrap\cache /grant Everyone:F /T
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 خلاصه سریع (Quick Start)
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: 1. ساخت پروژه
|
||||||
|
cd C:\xampp\htdocs
|
||||||
|
composer create-project laravel/laravel projectra
|
||||||
|
|
||||||
|
:: 2. کپی فایلها از ZIP → projectra/
|
||||||
|
|
||||||
|
:: 3. نصب وابستگیها
|
||||||
|
cd projectra
|
||||||
|
composer install
|
||||||
|
npm install
|
||||||
|
|
||||||
|
:: 4. تنظیم env
|
||||||
|
copy .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
|
||||||
|
:: 5. تنظیم config/app.php (Providers + Aliases)
|
||||||
|
:: 6. تنظیم composer.json autoload (helpers.php)
|
||||||
|
composer dump-autoload
|
||||||
|
|
||||||
|
:: 7. ساخت دیتابیس در phpMyAdmin: projectra
|
||||||
|
|
||||||
|
:: 8. Migration + Seed
|
||||||
|
php artisan migrate
|
||||||
|
php artisan db:seed
|
||||||
|
|
||||||
|
:: 9. Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
:: 10. اجرا! 🚀
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
مرورگر → `http://localhost:8000` ✅
|
||||||
340
INSTALL.md
Normal file
340
INSTALL.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
# 🏗️ Projectra - راهنمای نصب و راهاندازی
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
قبل از شروع، مطمئن شوید این نرمافزارها روی سیستم شما نصب هستند:
|
||||||
|
|
||||||
|
| نرمافزار | حداقل نسخه | بررسی |
|
||||||
|
|-----------|------------|-------|
|
||||||
|
| PHP | 8.2+ | `php -v` |
|
||||||
|
| Composer | 2.x | `composer --version` |
|
||||||
|
| Node.js | 18+ | `node -v` |
|
||||||
|
| npm/bun | هر دو قابل استفادهاند | `npm -v` |
|
||||||
|
| MySQL | 8.0+ / MariaDB 10.6+ | `mysql --version` |
|
||||||
|
| Git | 2.x | `git --version` |
|
||||||
|
|
||||||
|
### اکستنشنهای PHP مورد نیاز:
|
||||||
|
```bash
|
||||||
|
php -m | grep -E "intl|pdo|mbstring|xml|curl|openssl|fileinfo"
|
||||||
|
```
|
||||||
|
باید شامل: `intl`, `pdo_mysql`, `mbstring`, `simplexml`, `curl`, `openssl`, `fileinfo`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱: ساخت پروژه Laravel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ساخت پروژه جدید Laravel
|
||||||
|
composer create-project laravel/laravel projectra
|
||||||
|
cd projectra
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۲: کپی فایلهای Projectra
|
||||||
|
|
||||||
|
فایلهای ZIP دانلود شده را Extract کنید و **فقط** پوشهها و فایلهای زیر را در پروژه Laravel کپی کنید:
|
||||||
|
|
||||||
|
### فایلهای ریشه (جایگزین کنید):
|
||||||
|
```
|
||||||
|
composer.json
|
||||||
|
package.json
|
||||||
|
vite.config.js
|
||||||
|
tailwind.config.js
|
||||||
|
postcss.config.js
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
### پوشه app/ (جایگزین کنید):
|
||||||
|
```
|
||||||
|
app/Http/Controllers/
|
||||||
|
app/Http/Middleware/
|
||||||
|
app/Http/Requests/
|
||||||
|
app/Http/Kernel.php
|
||||||
|
app/Models/
|
||||||
|
app/Helpers/
|
||||||
|
app/Policies/
|
||||||
|
app/Providers/AppServiceProvider.php
|
||||||
|
app/Providers/BladeServiceProvider.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### پوشههای دیگر:
|
||||||
|
```
|
||||||
|
config/projectra.php
|
||||||
|
config/projectra_app_additions.php
|
||||||
|
database/migrations/
|
||||||
|
database/seeders/
|
||||||
|
resources/views/
|
||||||
|
resources/lang/
|
||||||
|
resources/css/
|
||||||
|
resources/js/
|
||||||
|
routes/web.php
|
||||||
|
routes/api.php
|
||||||
|
public/logo.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۳: نصب وابستگیها
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# نصب پکیجهای PHP
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# نصب پکیجهای Frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۴: تنظیم محیط
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# کپی فایل env
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# ساخت کلید اپلیکیشن
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
فایل `.env` را باز کنید و تنظیمات دیتابیس را وارد کنید:
|
||||||
|
```env
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=projectra
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۵: تنظیمات اضافی config/app.php
|
||||||
|
|
||||||
|
فایل `config/app.php` را باز کنید و تغییرات زیر را اعمال کنید:
|
||||||
|
|
||||||
|
### اضافه کردن Providers:
|
||||||
|
```php
|
||||||
|
'providers' => [
|
||||||
|
// ... existing providers ...
|
||||||
|
|
||||||
|
// Projectra additions
|
||||||
|
Spatie\Permission\PermissionServiceProvider::class,
|
||||||
|
Morilog\Jalali\JalaliServiceProvider::class,
|
||||||
|
Maatwebsite\Excel\ExcelServiceProvider::class,
|
||||||
|
Barryvdh\DomPDF\ServiceProvider::class,
|
||||||
|
App\Providers\BladeServiceProvider::class,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### اضافه کردن Aliases:
|
||||||
|
```php
|
||||||
|
'aliases' => [
|
||||||
|
// ... existing aliases ...
|
||||||
|
|
||||||
|
// Projectra additions
|
||||||
|
'Jalali' => Morilog\Jalali\JalaliFacade::class,
|
||||||
|
'Excel' => Maatwebsite\Excel\Facades\Excel::class,
|
||||||
|
'PDF' => Barryvdh\DomPDF\Facade\Pdf::class,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### تنظیم locale پیشفرض:
|
||||||
|
```php
|
||||||
|
'locale' => env('APP_LOCALE', 'fa'),
|
||||||
|
'fallback_locale' => 'en',
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۶: تنظیم composer.json
|
||||||
|
|
||||||
|
فایل `composer.json` را باز کنید و در بخش `autoload` اضافه کنید:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"app/Helpers/helpers.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس:
|
||||||
|
```bash
|
||||||
|
composer dump-autoload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۷: ساخت دیتابیس
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ورود به MySQL
|
||||||
|
mysql -u root -p
|
||||||
|
|
||||||
|
# ساخت دیتابیس
|
||||||
|
CREATE DATABASE projectra CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
EXIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۸: اجرای Migration و Seeder
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# اجرای Migrationها
|
||||||
|
php artisan migrate
|
||||||
|
|
||||||
|
# اجرای Seeder (داده نمونه)
|
||||||
|
php artisan db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۹: ساخت Assetها
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ساخت CSS/JS برای توسعه
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# یا برای تولید
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱۰: اجرای پروژه
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
پروژه در آدرس `http://localhost:8000` در دسترس خواهد بود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 حسابهای پیشفرض (بعد از db:seed)
|
||||||
|
|
||||||
|
| نقش | ایمیل | رمز عبور | Tenant |
|
||||||
|
|------|-------|----------|--------|
|
||||||
|
| Super Admin | admin@projectra.ir | password | - |
|
||||||
|
| Admin (سازندگی سپید) | admin@sepideh.ir | password | سازندگی سپید |
|
||||||
|
| Project Manager | manager@sepideh.ir | password | سازندگی سپید |
|
||||||
|
| Admin (Global) | admin@global.com | password | Global Construction |
|
||||||
|
|
||||||
|
> ⚠️ رمز عبورها را حتماً بعد از نصب تغییر دهید!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 ساختار پوشهها
|
||||||
|
|
||||||
|
```
|
||||||
|
projectra/
|
||||||
|
├── app/
|
||||||
|
│ ├── Http/
|
||||||
|
│ │ ├── Controllers/ ← ۸ کنترلر
|
||||||
|
│ │ ├── Middleware/ ← ۳ میدلور (SetTenant, SetLocale, TenantScope)
|
||||||
|
│ │ ├── Requests/ ← ۴ Form Request
|
||||||
|
│ │ └── Kernel.php
|
||||||
|
│ ├── Models/ ← ۱۹ مدل Eloquent
|
||||||
|
│ ├── Helpers/ ← ۵ فایل Helper (Jalali, Number, Currency, Calendar, helpers)
|
||||||
|
│ ├── Policies/ ← ۴ Policy
|
||||||
|
│ └── Providers/ ← AppServiceProvider + BladeServiceProvider
|
||||||
|
├── config/
|
||||||
|
│ └── projectra.php ← تنظیمات اختصاصی Projectra
|
||||||
|
├── database/
|
||||||
|
│ ├── migrations/ ← ۲۰ Migration
|
||||||
|
│ └── seeders/ ← ۹ Seeder
|
||||||
|
├── resources/
|
||||||
|
│ ├── views/ ← ۱۸ Blade Template
|
||||||
|
│ │ ├── layouts/ ← app, sidebar, header
|
||||||
|
│ │ ├── dashboard/ ← داشبورد اصلی
|
||||||
|
│ │ ├── projects/ ← لیست، فرم، جزئیات
|
||||||
|
│ │ ├── tasks/ ← لیست، فرم
|
||||||
|
│ │ ├── employees/ ← لیست، فرم
|
||||||
|
│ │ ├── expenses/ ← لیست، فرم
|
||||||
|
│ │ ├── auth/ ← صفحه ورود
|
||||||
|
│ │ └── components/ ← status-badge, localized-date, currency-display, priority-badge
|
||||||
|
│ ├── lang/
|
||||||
|
│ │ ├── fa.json ← ۱۱۲ کلید فارسی
|
||||||
|
│ │ └── en.json ← ۱۱۲ کلید انگلیسی
|
||||||
|
│ ├── css/
|
||||||
|
│ └── js/
|
||||||
|
└── routes/
|
||||||
|
├── web.php ← مسیرهای وب
|
||||||
|
└── api.php ← مسیرهای API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 قابلیتهای بینالمللیسازی (i18n)
|
||||||
|
|
||||||
|
### Blade Directiveها:
|
||||||
|
```blade
|
||||||
|
@localizeNumber(1234567) ← ۱,۲۳۴,۵۶۷ (فارسی) / 1,234,567 (انگلیسی)
|
||||||
|
@localizeDate($date) ← ۱۴۰۴/۰۳/۰۵ (شمسی) / 2025/05/26 (میلادی)
|
||||||
|
@localizeCurrency(45000000, 'IRR') ← ۴۵,۰۰۰,۰۰۰ ﷼
|
||||||
|
@direction ← rtl یا ltr
|
||||||
|
@isRtl ... @endIsRtl ← بلوک شرطی RTL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper Functionها:
|
||||||
|
```php
|
||||||
|
jalali_date($date) // تبدیل به شمسی
|
||||||
|
persian_num(12345) // ۱۲۳۴۵
|
||||||
|
format_currency(45000000, 'IRR')// ۴۵,۰۰۰,۰۰۰ ﷼
|
||||||
|
format_number(1234567) // ۱,۲۳۴,۵۶۷
|
||||||
|
calendar_date($date) // تاریخ بر اساس تقویم فعلی
|
||||||
|
get_direction() // 'rtl' یا 'ltr'
|
||||||
|
```
|
||||||
|
|
||||||
|
### اولویت زبان/تقویم:
|
||||||
|
```
|
||||||
|
کاربر (User) → Tenant → Session → مرورگر → پیشفرض سیستم
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ مشکلات رایج
|
||||||
|
|
||||||
|
### 1. خطای "Class not found"
|
||||||
|
```bash
|
||||||
|
composer dump-autoload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. خطای permission در storage
|
||||||
|
```bash
|
||||||
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. خطای اکستنشن intl
|
||||||
|
```bash
|
||||||
|
# Ubuntu
|
||||||
|
sudo apt-get install php8.2-intl
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install php@8.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. مشکل Vite
|
||||||
|
```bash
|
||||||
|
npm install && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 پشتیبانی
|
||||||
|
|
||||||
|
- مستندات پروژه: پوشه `upload/` (اسناد ۱ تا ۱۱)
|
||||||
|
- لاگ تغییرات: `worklog.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**نسخه:** MVP (Phase 0)
|
||||||
|
**تاریخ:** ۱۴۰۴/۰۳/۰۶
|
||||||
|
**تکنولوژی:** Laravel 11 + Blade + Tailwind CSS + Alpine.js
|
||||||
32
INSTALL.txt
Normal file
32
INSTALL.txt
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
راهنمای نصب اصلاحات تقویم شمسی v2 - ورنوا
|
||||||
|
================================================
|
||||||
|
|
||||||
|
تغییرات این نسخه:
|
||||||
|
- تقویم شمسی کاملاً سفارشی با Vanilla JS (بدون jQuery، بدون eval، بدون مشکل CSP)
|
||||||
|
- کامپوننت <x-date-input> اصلاح شده
|
||||||
|
- فیلدهای ورودی تاریخ شمسی با تقویم پاپآپ
|
||||||
|
|
||||||
|
فایلها:
|
||||||
|
--------
|
||||||
|
1. public/css/jalali-datepicker.css → فایل جدید (استایل تقویم)
|
||||||
|
2. public/js/jalali-datepicker.js → جایگزین شود (تقویم Vanilla JS)
|
||||||
|
3. resources/views/components/date-input.blade.php → جایگزین شود
|
||||||
|
4. resources/views/layouts/app.blade.php → جایگزین شود
|
||||||
|
5. resources/views/expenses/form.blade.php → جایگزین شود
|
||||||
|
6. app/Helpers/helpers.php → جایگزین شود (اگه هنوز نکردی)
|
||||||
|
|
||||||
|
نکته مهم: فایلهای قدیمی jQuery و persian-datepicker از CDN حذف شدن.
|
||||||
|
این نسخه هیچ وابستگی خارجی نداره!
|
||||||
|
|
||||||
|
مراحل نصب:
|
||||||
|
==========
|
||||||
|
1. فایلها رو جایگزین کن
|
||||||
|
2. کش رو پاک کن: php artisan view:clear && php artisan cache:clear
|
||||||
|
3. تست کن
|
||||||
|
|
||||||
|
برای سایر فرمها:
|
||||||
|
=================
|
||||||
|
هر input type="date" رو با <x-date-input> جایگزین کن.
|
||||||
|
مثلاً:
|
||||||
|
قبل: <input type="date" name="start_date" value="{{ old('start_date', $project?->start_date?->format('Y-m-d')) }}">
|
||||||
|
بعد: <x-date-input name="start_date" :value="old('start_date', $project?->start_date?->format('Y-m-d'))" />
|
||||||
63
InventoryTransactionPolicy.php
Normal file
63
InventoryTransactionPolicy.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
|
||||||
|
class InventoryTransactionPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any inventory transactions.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the inventory transaction.
|
||||||
|
*/
|
||||||
|
public function view(User $user, InventoryTransaction $inventoryTransaction): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $inventoryTransaction->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create inventory transactions.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the inventory transaction.
|
||||||
|
*/
|
||||||
|
public function update(User $user, InventoryTransaction $inventoryTransaction): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $inventoryTransaction->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the inventory transaction.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, InventoryTransaction $inventoryTransaction): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $inventoryTransaction->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
}
|
||||||
63
ItemPolicy.php
Normal file
63
ItemPolicy.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Item;
|
||||||
|
|
||||||
|
class ItemPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any items.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the item.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Item $item): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $item->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create items.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the item.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Item $item): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $item->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the item.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Item $item): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $item->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Kernel.php
Normal file
55
Kernel.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
|
|
||||||
|
class Kernel extends HttpKernel
|
||||||
|
{
|
||||||
|
protected $middleware = [
|
||||||
|
\Illuminate\Http\Middleware\HandleCors::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $middlewareGroups = [
|
||||||
|
'web' => [
|
||||||
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\SetTenant::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'api' => [
|
||||||
|
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||||
|
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\SetTenant::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $middlewareAliases = [
|
||||||
|
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||||
|
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||||
|
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||||
|
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||||
|
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||||
|
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||||
|
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
||||||
|
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||||
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
|
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
||||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||||
|
'tenant.scope' => \App\Http\Middleware\TenantScope::class,
|
||||||
|
'audit.log' => \App\Http\Middleware\AuditLogMiddleware::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
86
PettyCash.php
Normal file
86
PettyCash.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class PettyCash extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'currency_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'amount',
|
||||||
|
'original_amount',
|
||||||
|
'amount_usd',
|
||||||
|
'remaining',
|
||||||
|
'request_date',
|
||||||
|
'receipt_path',
|
||||||
|
'requested_by',
|
||||||
|
'approved_by',
|
||||||
|
'approval_date',
|
||||||
|
'approved_at',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'original_amount' => 'decimal:2',
|
||||||
|
'amount_usd' => 'decimal:2',
|
||||||
|
'remaining' => 'decimal:2',
|
||||||
|
'request_date' => 'date',
|
||||||
|
'approval_date' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'requested_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approvedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expenses()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Expense::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByStatus($query, $status)
|
||||||
|
{
|
||||||
|
return $query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('pettyCash.pending'),
|
||||||
|
'approved' => __('pettyCash.approved'),
|
||||||
|
'rejected' => __('pettyCash.rejected'),
|
||||||
|
'settled' => __('pettyCash.settled'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
PettyCashController.php
Normal file
220
PettyCashController.php
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\PettyCash;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class PettyCashController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of petty cash requests.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = PettyCash::with(['project', 'currency', 'requestedBy', 'approvedBy']);
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy('request_date', 'desc');
|
||||||
|
|
||||||
|
$pettyCashes = $query->paginate(20)->appends($request->query());
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$statuses = ['pending', 'approved', 'rejected', 'settled'];
|
||||||
|
|
||||||
|
return view('petty-cashes.index', compact('pettyCashes', 'projects', 'statuses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new petty cash request.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('petty-cashes.form', compact('projects', 'currencies', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created petty cash request.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'project_id' => 'required|exists:projects,id',
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'required|exists:currencies,id',
|
||||||
|
'request_date' => 'required|date',
|
||||||
|
'receipt' => 'nullable|file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = session('tenant_id');
|
||||||
|
$validated['requested_by'] = Auth::id();
|
||||||
|
$validated['status'] = 'pending';
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($validated['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$validated['original_amount'] = $validated['amount'];
|
||||||
|
$validated['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $validated['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$validated['receipt_path'] = $request->file('receipt')->store('receipts/petty-cash', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pettyCash = PettyCash::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('success', __('expense.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function show(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$pettyCash->load(['project', 'currency', 'requestedBy', 'approvedBy', 'expenses']);
|
||||||
|
|
||||||
|
return view('petty-cashes.show', compact('pettyCash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function edit(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('petty-cashes.form', compact('pettyCash', 'projects', 'currencies', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'project_id' => 'required|exists:projects,id',
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'required|exists:currencies,id',
|
||||||
|
'request_date' => 'required|date',
|
||||||
|
'receipt' => 'nullable|file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($validated['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$validated['original_amount'] = $validated['amount'];
|
||||||
|
$validated['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $validated['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
// Delete old receipt if exists
|
||||||
|
if ($pettyCash->receipt_path) {
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('public')->delete($pettyCash->receipt_path);
|
||||||
|
}
|
||||||
|
$validated['receipt_path'] = $request->file('receipt')->store('receipts/petty-cash', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pettyCash->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('success', __('expense.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function destroy(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$pettyCash->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.index')
|
||||||
|
->with('success', __('expense.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a petty cash request.
|
||||||
|
*/
|
||||||
|
public function approve(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$pettyCash->update([
|
||||||
|
'status' => 'approved',
|
||||||
|
'approved_by' => Auth::id(),
|
||||||
|
'approval_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('expense.approved_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a petty cash request.
|
||||||
|
*/
|
||||||
|
public function reject(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$pettyCash->update([
|
||||||
|
'status' => 'rejected',
|
||||||
|
'approved_by' => Auth::id(),
|
||||||
|
'approval_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('expense.rejected_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle a petty cash request (mark as settled after usage).
|
||||||
|
*/
|
||||||
|
public function settle(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$pettyCash->update([
|
||||||
|
'status' => 'settled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('expense.settled_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
PettyCashPolicy.php
Normal file
98
PettyCashPolicy.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\PettyCash;
|
||||||
|
|
||||||
|
class PettyCashPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any petty cash requests.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage petty cash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the petty cash request.
|
||||||
|
*/
|
||||||
|
public function view(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $pettyCash->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasPermissionTo('manage petty cash')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can view their own petty cash requests
|
||||||
|
return $pettyCash->requested_by === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create petty cash requests.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage petty cash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the petty cash request.
|
||||||
|
*/
|
||||||
|
public function update(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $pettyCash->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pending requests can be updated
|
||||||
|
if ($pettyCash->status !== 'pending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasPermissionTo('manage petty cash')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pettyCash->requested_by === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the petty cash request.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $pettyCash->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('manage petty cash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can approve the petty cash request.
|
||||||
|
* Only admin/manager can approve.
|
||||||
|
*/
|
||||||
|
public function approve(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $pettyCash->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager'));
|
||||||
|
}
|
||||||
|
}
|
||||||
92
README.md
92
README.md
@ -1,66 +1,52 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# Vernova - Calendar Fix (رفع مشکل تقویم)
|
||||||
|
|
||||||
<p align="center">
|
## مشکل
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
بخشهایی از برنامه تاریخها رو به میلادی نمایش میداد حتی وقتی کاربر تقویم رو روی شمسی ست کرده بود.
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
## تغییرات
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
### 1. app/Http/Middleware/SetLocale.php
|
||||||
|
**مشکل:** اولویت خواندن تقویم از سشن اشتباه بود. Middleware در هر درخواست، مقدار سشن رو با مقدار دیتابیس کاربر override میکرد. اگر کاربر تقویم رو از هدر تغییر بده، در درخواست بعدی مقدار سشن دوباره از دیتابیس خونده میشد و تغییر کاربر نادیده گرفته میشد.
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
**رفع:** اولویت سشن بالاتر از دیتابیس قرار گرفت. حالا ترتیب اولویت:
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
1. سشن (بالاترین اولویت - کاربر همین الان تغییر داده)
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
2. تنظیمات دیتابیس کاربر
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
3. تنظیمات tenant
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
4. تنظیمات مرورگر
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
5. تنظیمات پیشفرض config
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
### 2. app/Http/Controllers/DashboardController.php
|
||||||
|
**مشکل:** کلید کانفیگ اشتباه بود: `config('projectra.defaults.calendar')` به جای `config('projectra.default_calendar')`.
|
||||||
|
|
||||||
## Learning Laravel
|
**رفع:** کلید کانفیگ اصلاح شد.
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
### 3. resources/views/layouts/header.blade.php
|
||||||
|
**مشکل:** سینتکس `@x-localized-date :date="now()"` معتبر نیست و به صورت متنی خام نمایش داده میشد.
|
||||||
|
|
||||||
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
|
**رفع:** خط نامعتبر حذف شد و فقط `{{ calendar_date(now()) }}` باقی ماند.
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
### 4. app/Helpers/helpers.php
|
||||||
|
**مشکل:** تابع `calendar_date()` تاریخ شمسی رو با ارقام لاتین نمایش میداد (مثلاً 1403/01/15 به جای ۱۴۰۳/۰۱/۱۵).
|
||||||
|
|
||||||
## Laravel Sponsors
|
**رفع:** تبدیل خودکار ارقام فارسی وقتی locale روی 'fa' هست اضافه شد.
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
### 5. app/Models/Project.php
|
||||||
|
**مشکل:** اکسسور `getStatusLabelAttribute()` وجود نداشت اما ویوها از `$project->status_label` استفاده میکردن.
|
||||||
|
|
||||||
### Premium Partners
|
**رفع:** اکسسور `status_label` و رابطه `defaultCurrency()` اضافه شد.
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com/)**
|
## نحوه نصب
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
فایلها رو در مسیرهای مربوطه در پروژه لاراول کپی کنید:
|
||||||
- **[WebReinvent](https://webreinvent.com/)**
|
```
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
calendar-fix/
|
||||||
- **[64 Robots](https://64robots.com)**
|
├── app/
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
|
│ ├── Http/
|
||||||
- **[Cyber-Duck](https://cyber-duck.co.uk)**
|
│ │ ├── Middleware/SetLocale.php
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
│ │ └── Controllers/DashboardController.php
|
||||||
- **[Jump24](https://jump24.co.uk)**
|
│ ├── Helpers/helpers.php
|
||||||
- **[Redberry](https://redberry.international/laravel/)**
|
│ └── Models/Project.php
|
||||||
- **[Active Logic](https://activelogic.com)**
|
├── resources/
|
||||||
- **[byte5](https://byte5.de)**
|
│ └── views/
|
||||||
- **[OP.GG](https://op.gg)**
|
│ └── layouts/header.blade.php
|
||||||
|
└── README.md
|
||||||
## Contributing
|
```
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
138
app/Helpers/CalendarHelper.php
Normal file
138
app/Helpers/CalendarHelper.php
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
class CalendarHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parse a date string (may be Jalali) and return a Gregorian date string.
|
||||||
|
* If the calendar preference is 'jalali', try to convert from Jalali format.
|
||||||
|
*/
|
||||||
|
public static function parseDate(?string $date): ?string
|
||||||
|
{
|
||||||
|
if (empty($date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's already a valid Gregorian date (Y-m-d), return as-is
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as Jalali date (Y/m/d or Y-m-d with Jalali year)
|
||||||
|
$calendar = session('calendar', auth()->user()?->preferred_calendar ?? 'jalali');
|
||||||
|
|
||||||
|
if ($calendar === 'jalali' && class_exists(\Morilog\Jalali\Jalalian::class)) {
|
||||||
|
try {
|
||||||
|
// Try Jalali format: 1404/03/15 or 1404-03-15
|
||||||
|
$normalized = str_replace('/', '-', $date);
|
||||||
|
$parts = explode('-', $normalized);
|
||||||
|
|
||||||
|
if (count($parts) === 3) {
|
||||||
|
$jYear = (int) $parts[0];
|
||||||
|
$jMonth = (int) $parts[1];
|
||||||
|
$jDay = (int) $parts[2];
|
||||||
|
|
||||||
|
// If year looks like a Jalali year (> 1300), convert
|
||||||
|
if ($jYear > 1300 && $jYear < 1600) {
|
||||||
|
$gregorian = \Morilog\Jalali\Jalalian::fromDateTime(
|
||||||
|
\Morilog\Jalali\CalendarUtils::toGregorian($jYear, $jMonth, $jDay)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $gregorian->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fall through to Carbon parse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try Carbon parse
|
||||||
|
try {
|
||||||
|
return \Carbon\Carbon::parse($date)->format('Y-m-d');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date based on the user's calendar preference.
|
||||||
|
*/
|
||||||
|
public static function formatDate(?string $date, string $calendar = 'jalali', string $format = 'Y/m/d'): string
|
||||||
|
{
|
||||||
|
return JalaliHelper::formatDateForDisplay($date, $calendar, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the month name for a given month number.
|
||||||
|
*/
|
||||||
|
public static function getMonthName(int $month, string $calendar = 'jalali'): string
|
||||||
|
{
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
return JalaliHelper::jalaliMonthName($month);
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [
|
||||||
|
1 => 'January',
|
||||||
|
2 => 'February',
|
||||||
|
3 => 'March',
|
||||||
|
4 => 'April',
|
||||||
|
5 => 'May',
|
||||||
|
6 => 'June',
|
||||||
|
7 => 'July',
|
||||||
|
8 => 'August',
|
||||||
|
9 => 'September',
|
||||||
|
10 => 'October',
|
||||||
|
11 => 'November',
|
||||||
|
12 => 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $months[$month] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the day (of week) name.
|
||||||
|
*/
|
||||||
|
public static function getDayName(int $dayOfWeek, string $calendar = 'jalali'): string
|
||||||
|
{
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
return JalaliHelper::jalaliDayName($dayOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = [
|
||||||
|
0 => 'Sunday',
|
||||||
|
1 => 'Monday',
|
||||||
|
2 => 'Tuesday',
|
||||||
|
3 => 'Wednesday',
|
||||||
|
4 => 'Thursday',
|
||||||
|
5 => 'Friday',
|
||||||
|
6 => 'Saturday',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $days[$dayOfWeek] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current date formatted based on the calendar preference.
|
||||||
|
*/
|
||||||
|
public static function getCurrentDate(string $calendar = 'jalali', string $format = 'Y/m/d'): string
|
||||||
|
{
|
||||||
|
return self::formatDate(now()->format('Y-m-d'), $calendar, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the query date range for a given month and year.
|
||||||
|
*/
|
||||||
|
public static function getQueryRangeForMonth(int $year, int $month, string $calendar = 'jalali'): array
|
||||||
|
{
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
return JalaliHelper::getQueryRangeForJalaliMonth($year, $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gregorian range
|
||||||
|
$start = \Carbon\Carbon::create($year, $month, 1)->startOfDay();
|
||||||
|
$end = \Carbon\Carbon::create($year, $month, 1)->endOfMonth()->endOfDay();
|
||||||
|
|
||||||
|
return [$start, $end];
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/Helpers/CurrencyHelper.php
Normal file
96
app/Helpers/CurrencyHelper.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
use App\Models\Currency;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class CurrencyHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert an amount from one currency to another using USD as pivot.
|
||||||
|
*/
|
||||||
|
public static function convert(float $amount, string $fromCode, string $toCode): float
|
||||||
|
{
|
||||||
|
if ($fromCode === $toCode) {
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromRate = static::getExchangeRate($fromCode);
|
||||||
|
$toRate = static::getExchangeRate($toCode);
|
||||||
|
|
||||||
|
if ($fromRate <= 0 || $toRate <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to USD first, then to target currency
|
||||||
|
$usdAmount = $amount / $fromRate;
|
||||||
|
|
||||||
|
return round($usdAmount * $toRate, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the exchange rate for a currency relative to USD.
|
||||||
|
* Cached for 24 hours.
|
||||||
|
*/
|
||||||
|
public static function getExchangeRate(string $currencyCode): float
|
||||||
|
{
|
||||||
|
return Cache::remember("exchange_rate_{$currencyCode}", now()->addHours(24), function () use ($currencyCode) {
|
||||||
|
$currency = Currency::where('code', $currencyCode)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $currency?->exchange_rate_to_usd ?? 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the symbol for a currency.
|
||||||
|
*/
|
||||||
|
public static function getCurrencySymbol(string $currencyCode): string
|
||||||
|
{
|
||||||
|
$currency = Currency::where('code', $currencyCode)->first();
|
||||||
|
|
||||||
|
return $currency?->symbol ?? $currencyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decimal places for a currency.
|
||||||
|
*/
|
||||||
|
public static function getDecimalPlaces(string $currencyCode): int
|
||||||
|
{
|
||||||
|
$currency = Currency::where('code', $currencyCode)->first();
|
||||||
|
|
||||||
|
return $currency?->decimal_places ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an amount with the currency symbol.
|
||||||
|
*/
|
||||||
|
public static function formatWithSymbol(float $amount, string $currencyCode, string $locale = 'fa_IR'): string
|
||||||
|
{
|
||||||
|
$symbol = static::getCurrencySymbol($currencyCode);
|
||||||
|
$decimals = static::getDecimalPlaces($currencyCode);
|
||||||
|
$formatted = NumberHelper::formatNumber($amount, $decimals, $locale);
|
||||||
|
|
||||||
|
return $formatted . ' ' . $symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base currency for the current tenant.
|
||||||
|
*/
|
||||||
|
public static function getBaseCurrency(): ?Currency
|
||||||
|
{
|
||||||
|
$tenantId = session('tenant_id');
|
||||||
|
|
||||||
|
if (!$tenantId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cache::remember("base_currency_{$tenantId}", now()->addHours(24), function () use ($tenantId) {
|
||||||
|
$tenant = \App\Models\Tenant::find($tenantId);
|
||||||
|
|
||||||
|
return $tenant?->baseCurrency;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/Helpers/JalaliHelper.php
Normal file
128
app/Helpers/JalaliHelper.php
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
use Morilog\Jalali\Jalalian;
|
||||||
|
|
||||||
|
class JalaliHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert a Gregorian date to Jalali.
|
||||||
|
*/
|
||||||
|
public static function toJalali(int $year, int $month, int $day): array
|
||||||
|
{
|
||||||
|
return Jalalian::fromGregorian($year, $month, $day)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Jalali date to Gregorian.
|
||||||
|
*/
|
||||||
|
public static function toGregorian(int $year, int $month, int $day): array
|
||||||
|
{
|
||||||
|
return (new Jalalian($year, $month, $day))->toGregorian()->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Gregorian date string as Jalali.
|
||||||
|
*/
|
||||||
|
public static function formatJalali(string $date, string $format = 'Y/m/d'): string
|
||||||
|
{
|
||||||
|
if (empty($date)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Jalalian::fromDateTime($date)->format($format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Jalali month name.
|
||||||
|
*/
|
||||||
|
public static function jalaliMonthName(int $month): string
|
||||||
|
{
|
||||||
|
$months = [
|
||||||
|
1 => 'فروردین',
|
||||||
|
2 => 'اردیبهشت',
|
||||||
|
3 => 'خرداد',
|
||||||
|
4 => 'تیر',
|
||||||
|
5 => 'مرداد',
|
||||||
|
6 => 'شهریور',
|
||||||
|
7 => 'مهر',
|
||||||
|
8 => 'آبان',
|
||||||
|
9 => 'آذر',
|
||||||
|
10 => 'دی',
|
||||||
|
11 => 'بهمن',
|
||||||
|
12 => 'اسفند',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $months[$month] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Jalali day (week) name.
|
||||||
|
*/
|
||||||
|
public static function jalaliDayName(int $dayOfWeek): string
|
||||||
|
{
|
||||||
|
$days = [
|
||||||
|
0 => 'یکشنبه',
|
||||||
|
1 => 'دوشنبه',
|
||||||
|
2 => 'سهشنبه',
|
||||||
|
3 => 'چهارشنبه',
|
||||||
|
4 => 'پنجشنبه',
|
||||||
|
5 => 'جمعه',
|
||||||
|
6 => 'شنبه',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $days[$dayOfWeek] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's date in Jalali as a formatted string.
|
||||||
|
*/
|
||||||
|
public static function todayJalali(string $format = 'Y/m/d'): string
|
||||||
|
{
|
||||||
|
return Jalalian::now()->format($format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display based on the user's calendar preference.
|
||||||
|
*/
|
||||||
|
public static function formatDateForDisplay(?string $date, string $calendar = 'jalali', string $format = 'Y/m/d'): string
|
||||||
|
{
|
||||||
|
if (empty($date)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
return static::formatJalali($date, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date($format, strtotime($date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the start and end dates for a given Jalali month as Gregorian Carbon instances.
|
||||||
|
* Useful for querying records within a specific Jalali month.
|
||||||
|
*/
|
||||||
|
public static function getQueryRangeForJalaliMonth(int $year, int $month): array
|
||||||
|
{
|
||||||
|
$start = (new Jalalian($year, $month, 1))->toCarbon();
|
||||||
|
$end = (new Jalalian($year, $month, $start->format('t')))->toCarbon()->endOfDay();
|
||||||
|
|
||||||
|
// Jalalian doesn't have daysInMonth directly, calculate it
|
||||||
|
$jalaliEndDay = 29;
|
||||||
|
if ($month <= 6) {
|
||||||
|
$jalaliEndDay = 31;
|
||||||
|
} elseif ($month <= 11) {
|
||||||
|
$jalaliEndDay = 30;
|
||||||
|
} else {
|
||||||
|
// Esfand - check for leap year
|
||||||
|
$j = new Jalalian($year, 1, 1);
|
||||||
|
$jalaliEndDay = $j->isLeapYear() ? 30 : 29;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = (new Jalalian($year, $month, 1))->toCarbon()->startOfDay();
|
||||||
|
$end = (new Jalalian($year, $month, $jalaliEndDay))->toCarbon()->endOfDay();
|
||||||
|
|
||||||
|
return [$start, $end];
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Helpers/NumberHelper.php
Normal file
89
app/Helpers/NumberHelper.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
class NumberHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert Latin digits to Persian digits.
|
||||||
|
*/
|
||||||
|
public static function toPersianDigits(string|int|float $number): string
|
||||||
|
{
|
||||||
|
$persian = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||||
|
$latin = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
|
||||||
|
return str_replace($latin, $persian, (string) $number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Persian/Arabic digits to Latin digits.
|
||||||
|
*/
|
||||||
|
public static function toLatinDigits(string $number): string
|
||||||
|
{
|
||||||
|
$persian = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||||
|
$arabic = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
|
||||||
|
$latin = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
|
||||||
|
$result = str_replace($persian, $latin, $number);
|
||||||
|
$result = str_replace($arabic, $latin, $result);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number with grouping separator.
|
||||||
|
*/
|
||||||
|
public static function formatNumber(string|int|float $number, int $decimals = 0, string $locale = 'fa_IR'): string
|
||||||
|
{
|
||||||
|
if (class_exists(\NumberFormatter::class)) {
|
||||||
|
try {
|
||||||
|
$formatter = new \NumberFormatter($locale, \NumberFormatter::DECIMAL);
|
||||||
|
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $decimals);
|
||||||
|
|
||||||
|
return $formatter->format((float) $number);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fallback below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $number, $decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number as currency.
|
||||||
|
*/
|
||||||
|
public static function formatCurrency(string|int|float $amount, string $currency = 'IRR', string $locale = 'fa_IR', int $decimals = 0): string
|
||||||
|
{
|
||||||
|
if (class_exists(\NumberFormatter::class)) {
|
||||||
|
try {
|
||||||
|
$formatter = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
|
||||||
|
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $decimals);
|
||||||
|
|
||||||
|
return $formatter->formatCurrency((float) $amount, $currency);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fallback below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $amount, $decimals) . ' ' . $currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number as a percentage.
|
||||||
|
*/
|
||||||
|
public static function formatPercentage(string|int|float $value, int $decimals = 1, string $locale = 'fa_IR'): string
|
||||||
|
{
|
||||||
|
if (class_exists(\NumberFormatter::class)) {
|
||||||
|
try {
|
||||||
|
$formatter = new \NumberFormatter($locale, \NumberFormatter::PERCENT);
|
||||||
|
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $decimals);
|
||||||
|
|
||||||
|
return $formatter->format((float) $value / 100);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fallback below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $value, $decimals) . '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/Helpers/helpers.php
Normal file
126
app/Helpers/helpers.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Helpers\CalendarHelper;
|
||||||
|
|
||||||
|
if (!function_exists('calendar_date')) {
|
||||||
|
/**
|
||||||
|
* Format a date based on the user's calendar preference with Persian digits.
|
||||||
|
*/
|
||||||
|
function calendar_date($date, $format = 'Y/m/d')
|
||||||
|
{
|
||||||
|
if (!$date) return '';
|
||||||
|
|
||||||
|
$calendar = session('calendar', 'jalali');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert to date string if it's a Carbon/DateTime object
|
||||||
|
if ($date instanceof \DateTimeInterface) {
|
||||||
|
$dateString = $date->format('Y-m-d H:i:s');
|
||||||
|
} else {
|
||||||
|
$dateString = (string) $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
$jalali = \Morilog\Jalali\Jalalian::fromDateTime($dateString);
|
||||||
|
$result = $jalali->format($format);
|
||||||
|
} else {
|
||||||
|
$result = date($format, strtotime($dateString));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fallback: try Carbon format
|
||||||
|
try {
|
||||||
|
if ($date instanceof \DateTimeInterface) {
|
||||||
|
$result = $date->format($format);
|
||||||
|
} else {
|
||||||
|
$result = date($format, strtotime((string) $date));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e2) {
|
||||||
|
return (string) $date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Persian digits if locale is fa
|
||||||
|
if (app()->getLocale() === 'fa') {
|
||||||
|
$result = persian_num($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('calendar_date_raw')) {
|
||||||
|
/**
|
||||||
|
* Format a date based on calendar preference WITHOUT Persian digits.
|
||||||
|
* Useful for form values that need English digits.
|
||||||
|
*/
|
||||||
|
function calendar_date_raw($date, $format = 'Y/m/d')
|
||||||
|
{
|
||||||
|
if (!$date) return '';
|
||||||
|
|
||||||
|
$calendar = session('calendar', 'jalali');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($date instanceof \DateTimeInterface) {
|
||||||
|
$dateString = $date->format('Y-m-d H:i:s');
|
||||||
|
} else {
|
||||||
|
$dateString = (string) $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
$jalali = \Morilog\Jalali\Jalalian::fromDateTime($dateString);
|
||||||
|
return $jalali->format($format);
|
||||||
|
} else {
|
||||||
|
return date($format, strtotime($dateString));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($date instanceof \DateTimeInterface) {
|
||||||
|
return $date->format($format);
|
||||||
|
}
|
||||||
|
return (string) $date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('persian_num')) {
|
||||||
|
function persian_num($number)
|
||||||
|
{
|
||||||
|
$persian = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||||
|
$english = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
return str_replace($english, $persian, (string) $number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('format_currency')) {
|
||||||
|
function format_currency($amount, $currency = 'IRR')
|
||||||
|
{
|
||||||
|
$formatted = number_format((float) $amount, $currency === 'IRR' ? 0 : 2);
|
||||||
|
|
||||||
|
if (app()->getLocale() === 'fa') {
|
||||||
|
$formatted = persian_num($formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
$symbol = '';
|
||||||
|
switch ($currency) {
|
||||||
|
case 'IRR': $symbol = 'ریال'; break;
|
||||||
|
case 'USD': $symbol = '$'; break;
|
||||||
|
case 'EUR': $symbol = '€'; break;
|
||||||
|
case 'AED': $symbol = 'د.إ'; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted . ' ' . $symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('get_direction')) {
|
||||||
|
function get_direction()
|
||||||
|
{
|
||||||
|
return app()->getLocale() === 'fa' ? 'rtl' : 'ltr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('is_rtl')) {
|
||||||
|
function is_rtl()
|
||||||
|
{
|
||||||
|
return app()->getLocale() === 'fa';
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Http/Controllers/AttachmentController.php
Normal file
105
app/Http/Controllers/AttachmentController.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class AttachmentController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Upload attachments for a given model.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'attachable_type' => 'required|string',
|
||||||
|
'attachable_id' => 'required|integer',
|
||||||
|
'files' => 'required|array|min:1',
|
||||||
|
'files.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx|max:10240',
|
||||||
|
'category' => 'nullable|string|in:receipt,document,photo,other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve the model
|
||||||
|
$allowedTypes = [
|
||||||
|
'App\\Models\\Expense',
|
||||||
|
'App\\Models\\PettyCash',
|
||||||
|
'App\\Models\\Project',
|
||||||
|
'App\\Models\\Task',
|
||||||
|
'App\\Models\\Letter',
|
||||||
|
'App\\Models\\Document',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!in_array($validated['attachable_type'], $allowedTypes)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => __('attachment.invalid_model_type'),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = $validated['attachable_type']::find($validated['attachable_id']);
|
||||||
|
|
||||||
|
if (!$model) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => __('attachment.model_not_found'),
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize
|
||||||
|
if (method_exists($model, 'update')) {
|
||||||
|
$this->authorize('update', $model);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = $validated['category'] ?? 'receipt';
|
||||||
|
$attachments = $model->attachFiles($validated['files'], $category);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('attachment.uploaded_successfully'),
|
||||||
|
'attachments' => collect($attachments)->map(fn ($a) => [
|
||||||
|
'id' => $a->id,
|
||||||
|
'file_name' => $a->file_name,
|
||||||
|
'file_size' => $a->formatted_size,
|
||||||
|
'mime_type' => $a->mime_type,
|
||||||
|
'category' => $a->category,
|
||||||
|
'url' => $a->url,
|
||||||
|
'is_image' => $a->is_image,
|
||||||
|
'icon' => $a->icon,
|
||||||
|
'created_at' => $a->created_at?->format('Y-m-d H:i'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an attachment.
|
||||||
|
*/
|
||||||
|
public function destroy(Attachment $attachment)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $attachment);
|
||||||
|
|
||||||
|
$attachment->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('attachment.deleted_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an attachment.
|
||||||
|
*/
|
||||||
|
public function download(Attachment $attachment)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $attachment);
|
||||||
|
|
||||||
|
if (!\Illuminate\Support\Facades\Storage::disk('public')->exists($attachment->file_path)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return \Illuminate\Support\Facades\Storage::disk('public')
|
||||||
|
->download($attachment->file_path, $attachment->file_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Http/Controllers/AuthController.php
Normal file
83
app/Http/Controllers/AuthController.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the login form.
|
||||||
|
*/
|
||||||
|
public function showLogin()
|
||||||
|
{
|
||||||
|
if (Auth::check()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle login attempt with tenant and locale setup.
|
||||||
|
*/
|
||||||
|
public function login(Request $request)
|
||||||
|
{
|
||||||
|
$credentials = $request->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$remember = $request->boolean('remember');
|
||||||
|
|
||||||
|
if (Auth::attempt($credentials, $remember)) {
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
// Set tenant context
|
||||||
|
if ($user->tenant_id) {
|
||||||
|
session(['tenant_id' => $user->tenant_id]);
|
||||||
|
$user->load('tenant');
|
||||||
|
|
||||||
|
// Set tenant defaults
|
||||||
|
if ($user->tenant) {
|
||||||
|
session([
|
||||||
|
'locale' => $user->locale ?? $user->tenant->default_locale ?? config('Vernova.defaults.locale', 'fa'),
|
||||||
|
'calendar' => $user->preferred_calendar ?? $user->tenant->default_calendar ?? config('Vernova.defaults.calendar', 'jalali'),
|
||||||
|
'currency' => $user->tenant->baseCurrency?->code ?? config('Vernova.defaults.currency', 'IRR'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set application locale
|
||||||
|
$locale = session('locale', $user->locale ?? config('Vernova.defaults.locale', 'fa'));
|
||||||
|
app()->setLocale($locale);
|
||||||
|
session(['locale' => $locale]);
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard'))
|
||||||
|
->with('success', __('auth.login_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->withErrors([
|
||||||
|
'email' => __('auth.failed'),
|
||||||
|
])->onlyInput('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log the user out.
|
||||||
|
*/
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')
|
||||||
|
->with('success', __('auth.logout_success'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,35 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class Controller extends BaseController
|
||||||
{
|
{
|
||||||
//
|
use AuthorizesRequests, ValidatesRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current tenant ID from session or authenticated user.
|
||||||
|
* Falls back to the authenticated user's tenant_id if session is missing.
|
||||||
|
*/
|
||||||
|
protected function getTenantId(): ?int
|
||||||
|
{
|
||||||
|
$id = session('tenant_id');
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: get from authenticated user
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user && $user->tenant_id) {
|
||||||
|
session(['tenant_id' => $user->tenant_id]);
|
||||||
|
return (int) $user->tenant_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
app/Http/Controllers/DashboardController.php
Normal file
118
app/Http/Controllers/DashboardController.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use App\Helpers\CalendarHelper;
|
||||||
|
use App\Helpers\NumberHelper;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\Expense;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the dashboard with KPIs, recent activities, and project progress.
|
||||||
|
* All data is tenant-scoped via global scope.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$tenantId = $this->getTenantId();
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$currencyCode = $baseCurrency?->code ?? config('projectra.defaults.currency', 'IRR');
|
||||||
|
|
||||||
|
// --- KPIs ---
|
||||||
|
$activeProjectsCount = Project::whereIn('status', ['planning', 'active'])->count();
|
||||||
|
$totalBudget = Project::whereIn('status', ['planning', 'active'])
|
||||||
|
->sum('budget');
|
||||||
|
$pendingTasksCount = Task::where('status', 'pending')->count();
|
||||||
|
$activeEmployeesCount = Employee::where('status', 'active')->count();
|
||||||
|
|
||||||
|
// Budget in base currency
|
||||||
|
$totalBudgetFormatted = CurrencyHelper::formatWithSymbol((float) $totalBudget, $currencyCode);
|
||||||
|
|
||||||
|
// --- Trends (compare with previous month) ---
|
||||||
|
$lastMonthStart = now()->subMonth()->startOfMonth();
|
||||||
|
$lastMonthEnd = now()->subMonth()->endOfMonth();
|
||||||
|
|
||||||
|
$lastMonthProjects = Project::whereBetween('created_at', [$lastMonthStart, $lastMonthEnd])->count();
|
||||||
|
$thisMonthProjects = Project::whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])->count();
|
||||||
|
$projectTrend = $lastMonthProjects > 0
|
||||||
|
? round((($thisMonthProjects - $lastMonthProjects) / $lastMonthProjects) * 100, 1)
|
||||||
|
: ($thisMonthProjects > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
$lastMonthTasks = Task::whereBetween('created_at', [$lastMonthStart, $lastMonthEnd])->count();
|
||||||
|
$thisMonthTasks = Task::whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])->count();
|
||||||
|
$taskTrend = $lastMonthTasks > 0
|
||||||
|
? round((($thisMonthTasks - $lastMonthTasks) / $lastMonthTasks) * 100, 1)
|
||||||
|
: ($thisMonthTasks > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
$lastMonthExpenses = Expense::whereBetween('expense_date', [$lastMonthStart, $lastMonthEnd])
|
||||||
|
->where('status', 'approved')->sum('amount');
|
||||||
|
$thisMonthExpenses = Expense::whereBetween('expense_date', [now()->startOfMonth(), now()->endOfMonth()])
|
||||||
|
->where('status', 'approved')->sum('amount');
|
||||||
|
$expenseTrend = $lastMonthExpenses > 0
|
||||||
|
? round((($thisMonthExpenses - $lastMonthExpenses) / $lastMonthExpenses) * 100, 1)
|
||||||
|
: ($thisMonthExpenses > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
$lastMonthEmployees = Employee::whereBetween('hire_date', [$lastMonthStart, $lastMonthEnd])->count();
|
||||||
|
$thisMonthEmployees = Employee::whereBetween('hire_date', [now()->startOfMonth(), now()->endOfMonth()])->count();
|
||||||
|
$employeeTrend = $lastMonthEmployees > 0
|
||||||
|
? round((($thisMonthEmployees - $lastMonthEmployees) / $lastMonthEmployees) * 100, 1)
|
||||||
|
: ($thisMonthEmployees > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
// --- Project Progress ---
|
||||||
|
$projectsInProgress = Project::whereIn('status', ['active', 'planning'])
|
||||||
|
->with(['manager', 'tasks'])
|
||||||
|
->orderBy('progress', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// --- Recent Activities ---
|
||||||
|
$recentActivities = AuditLog::where('tenant_id', $tenantId)
|
||||||
|
->with('user')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// --- Tasks by status for current user ---
|
||||||
|
$myTasks = Task::where('assignee_id', $user->id)
|
||||||
|
->whereNotIn('status', ['done', 'cancelled'])
|
||||||
|
->with('project')
|
||||||
|
->orderBy('due_date', 'asc')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// --- Calendar & i18n info ---
|
||||||
|
$currentCalendar = session('calendar', config('projectra.default_calendar', 'jalali'));
|
||||||
|
$currentLocale = app()->getLocale();
|
||||||
|
$todayDisplay = CalendarHelper::getCurrentDate($currentCalendar);
|
||||||
|
if ($currentLocale === 'fa') {
|
||||||
|
$todayDisplay = persian_num($todayDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('dashboard.index', compact(
|
||||||
|
'activeProjectsCount',
|
||||||
|
'totalBudget',
|
||||||
|
'totalBudgetFormatted',
|
||||||
|
'pendingTasksCount',
|
||||||
|
'activeEmployeesCount',
|
||||||
|
'projectTrend',
|
||||||
|
'taskTrend',
|
||||||
|
'expenseTrend',
|
||||||
|
'employeeTrend',
|
||||||
|
'projectsInProgress',
|
||||||
|
'recentActivities',
|
||||||
|
'myTasks',
|
||||||
|
'currentCalendar',
|
||||||
|
'currentLocale',
|
||||||
|
'todayDisplay',
|
||||||
|
'currencyCode'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/Http/Controllers/DocumentController.php
Normal file
177
app/Http/Controllers/DocumentController.php
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class DocumentController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of documents.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Document::with(['project', 'uploader']);
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('file_type')) {
|
||||||
|
$query->where('file_type', $request->file_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->search($request->search);
|
||||||
|
}
|
||||||
|
|
||||||
|
$documents = $query->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20)
|
||||||
|
->appends($request->query());
|
||||||
|
|
||||||
|
$projects = Project::all();
|
||||||
|
$fileTypes = ['pdf', 'image', 'word', 'excel', 'text', 'other'];
|
||||||
|
|
||||||
|
return view('documents.index', compact('documents', 'projects', 'fileTypes'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new document.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$projects = Project::all();
|
||||||
|
$document = null;
|
||||||
|
|
||||||
|
return view('documents.form', compact('projects', 'document'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created document in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'file' => 'required|file|max:20480',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $request->file('file');
|
||||||
|
$path = $file->store('documents', 'public');
|
||||||
|
|
||||||
|
$extension = strtolower($file->getClientOriginalExtension());
|
||||||
|
$fileType = match(true) {
|
||||||
|
$extension === 'pdf' => 'pdf',
|
||||||
|
in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']) => 'image',
|
||||||
|
in_array($extension, ['doc', 'docx']) => 'word',
|
||||||
|
in_array($extension, ['xls', 'xlsx', 'csv']) => 'excel',
|
||||||
|
in_array($extension, ['txt', 'rtf']) => 'text',
|
||||||
|
default => 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
Document::create([
|
||||||
|
'tenant_id' => $this->getTenantId(),
|
||||||
|
'project_id' => $validated['project_id'] ?? null,
|
||||||
|
'title' => $validated['title'],
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'file_path' => $path,
|
||||||
|
'file_type' => $fileType,
|
||||||
|
'uploaded_by' => Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('documents.index')
|
||||||
|
->with('success', __('document.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified document.
|
||||||
|
*/
|
||||||
|
public function show(Document $document)
|
||||||
|
{
|
||||||
|
$document->load(['project', 'uploader']);
|
||||||
|
|
||||||
|
return view('documents.show', compact('document'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified document.
|
||||||
|
*/
|
||||||
|
public function edit(Document $document)
|
||||||
|
{
|
||||||
|
$projects = Project::all();
|
||||||
|
|
||||||
|
return view('documents.form', compact('document', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified document in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Document $document)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'file' => 'nullable|file|max:20480',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->hasFile('file')) {
|
||||||
|
// Delete old file
|
||||||
|
Storage::disk('public')->delete($document->file_path);
|
||||||
|
|
||||||
|
$file = $request->file('file');
|
||||||
|
$path = $file->store('documents', 'public');
|
||||||
|
|
||||||
|
$extension = strtolower($file->getClientOriginalExtension());
|
||||||
|
$fileType = match(true) {
|
||||||
|
$extension === 'pdf' => 'pdf',
|
||||||
|
in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']) => 'image',
|
||||||
|
in_array($extension, ['doc', 'docx']) => 'word',
|
||||||
|
in_array($extension, ['xls', 'xlsx', 'csv']) => 'excel',
|
||||||
|
in_array($extension, ['txt', 'rtf']) => 'text',
|
||||||
|
default => 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
$validated['file_path'] = $path;
|
||||||
|
$validated['file_type'] = $fileType;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('documents.index')
|
||||||
|
->with('success', __('document.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified document from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Document $document)
|
||||||
|
{
|
||||||
|
Storage::disk('public')->delete($document->file_path);
|
||||||
|
$document->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('documents.index')
|
||||||
|
->with('success', __('document.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the specified document.
|
||||||
|
*/
|
||||||
|
public function download(Document $document)
|
||||||
|
{
|
||||||
|
if (!Storage::disk('public')->exists($document->file_path)) {
|
||||||
|
abort(404, __('document.file_not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Storage::disk('public')->download($document->file_path, $document->title . '.' . pathinfo($document->file_path, PATHINFO_EXTENSION));
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/Http/Controllers/EmployeeController.php
Normal file
216
app/Http/Controllers/EmployeeController.php
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreEmployeeRequest;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class EmployeeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of employees with filters.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Employee::class);
|
||||||
|
|
||||||
|
$query = Employee::with(['project', 'currency']);
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status (active/inactive)
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by contract type
|
||||||
|
if ($request->filled('contract_type')) {
|
||||||
|
$query->where('contract_type', $request->contract_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('first_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('last_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('national_code', 'like', "%{$search}%")
|
||||||
|
->orWhere('position', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy('first_name')->orderBy('last_name');
|
||||||
|
|
||||||
|
$employees = $query->paginate(16)->appends($request->query());
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$contractTypes = ['full_time', 'part_time', 'contractor', 'intern', 'consultant'];
|
||||||
|
|
||||||
|
return view('employees.index', compact('employees', 'projects', 'contractTypes'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new employee.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->authorize('create', Employee::class);
|
||||||
|
|
||||||
|
$employee = null;
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$contractTypes = ['full_time', 'part_time', 'contractor', 'intern', 'consultant'];
|
||||||
|
|
||||||
|
return view('employees.form', compact('employee', 'projects', 'currencies', 'contractTypes'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created employee in storage.
|
||||||
|
*/
|
||||||
|
public function store(StoreEmployeeRequest $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Employee::class);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$data['tenant_id'] = $this->getTenantId();
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($data['hire_date'])) {
|
||||||
|
$data['hire_date'] = \App\Helpers\CalendarHelper::parseDate($data['hire_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = Employee::create($data);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('employees.show', $employee)
|
||||||
|
->with('success', __('employee.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified employee with work logs and financials.
|
||||||
|
*/
|
||||||
|
public function show(Employee $employee)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $employee);
|
||||||
|
|
||||||
|
$employee->load([
|
||||||
|
'project',
|
||||||
|
'currency',
|
||||||
|
'workLogs.project',
|
||||||
|
'workLogs.task',
|
||||||
|
'financials.currency',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return view('employees.show', compact('employee'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified employee.
|
||||||
|
*/
|
||||||
|
public function edit(Employee $employee)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $employee);
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$contractTypes = ['full_time', 'part_time', 'contractor', 'intern', 'consultant'];
|
||||||
|
|
||||||
|
return view('employees.form', compact('employee', 'projects', 'currencies', 'contractTypes'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified employee in storage.
|
||||||
|
*/
|
||||||
|
public function update(StoreEmployeeRequest $request, Employee $employee)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $employee);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($data['hire_date'])) {
|
||||||
|
$data['hire_date'] = \App\Helpers\CalendarHelper::parseDate($data['hire_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee->update($data);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('employees.show', $employee)
|
||||||
|
->with('success', __('employee.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified employee from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Employee $employee)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $employee);
|
||||||
|
|
||||||
|
$employee->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('employees.index')
|
||||||
|
->with('success', __('employee.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display work logs for the specified employee.
|
||||||
|
*/
|
||||||
|
public function workLogs(Request $request, Employee $employee)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $employee);
|
||||||
|
|
||||||
|
$query = $employee->workLogs()->with(['project', 'task']);
|
||||||
|
|
||||||
|
if ($request->filled('from_date') && $request->filled('to_date')) {
|
||||||
|
$query->dateRange($request->from_date, $request->to_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalHours = (clone $query)->sum('hours_worked');
|
||||||
|
$overtimeHours = (clone $query)->overtime()->sum('overtime_hours');
|
||||||
|
$workLogs = $query->orderBy('date', 'desc')->paginate(20);
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
|
||||||
|
return view('employees.work-logs', compact('employee', 'workLogs', 'totalHours', 'overtimeHours', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display financial records for the specified employee.
|
||||||
|
*/
|
||||||
|
public function financials(Request $request, Employee $employee)
|
||||||
|
{
|
||||||
|
$this->authorize('viewFinancials', $employee);
|
||||||
|
|
||||||
|
$query = $employee->financials()->with('currency');
|
||||||
|
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->byType($request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('is_paid') && $request->is_paid !== '') {
|
||||||
|
$query->where('is_paid', $request->boolean('is_paid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$financials = $query->orderBy('effective_date', 'desc')->paginate(20);
|
||||||
|
|
||||||
|
$totalPaid = $employee->financials()->where('is_paid', true)->sum('amount');
|
||||||
|
$totalUnpaid = $employee->financials()->where('is_paid', false)->sum('amount');
|
||||||
|
|
||||||
|
$financialTypes = ['salary', 'bonus', 'deduction', 'overtime', 'advance', 'reimbursement'];
|
||||||
|
|
||||||
|
return view('employees.financials', compact('employee', 'financials', 'totalPaid', 'totalUnpaid', 'financialTypes'));
|
||||||
|
}
|
||||||
|
}
|
||||||
258
app/Http/Controllers/ExpenseController.php
Normal file
258
app/Http/Controllers/ExpenseController.php
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreExpenseRequest;
|
||||||
|
use App\Models\Expense;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class ExpenseController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of expenses with filters.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Expense::class);
|
||||||
|
|
||||||
|
$query = Expense::with(['project', 'currency', 'requestedBy', 'approvedBy']);
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if ($request->filled('category')) {
|
||||||
|
$query->byCategory($request->category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by date range
|
||||||
|
if ($request->filled('from_date')) {
|
||||||
|
$query->where('expense_date', '>=', $request->from_date);
|
||||||
|
}
|
||||||
|
if ($request->filled('to_date')) {
|
||||||
|
$query->where('expense_date', '<=', $request->to_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy('expense_date', 'desc');
|
||||||
|
|
||||||
|
$expenses = $query->paginate(20)->appends($request->query());
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$categories = ['materials', 'labor', 'equipment', 'transport', 'services', 'food', 'accommodation', 'other'];
|
||||||
|
$statuses = ['pending', 'approved', 'rejected', 'paid'];
|
||||||
|
|
||||||
|
return view('expenses.index', compact('expenses', 'projects', 'categories', 'statuses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new expense (multi-currency aware).
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->authorize('create', Expense::class);
|
||||||
|
|
||||||
|
$expense = null; // ← این خط اضافه بشه
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$categories = ['materials', 'labor', 'equipment', 'transport', 'services', 'food', 'accommodation', 'other'];
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('expenses.form', compact('expense', 'projects', 'currencies', 'categories', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created expense in storage.
|
||||||
|
*/
|
||||||
|
public function store(StoreExpenseRequest $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Expense::class);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$data['tenant_id'] = $this->getTenantId();
|
||||||
|
$data['requested_by'] = Auth::id();
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($data['expense_date'])) {
|
||||||
|
$data['expense_date'] = \App\Helpers\CalendarHelper::parseDate($data['expense_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($data['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$data['original_amount'] = $data['amount'];
|
||||||
|
$data['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $data['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$data['receipt_path'] = $request->file('receipt')->store('receipts', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expense = Expense::create($data);
|
||||||
|
|
||||||
|
// Handle multiple file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
$expense->attachFiles($request->file('attachments'), 'receipt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('expenses.show', $expense)
|
||||||
|
->with('success', __('expense.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified expense.
|
||||||
|
*/
|
||||||
|
public function show(Expense $expense)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $expense);
|
||||||
|
|
||||||
|
$expense->load(['project', 'currency', 'requestedBy', 'approvedBy', 'pettyCash']);
|
||||||
|
|
||||||
|
return view('expenses.show', compact('expense'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified expense.
|
||||||
|
*/
|
||||||
|
public function edit(Expense $expense)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $expense);
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$categories = ['materials', 'labor', 'equipment', 'transport', 'services', 'food', 'accommodation', 'other'];
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('expenses.form', compact('expense', 'projects', 'currencies', 'categories', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified expense in storage.
|
||||||
|
*/
|
||||||
|
public function update(StoreExpenseRequest $request, Expense $expense)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $expense);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($data['expense_date'])) {
|
||||||
|
$data['expense_date'] = \App\Helpers\CalendarHelper::parseDate($data['expense_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($data['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$data['original_amount'] = $data['amount'];
|
||||||
|
$data['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $data['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
// Delete old receipt
|
||||||
|
if ($expense->receipt_path) {
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('public')->delete($expense->receipt_path);
|
||||||
|
}
|
||||||
|
$data['receipt_path'] = $request->file('receipt')->store('receipts', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expense->update($data);
|
||||||
|
|
||||||
|
// Handle multiple file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
$expense->attachFiles($request->file('attachments'), 'receipt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('expenses.show', $expense)
|
||||||
|
->with('success', __('expense.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified expense from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Expense $expense)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $expense);
|
||||||
|
|
||||||
|
$expense->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('expenses.index')
|
||||||
|
->with('success', __('expense.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve an expense via AJAX.
|
||||||
|
*/
|
||||||
|
public function approve(Expense $expense)
|
||||||
|
{
|
||||||
|
$this->authorize('approve', $expense);
|
||||||
|
|
||||||
|
$expense->update([
|
||||||
|
'status' => 'approved',
|
||||||
|
'approved_by' => Auth::id(),
|
||||||
|
'approval_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update petty cash remaining if linked
|
||||||
|
if ($expense->petty_cash_id && $expense->pettyCash) {
|
||||||
|
$expense->pettyCash->updateRemaining();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('expense.approved_successfully'),
|
||||||
|
'status' => $expense->status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject an expense via AJAX.
|
||||||
|
*/
|
||||||
|
public function reject(Expense $expense)
|
||||||
|
{
|
||||||
|
$this->authorize('approve', $expense);
|
||||||
|
|
||||||
|
$expense->update([
|
||||||
|
'status' => 'rejected',
|
||||||
|
'approved_by' => Auth::id(),
|
||||||
|
'approval_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update petty cash remaining if linked
|
||||||
|
if ($expense->petty_cash_id && $expense->pettyCash) {
|
||||||
|
$expense->pettyCash->updateRemaining();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('expense.rejected_successfully'),
|
||||||
|
'status' => $expense->status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
441
app/Http/Controllers/InventoryController.php
Normal file
441
app/Http/Controllers/InventoryController.php
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Item;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class InventoryController extends Controller
|
||||||
|
{
|
||||||
|
// ==========================================
|
||||||
|
// DASHBOARD
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$totalItems = Item::count();
|
||||||
|
$activeItems = Item::where('is_active', true)->count();
|
||||||
|
$totalWarehouses = Warehouse::where('is_active', true)->count();
|
||||||
|
$recentTransactions = InventoryTransaction::with(['item', 'warehouse', 'createdBy'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Low stock items (below min_stock or zero)
|
||||||
|
$lowStockItems = Item::where('is_active', true)
|
||||||
|
->with(['inventoryTransactions'])
|
||||||
|
->get()
|
||||||
|
->filter(function ($item) {
|
||||||
|
$stock = $this->getCurrentStockForItem($item->id);
|
||||||
|
$minStock = $item->min_stock ?? 0;
|
||||||
|
return $stock <= $minStock;
|
||||||
|
})
|
||||||
|
->take(10);
|
||||||
|
|
||||||
|
return view('inventory.index', compact(
|
||||||
|
'totalItems', 'activeItems', 'totalWarehouses',
|
||||||
|
'recentTransactions', 'lowStockItems'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ITEMS CRUD
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
public function items(Request $request)
|
||||||
|
{
|
||||||
|
$query = Item::with(['inventoryTransactions.currency', 'inventoryTransactions.warehouse']);
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('category', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($request->filled('category')) {
|
||||||
|
$query->where('category', $request->category);
|
||||||
|
}
|
||||||
|
if ($request->filled('is_active')) {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $query->orderBy('name')->paginate(20)->appends($request->query());
|
||||||
|
$categories = Item::distinct()->pluck('category')->filter()->values();
|
||||||
|
|
||||||
|
// Calculate current stock for each item
|
||||||
|
$items->through(function ($item) {
|
||||||
|
$item->current_stock = $this->getCurrentStockForItem($item->id);
|
||||||
|
return $item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('inventory.items', compact('items', 'categories'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createItem()
|
||||||
|
{
|
||||||
|
$this->authorize('create', Item::class);
|
||||||
|
|
||||||
|
$item = null;
|
||||||
|
$categories = Item::distinct()->pluck('category')->filter()->values();
|
||||||
|
|
||||||
|
return view('inventory.item-form', compact('item', 'categories'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeItem(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Item::class);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'code' => 'nullable|string|max:50|unique:items,code',
|
||||||
|
'unit' => 'required|string|max:50',
|
||||||
|
'category' => 'nullable|string|max:100',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'min_stock' => 'nullable|numeric|min:0',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['is_active'] = $request->boolean('is_active', true);
|
||||||
|
|
||||||
|
$item = Item::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.items')
|
||||||
|
->with('success', __('inventory.item_created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editItem(Item $item)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $item);
|
||||||
|
|
||||||
|
$categories = Item::distinct()->pluck('category')->filter()->values();
|
||||||
|
|
||||||
|
return view('inventory.item-form', compact('item', 'categories'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItem(Request $request, Item $item)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $item);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'code' => 'nullable|string|max:50|unique:items,code,' . $item->id,
|
||||||
|
'unit' => 'required|string|max:50',
|
||||||
|
'category' => 'nullable|string|max:100',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'min_stock' => 'nullable|numeric|min:0',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->boolean('is_active');
|
||||||
|
|
||||||
|
$item->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.items')
|
||||||
|
->with('success', __('inventory.item_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyItem(Item $item)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $item);
|
||||||
|
|
||||||
|
// Check if item has transactions
|
||||||
|
if ($item->inventoryTransactions()->count() > 0) {
|
||||||
|
return back()->withErrors(['item' => __('inventory.item_has_transactions')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.items')
|
||||||
|
->with('success', __('inventory.item_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// WAREHOUSES CRUD
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
public function warehouses(Request $request)
|
||||||
|
{
|
||||||
|
$query = Warehouse::with(['project']);
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
if ($request->filled('is_active')) {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$warehouses = $query->orderBy('name')->paginate(20)->appends($request->query());
|
||||||
|
$projects = Project::all();
|
||||||
|
|
||||||
|
return view('inventory.warehouses', compact('warehouses', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createWarehouse()
|
||||||
|
{
|
||||||
|
$this->authorize('create', Warehouse::class);
|
||||||
|
|
||||||
|
$warehouse = null;
|
||||||
|
$projects = Project::all();
|
||||||
|
|
||||||
|
return view('inventory.warehouse-form', compact('warehouse', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeWarehouse(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Warehouse::class);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['is_active'] = $request->boolean('is_active', true);
|
||||||
|
|
||||||
|
Warehouse::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.warehouses')
|
||||||
|
->with('success', __('inventory.warehouse_created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editWarehouse(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $warehouse);
|
||||||
|
|
||||||
|
$projects = Project::all();
|
||||||
|
|
||||||
|
return view('inventory.warehouse-form', compact('warehouse', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateWarehouse(Request $request, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $warehouse);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->boolean('is_active');
|
||||||
|
|
||||||
|
$warehouse->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.warehouses')
|
||||||
|
->with('success', __('inventory.warehouse_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyWarehouse(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $warehouse);
|
||||||
|
|
||||||
|
if ($warehouse->inventoryTransactions()->count() > 0) {
|
||||||
|
return back()->withErrors(['warehouse' => __('inventory.warehouse_has_transactions')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$warehouse->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.warehouses')
|
||||||
|
->with('success', __('inventory.warehouse_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TRANSACTIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
public function transactions(Request $request)
|
||||||
|
{
|
||||||
|
$query = InventoryTransaction::with(['item', 'warehouse', 'project', 'currency', 'createdBy']);
|
||||||
|
|
||||||
|
if ($request->filled('item_id')) { $query->where('item_id', $request->item_id); }
|
||||||
|
if ($request->filled('warehouse_id')) { $query->where('warehouse_id', $request->warehouse_id); }
|
||||||
|
if ($request->filled('type')) { $query->byType($request->type); }
|
||||||
|
if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); }
|
||||||
|
if ($request->filled('from_date')) { $query->where('created_at', '>=', $request->from_date); }
|
||||||
|
if ($request->filled('to_date')) { $query->where('created_at', '<=', $request->to_date); }
|
||||||
|
|
||||||
|
$transactions = $query->orderBy('created_at', 'desc')->paginate(20)->appends($request->query());
|
||||||
|
$items = Item::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$warehouses = Warehouse::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$projects = Project::all();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$types = ['in', 'out', 'transfer', 'return', 'adjustment'];
|
||||||
|
|
||||||
|
return view('inventory.transactions', compact(
|
||||||
|
'transactions', 'items', 'warehouses', 'projects', 'currencies', 'types'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stockIn(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'item_id' => 'required|exists:items,id',
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'quantity' => 'required|numeric|min:0.01',
|
||||||
|
'unit_price' => 'nullable|numeric|min:0',
|
||||||
|
'currency_id' => 'nullable|exists:currencies,id',
|
||||||
|
'reference_number' => 'nullable|string|max:100',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['type'] = 'in';
|
||||||
|
$validated['created_by'] = Auth::id();
|
||||||
|
|
||||||
|
if (!empty($validated['unit_price']) && !empty($validated['quantity'])) {
|
||||||
|
$validated['total_price'] = $validated['unit_price'] * $validated['quantity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryTransaction::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.transactions')
|
||||||
|
->with('success', __('inventory.stock_in_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stockOut(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'item_id' => 'required|exists:items,id',
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'quantity' => 'required|numeric|min:0.01',
|
||||||
|
'unit_price' => 'nullable|numeric|min:0',
|
||||||
|
'currency_id' => 'nullable|exists:currencies,id',
|
||||||
|
'reference_number' => 'nullable|string|max:100',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentStock = $this->getCurrentStockForItem($validated['item_id'], $validated['warehouse_id']);
|
||||||
|
|
||||||
|
if ($currentStock < $validated['quantity']) {
|
||||||
|
return back()
|
||||||
|
->withErrors(['quantity' => __('inventory.insufficient_stock')])
|
||||||
|
->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['type'] = 'out';
|
||||||
|
$validated['created_by'] = Auth::id();
|
||||||
|
|
||||||
|
if (!empty($validated['unit_price']) && !empty($validated['quantity'])) {
|
||||||
|
$validated['total_price'] = $validated['unit_price'] * $validated['quantity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryTransaction::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.transactions')
|
||||||
|
->with('success', __('inventory.stock_out_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stockTransfer(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'item_id' => 'required|exists:items,id',
|
||||||
|
'from_warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'quantity' => 'required|numeric|min:0.01',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check source stock
|
||||||
|
$currentStock = $this->getCurrentStockForItem($validated['item_id'], $validated['from_warehouse_id']);
|
||||||
|
if ($currentStock < $validated['quantity']) {
|
||||||
|
return back()
|
||||||
|
->withErrors(['quantity' => __('inventory.insufficient_stock_source')])
|
||||||
|
->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated) {
|
||||||
|
$tenantId = $this->getTenantId();
|
||||||
|
$userId = Auth::id();
|
||||||
|
|
||||||
|
// Out from source
|
||||||
|
InventoryTransaction::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'item_id' => $validated['item_id'],
|
||||||
|
'warehouse_id' => $validated['from_warehouse_id'],
|
||||||
|
'project_id' => $validated['project_id'] ?? null,
|
||||||
|
'type' => 'transfer',
|
||||||
|
'quantity' => $validated['quantity'],
|
||||||
|
'reference_number' => 'TRF-' . time(),
|
||||||
|
'notes' => ($validated['notes'] ?? '') . ' — انتقال به انبار مقصد',
|
||||||
|
'created_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// In to destination
|
||||||
|
InventoryTransaction::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'item_id' => $validated['item_id'],
|
||||||
|
'warehouse_id' => $validated['to_warehouse_id'],
|
||||||
|
'project_id' => $validated['project_id'] ?? null,
|
||||||
|
'type' => 'transfer',
|
||||||
|
'quantity' => $validated['quantity'],
|
||||||
|
'reference_number' => 'TRF-' . time(),
|
||||||
|
'notes' => ($validated['notes'] ?? '') . ' — انتقال از انبار مبدأ',
|
||||||
|
'created_by' => $userId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory.transactions')
|
||||||
|
->with('success', __('inventory.transfer_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentStock(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'item_id' => 'required|exists:items,id',
|
||||||
|
'warehouse_id' => 'nullable|exists:warehouses,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stock = $this->getCurrentStockForItem($request->item_id, $request->warehouse_id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'item_id' => $request->item_id,
|
||||||
|
'warehouse_id' => $request->warehouse_id,
|
||||||
|
'current_stock' => $stock,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// STOCK HELPERS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
private function getCurrentStockForItem(int $itemId, ?int $warehouseId = null): float
|
||||||
|
{
|
||||||
|
$query = InventoryTransaction::where('item_id', $itemId);
|
||||||
|
|
||||||
|
if ($warehouseId) {
|
||||||
|
$query->where('warehouse_id', $warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalIn = (clone $query)->whereIn('type', ['in', 'return'])->sum('quantity');
|
||||||
|
$totalOut = (clone $query)->where('type', 'out')->sum('quantity');
|
||||||
|
$adjustments = (clone $query)->where('type', 'adjustment')->sum('quantity');
|
||||||
|
|
||||||
|
return (float) ($totalIn - $totalOut + $adjustments);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
app/Http/Controllers/InventoryTransactionController.php
Normal file
176
app/Http/Controllers/InventoryTransactionController.php
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
use App\Models\Item;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class InventoryTransactionController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$tenantId = tenant_id() ?? Auth::user()?->tenant_id;
|
||||||
|
$query = InventoryTransaction::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->with(['item', 'warehouse', 'project', 'currency', 'createdBy']);
|
||||||
|
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->where('type', $request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('item_id')) {
|
||||||
|
$query->where('item_id', $request->item_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('warehouse_id')) {
|
||||||
|
$query->where('warehouse_id', $request->warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('from_date')) {
|
||||||
|
$query->where('date', '>=', $request->from_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('to_date')) {
|
||||||
|
$query->where('date', '<=', $request->to_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $query->orderBy('date', 'desc')->orderBy('created_at', 'desc')->paginate(20)->appends($request->query());
|
||||||
|
|
||||||
|
$items = Item::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->orderBy('name')->get();
|
||||||
|
|
||||||
|
$warehouses = Warehouse::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$projects = Project::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->whereIn('status', ['planning', 'in_progress'])->get();
|
||||||
|
|
||||||
|
$types = ['in', 'out', 'return', 'adjustment'];
|
||||||
|
|
||||||
|
return view('inventory-transactions.index', compact('transactions', 'items', 'warehouses', 'projects', 'types'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$tenantId = tenant_id() ?? Auth::user()?->tenant_id;
|
||||||
|
$warehouses = Warehouse::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->orderBy('name')->get();
|
||||||
|
|
||||||
|
$warehouses = Warehouse::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$projects = Project::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->whereIn('status', ['planning', 'in_progress'])->get();
|
||||||
|
|
||||||
|
$currencies = Currency::all();
|
||||||
|
$types = ['in' => 'in', 'out' => 'out', 'return' => 'return', 'adjustment' => 'adjustment'];
|
||||||
|
|
||||||
|
return view('inventory-transactions.form', compact('items', 'warehouses', 'projects', 'currencies', 'types'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$tenantId = tenant_id() ?? Auth::user()?->tenant_id;
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'type' => 'required|in:in,out,return,adjustment',
|
||||||
|
'item_id' => 'required|exists:items,id',
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'quantity' => 'required|numeric|min:0.01',
|
||||||
|
'unit_price' => 'nullable|numeric|min:0',
|
||||||
|
'currency_id' => 'nullable|exists:currencies,id',
|
||||||
|
'date' => 'required|date',
|
||||||
|
'reference' => 'nullable|string|max:100',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// بررسی موجودی برای خروج
|
||||||
|
if ($validated['type'] === 'out') {
|
||||||
|
$currentStock = $this->getCurrentStock($validated['item_id'], $validated['warehouse_id']);
|
||||||
|
if ($currentStock < $validated['quantity']) {
|
||||||
|
return back()->withErrors(['quantity' => __('inventory.insufficient_stock')])
|
||||||
|
->withInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $tenantId;
|
||||||
|
$validated['created_by'] = Auth::id();
|
||||||
|
|
||||||
|
if (!empty($validated['unit_price']) && !empty($validated['quantity'])) {
|
||||||
|
$validated['total_price'] = $validated['unit_price'] * $validated['quantity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryTransaction::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory-transactions.index')
|
||||||
|
->with('success', __('inventory.transaction_created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(InventoryTransaction $inventoryTransaction)
|
||||||
|
{
|
||||||
|
$inventoryTransaction->load(['item', 'warehouse', 'project', 'currency', 'createdBy']);
|
||||||
|
|
||||||
|
return view('inventory-transactions.show', compact('inventoryTransaction'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(InventoryTransaction $inventoryTransaction)
|
||||||
|
{
|
||||||
|
$inventoryTransaction->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('inventory-transactions.index')
|
||||||
|
->with('success', __('inventory.transaction_deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentStock(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'item_id' => 'required|exists:items,id',
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stock = $this->getCurrentStock($request->item_id, $request->warehouse_id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'current_stock' => $stock,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCurrentStock(int $itemId, int $warehouseId): float
|
||||||
|
{
|
||||||
|
$totalIn = InventoryTransaction::where('item_id', $itemId)
|
||||||
|
->where('warehouse_id', $warehouseId)
|
||||||
|
->whereIn('type', ['in', 'return'])
|
||||||
|
->sum('quantity');
|
||||||
|
|
||||||
|
$totalOut = InventoryTransaction::where('item_id', $itemId)
|
||||||
|
->where('warehouse_id', $warehouseId)
|
||||||
|
->where('type', 'out')
|
||||||
|
->sum('quantity');
|
||||||
|
|
||||||
|
$adjustments = InventoryTransaction::where('item_id', $itemId)
|
||||||
|
->where('warehouse_id', $warehouseId)
|
||||||
|
->where('type', 'adjustment')
|
||||||
|
->sum('quantity');
|
||||||
|
|
||||||
|
return (float) ($totalIn - $totalOut + $adjustments);
|
||||||
|
}
|
||||||
|
}
|
||||||
258
app/Http/Controllers/LetterController.php
Normal file
258
app/Http/Controllers/LetterController.php
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Letter;
|
||||||
|
use App\Models\LetterAttachment;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class LetterController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of letters with filters.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Letter::class);
|
||||||
|
|
||||||
|
$query = Letter::with(['project', 'attachments']);
|
||||||
|
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->byType($request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->search($request->search);
|
||||||
|
}
|
||||||
|
|
||||||
|
$letters = $query->orderBy('letter_date', 'desc')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20)
|
||||||
|
->appends($request->query());
|
||||||
|
|
||||||
|
$projects = Project::all();
|
||||||
|
$types = ['incoming', 'outgoing'];
|
||||||
|
$statuses = ['draft', 'sent', 'received', 'archived'];
|
||||||
|
|
||||||
|
return view('letters.index', compact('letters', 'projects', 'types', 'statuses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new letter.
|
||||||
|
*/
|
||||||
|
public function create(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Letter::class);
|
||||||
|
|
||||||
|
$projects = Project::all();
|
||||||
|
$letters = Letter::orderBy('letter_date', 'desc')->limit(50)->get();
|
||||||
|
$letter = null;
|
||||||
|
|
||||||
|
// Pre-fill parent_letter_id if provided
|
||||||
|
$preselectedParent = $request->get('parent_letter_id');
|
||||||
|
|
||||||
|
return view('letters.form', compact('projects', 'letters', 'letter', 'preselectedParent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created letter in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Letter::class);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'type' => 'required|in:incoming,outgoing',
|
||||||
|
'subject' => 'required|string|max:255',
|
||||||
|
'sender' => 'required|string|max:255',
|
||||||
|
'recipient' => 'required|string|max:255',
|
||||||
|
'letter_date' => 'required|date',
|
||||||
|
'reference_number' => 'nullable|string|max:100',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'parent_letter_id' => 'nullable|exists:letters,id',
|
||||||
|
'status' => 'required|in:draft,sent,received,archived',
|
||||||
|
'content' => 'nullable|string',
|
||||||
|
'attachments.*' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($validated['letter_date'])) {
|
||||||
|
$validated['letter_date'] = \App\Helpers\CalendarHelper::parseDate($validated['letter_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
|
||||||
|
$letter = Letter::create($validated);
|
||||||
|
|
||||||
|
// Handle file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
foreach ($request->file('attachments') as $file) {
|
||||||
|
$this->storeAttachment($letter, $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove checked attachments (for edit, but safe to check)
|
||||||
|
if ($request->has('remove_attachments')) {
|
||||||
|
foreach ($request->remove_attachments as $attachmentId) {
|
||||||
|
$this->removeAttachment($attachmentId, $letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('letters.show', $letter)
|
||||||
|
->with('success', __('letter.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified letter.
|
||||||
|
*/
|
||||||
|
public function show(Letter $letter)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $letter);
|
||||||
|
|
||||||
|
$letter->load(['project', 'parentLetter', 'replies', 'attachments']);
|
||||||
|
|
||||||
|
return view('letters.show', compact('letter'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified letter.
|
||||||
|
*/
|
||||||
|
public function edit(Letter $letter)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $letter);
|
||||||
|
|
||||||
|
$letter->load('attachments');
|
||||||
|
|
||||||
|
$projects = Project::all();
|
||||||
|
$letters = Letter::where('id', '!=', $letter->id)
|
||||||
|
->orderBy('letter_date', 'desc')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('letters.form', compact('letter', 'projects', 'letters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified letter in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Letter $letter)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $letter);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'type' => 'required|in:incoming,outgoing',
|
||||||
|
'subject' => 'required|string|max:255',
|
||||||
|
'sender' => 'required|string|max:255',
|
||||||
|
'recipient' => 'required|string|max:255',
|
||||||
|
'letter_date' => 'required|date',
|
||||||
|
'reference_number' => 'nullable|string|max:100',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'parent_letter_id' => 'nullable|exists:letters,id',
|
||||||
|
'status' => 'required|in:draft,sent,received,archived',
|
||||||
|
'content' => 'nullable|string',
|
||||||
|
'attachments.*' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($validated['letter_date'])) {
|
||||||
|
$validated['letter_date'] = \App\Helpers\CalendarHelper::parseDate($validated['letter_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$letter->update($validated);
|
||||||
|
|
||||||
|
// Handle file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
foreach ($request->file('attachments') as $file) {
|
||||||
|
$this->storeAttachment($letter, $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove checked attachments
|
||||||
|
if ($request->has('remove_attachments')) {
|
||||||
|
foreach ($request->remove_attachments as $attachmentId) {
|
||||||
|
$this->removeAttachment($attachmentId, $letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('letters.show', $letter)
|
||||||
|
->with('success', __('letter.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified letter from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Letter $letter)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $letter);
|
||||||
|
|
||||||
|
// Delete attachments from storage
|
||||||
|
foreach ($letter->attachments as $attachment) {
|
||||||
|
Storage::disk('public')->delete($attachment->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$letter->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('letters.index')
|
||||||
|
->with('success', __('letter.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive the specified letter.
|
||||||
|
*/
|
||||||
|
public function archive(Letter $letter)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $letter);
|
||||||
|
|
||||||
|
$letter->update(['status' => 'archived']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('letter.archived_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a file attachment for a letter.
|
||||||
|
*/
|
||||||
|
private function storeAttachment(Letter $letter, $file): void
|
||||||
|
{
|
||||||
|
$path = $file->store('letters/' . $letter->id, 'public');
|
||||||
|
|
||||||
|
LetterAttachment::create([
|
||||||
|
'letter_id' => $letter->id,
|
||||||
|
'file_name' => $file->getClientOriginalName(),
|
||||||
|
'file_path' => $path,
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'mime_type' => $file->getMimeType(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a letter attachment.
|
||||||
|
*/
|
||||||
|
private function removeAttachment(int $attachmentId, Letter $letter): void
|
||||||
|
{
|
||||||
|
$attachment = LetterAttachment::where('id', $attachmentId)
|
||||||
|
->where('letter_id', $letter->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($attachment) {
|
||||||
|
Storage::disk('public')->delete($attachment->file_path);
|
||||||
|
$attachment->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
467
app/Http/Controllers/PayrollController.php
Normal file
467
app/Http/Controllers/PayrollController.php
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\EmployeeFinancial;
|
||||||
|
use App\Models\EmployeeWorkLog;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\PayrollPeriod;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class PayrollController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display payroll dashboard with stats.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$currentYear = now()->year;
|
||||||
|
$currentMonth = now()->month;
|
||||||
|
|
||||||
|
$year = $request->get('year', $currentYear);
|
||||||
|
$month = $request->get('month', $currentMonth);
|
||||||
|
|
||||||
|
// Statistics for the selected month/year
|
||||||
|
$financials = EmployeeFinancial::with(['employee', 'currency'])
|
||||||
|
->where('tenant_id', $this->getTenantId())
|
||||||
|
->where('month', $month)
|
||||||
|
->where('year', $year)
|
||||||
|
->where('is_settlement', false)
|
||||||
|
->orderBy('employee_id')
|
||||||
|
->orderBy('type')
|
||||||
|
->paginate(30)
|
||||||
|
->appends($request->query());
|
||||||
|
|
||||||
|
// Calculate totals for the month
|
||||||
|
$allMonthFinancials = EmployeeFinancial::where('tenant_id', $this->getTenantId())
|
||||||
|
->where('month', $month)
|
||||||
|
->where('year', $year)
|
||||||
|
->where('is_settlement', false)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalGross = $allMonthFinancials->whereIn('type', ['salary', 'overtime', 'bonus', 'allowance'])->sum('amount');
|
||||||
|
$totalDeductions = $allMonthFinancials->where('type', 'deduction')->sum('amount');
|
||||||
|
$totalAdvances = $allMonthFinancials->where('type', 'advance')->sum('amount');
|
||||||
|
$totalNet = $totalGross - $totalDeductions - $totalAdvances;
|
||||||
|
$totalPaid = $allMonthFinancials->where('is_paid', true)->whereIn('type', ['salary', 'overtime', 'bonus', 'allowance'])->sum('amount');
|
||||||
|
$totalUnpaid = $totalGross - $totalPaid;
|
||||||
|
|
||||||
|
// Employee count with financials this month
|
||||||
|
$activeEmployeeCount = $allMonthFinancials->pluck('employee_id')->unique()->count();
|
||||||
|
$unpaidEmployeeCount = $allMonthFinancials->where('is_paid', false)->pluck('employee_id')->unique()->count();
|
||||||
|
|
||||||
|
$employees = Employee::where('tenant_id', $this->getTenantId())
|
||||||
|
->where('status', 'active')
|
||||||
|
->get();
|
||||||
|
$months = range(1, 12);
|
||||||
|
$years = range($currentYear - 2, $currentYear + 1);
|
||||||
|
|
||||||
|
// Persian month names
|
||||||
|
$persianMonths = [
|
||||||
|
1 => 'فروردین', 2 => 'اردیبهشت', 3 => 'خرداد',
|
||||||
|
4 => 'تیر', 5 => 'مرداد', 6 => 'شهریور',
|
||||||
|
7 => 'مهر', 8 => 'آبان', 9 => 'آذر',
|
||||||
|
10 => 'دی', 11 => 'بهمن', 12 => 'اسفند'
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('payroll.index', compact(
|
||||||
|
'financials', 'employees', 'months', 'years', 'year', 'month',
|
||||||
|
'totalGross', 'totalDeductions', 'totalAdvances', 'totalNet',
|
||||||
|
'totalPaid', 'totalUnpaid', 'activeEmployeeCount', 'unpaidEmployeeCount',
|
||||||
|
'persianMonths'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show payroll calculation form.
|
||||||
|
*/
|
||||||
|
public function calculate(Request $request)
|
||||||
|
{
|
||||||
|
$employees = Employee::where('tenant_id', $this->getTenantId())
|
||||||
|
->where('status', 'active')
|
||||||
|
->with(['financials' => function ($q) {
|
||||||
|
$q->where('month', now()->month)->where('year', now()->year);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
|
||||||
|
$currentMonth = now()->month;
|
||||||
|
$currentYear = now()->year;
|
||||||
|
|
||||||
|
return view('payroll.calculate', compact('employees', 'currencies', 'currentMonth', 'currentYear'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-calculate payroll for a month.
|
||||||
|
* Generates salary entries for all active employees based on base_salary + work logs.
|
||||||
|
*/
|
||||||
|
public function autoCalculate(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'month' => 'required|integer|min:1|max:12',
|
||||||
|
'year' => 'required|integer|min:1400|max:1500',
|
||||||
|
'employee_ids' => 'nullable|array',
|
||||||
|
'employee_ids.*' => 'exists:employees,id',
|
||||||
|
'include_worklogs' => 'nullable|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantId = $this->getTenantId();
|
||||||
|
$month = $validated['month'];
|
||||||
|
$year = $validated['year'];
|
||||||
|
|
||||||
|
// Get employees to process
|
||||||
|
$query = Employee::where('tenant_id', $tenantId)->where('status', 'active');
|
||||||
|
if (!empty($validated['employee_ids'])) {
|
||||||
|
$query->whereIn('id', $validated['employee_ids']);
|
||||||
|
}
|
||||||
|
$employees = $query->get();
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
// Check if salary already exists for this month
|
||||||
|
$existingSalary = EmployeeFinancial::where('tenant_id', $tenantId)
|
||||||
|
->where('employee_id', $employee->id)
|
||||||
|
->where('type', 'salary')
|
||||||
|
->where('month', $month)
|
||||||
|
->where('year', $year)
|
||||||
|
->where('is_settlement', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingSalary) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overtime from work logs
|
||||||
|
$overtimeHours = 0;
|
||||||
|
$regularHours = 0;
|
||||||
|
if (!empty($validated['include_worklogs'])) {
|
||||||
|
$workLogs = EmployeeWorkLog::where('employee_id', $employee->id)
|
||||||
|
->whereMonth('date', $month)
|
||||||
|
->whereYear('date', $year)
|
||||||
|
->get();
|
||||||
|
$regularHours = $workLogs->sum('hours');
|
||||||
|
$overtimeHours = $workLogs->sum('overtime_hours');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get currency
|
||||||
|
$currencyId = $employee->currency_id ?? Currency::where('is_default', true)->value('id');
|
||||||
|
|
||||||
|
// Create salary entry
|
||||||
|
if ($employee->base_salary > 0) {
|
||||||
|
EmployeeFinancial::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'employee_id' => $employee->id,
|
||||||
|
'type' => 'salary',
|
||||||
|
'amount' => $employee->base_salary,
|
||||||
|
'currency_id' => $currencyId,
|
||||||
|
'effective_date' => now(),
|
||||||
|
'month' => $month,
|
||||||
|
'year' => $year,
|
||||||
|
'is_paid' => false,
|
||||||
|
'created_by' => Auth::id(),
|
||||||
|
'description' => __('payroll.auto_salary_description', ['month' => $month, 'year' => $year]),
|
||||||
|
]);
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create overtime entry if there are overtime hours
|
||||||
|
if ($overtimeHours > 0 && $employee->base_salary > 0) {
|
||||||
|
$hourlyRate = $employee->base_salary / 200; // rough estimate: 200 working hours/month
|
||||||
|
$overtimeAmount = round($overtimeHours * $hourlyRate * 1.4); // 1.4x for overtime
|
||||||
|
|
||||||
|
EmployeeFinancial::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'employee_id' => $employee->id,
|
||||||
|
'type' => 'overtime',
|
||||||
|
'amount' => $overtimeAmount,
|
||||||
|
'currency_id' => $currencyId,
|
||||||
|
'effective_date' => now(),
|
||||||
|
'month' => $month,
|
||||||
|
'year' => $year,
|
||||||
|
'is_paid' => false,
|
||||||
|
'created_by' => Auth::id(),
|
||||||
|
'description' => __('payroll.auto_overtime_description', ['hours' => $overtimeHours]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = __('payroll.auto_calculated', ['created' => $created, 'skipped' => $skipped]);
|
||||||
|
|
||||||
|
return redirect()->route('payroll.index', ['month' => $month, 'year' => $year])
|
||||||
|
->with('success', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new payroll entry.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'employee_id' => 'required|exists:employees,id',
|
||||||
|
'type' => 'required|in:salary,overtime,bonus,deduction,advance,allowance',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'nullable|exists:currencies,id',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'effective_date' => 'required|date',
|
||||||
|
'month' => 'required|integer|min:1|max:12',
|
||||||
|
'year' => 'required|integer|min:1400|max:1500',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['created_by'] = Auth::id();
|
||||||
|
$validated['is_paid'] = false;
|
||||||
|
|
||||||
|
// Convert jalali date if needed
|
||||||
|
if (!empty($validated['effective_date'])) {
|
||||||
|
$validated['effective_date'] = \App\Helpers\CalendarHelper::parseDate($validated['effective_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
EmployeeFinancial::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('payroll.index', ['month' => $validated['month'], 'year' => $validated['year']])
|
||||||
|
->with('success', __('payroll.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show employee payroll detail.
|
||||||
|
*/
|
||||||
|
public function show(Employee $employee, Request $request)
|
||||||
|
{
|
||||||
|
$year = $request->get('year', now()->year);
|
||||||
|
|
||||||
|
$financials = EmployeeFinancial::with(['currency'])
|
||||||
|
->where('employee_id', $employee->id)
|
||||||
|
->where('year', $year)
|
||||||
|
->orderBy('month')
|
||||||
|
->orderBy('type')
|
||||||
|
->get()
|
||||||
|
->groupBy('month');
|
||||||
|
|
||||||
|
// Calculate yearly totals
|
||||||
|
$allYearFinancials = EmployeeFinancial::where('employee_id', $employee->id)
|
||||||
|
->where('year', $year)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$yearlyGross = $allYearFinancials->whereIn('type', ['salary', 'overtime', 'bonus', 'allowance'])->sum('amount');
|
||||||
|
$yearlyDeductions = $allYearFinancials->where('type', 'deduction')->sum('amount');
|
||||||
|
$yearlyAdvances = $allYearFinancials->where('type', 'advance')->sum('amount');
|
||||||
|
$yearlyNet = $yearlyGross - $yearlyDeductions - $yearlyAdvances;
|
||||||
|
$yearlyPaid = $allYearFinancials->where('is_paid', true)->whereIn('type', ['salary', 'overtime', 'bonus', 'allowance'])->sum('amount');
|
||||||
|
$yearlyUnpaid = $yearlyGross - $yearlyPaid;
|
||||||
|
|
||||||
|
// Work logs for the year
|
||||||
|
$workLogs = EmployeeWorkLog::where('employee_id', $employee->id)
|
||||||
|
->whereYear('date', $year)
|
||||||
|
->get()
|
||||||
|
->groupBy(function ($item) {
|
||||||
|
return \App\Helpers\CalendarHelper::formatDate($item->date, 'n');
|
||||||
|
});
|
||||||
|
|
||||||
|
$persianMonths = [
|
||||||
|
1 => 'فروردین', 2 => 'اردیبهشت', 3 => 'خرداد',
|
||||||
|
4 => 'تیر', 5 => 'مرداد', 6 => 'شهریور',
|
||||||
|
7 => 'مهر', 8 => 'آبان', 9 => 'آذر',
|
||||||
|
10 => 'دی', 11 => 'بهمن', 12 => 'اسفند'
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('payroll.show', compact(
|
||||||
|
'employee', 'financials', 'year', 'persianMonths',
|
||||||
|
'yearlyGross', 'yearlyDeductions', 'yearlyAdvances', 'yearlyNet',
|
||||||
|
'yearlyPaid', 'yearlyUnpaid'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a financial entry as paid.
|
||||||
|
*/
|
||||||
|
public function pay(EmployeeFinancial $financial)
|
||||||
|
{
|
||||||
|
$financial->update([
|
||||||
|
'is_paid' => true,
|
||||||
|
'paid_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('success', __('payroll.marked_as_paid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk pay financial entries.
|
||||||
|
*/
|
||||||
|
public function payBulk(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'financial_ids' => 'required|array',
|
||||||
|
'financial_ids.*' => 'exists:employee_financials,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count = EmployeeFinancial::whereIn('id', $validated['financial_ids'])
|
||||||
|
->where('is_paid', false)
|
||||||
|
->update([
|
||||||
|
'is_paid' => true,
|
||||||
|
'paid_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('success', __('payroll.bulk_paid_successfully') . " ($count)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a financial entry.
|
||||||
|
*/
|
||||||
|
public function destroy(EmployeeFinancial $financial)
|
||||||
|
{
|
||||||
|
if ($financial->is_paid) {
|
||||||
|
return redirect()->back()
|
||||||
|
->with('error', __('payroll.cannot_delete_paid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$financial->delete();
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->with('success', __('payroll.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show settlement form.
|
||||||
|
*/
|
||||||
|
public function settlement()
|
||||||
|
{
|
||||||
|
$employees = Employee::where('tenant_id', $this->getTenantId())
|
||||||
|
->whereIn('status', ['active', 'inactive'])
|
||||||
|
->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
|
||||||
|
return view('payroll.settlement', compact('employees', 'currencies'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview settlement calculation.
|
||||||
|
*/
|
||||||
|
public function previewSettlement(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'employee_id' => 'required|exists:employees,id',
|
||||||
|
'settlement_type' => 'required|in:resignation,termination,end_of_contract',
|
||||||
|
'settlement_date' => 'required|date',
|
||||||
|
'last_working_date' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$employee = Employee::findOrFail($validated['employee_id']);
|
||||||
|
$settlementDate = \App\Helpers\CalendarHelper::parseDate($validated['settlement_date']);
|
||||||
|
|
||||||
|
// Get all unpaid financials
|
||||||
|
$unpaidFinancials = EmployeeFinancial::where('employee_id', $employee->id)
|
||||||
|
->where('is_paid', false)
|
||||||
|
->with('currency')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalPayable = $unpaidFinancials->whereIn('type', ['salary', 'overtime', 'bonus', 'allowance'])->sum('amount');
|
||||||
|
$totalDeductions = $unpaidFinancials->where('type', 'deduction')->sum('amount');
|
||||||
|
$totalAdvances = $unpaidFinancials->where('type', 'advance')->sum('amount');
|
||||||
|
$netPayable = $totalPayable - $totalDeductions - $totalAdvances;
|
||||||
|
|
||||||
|
// Unused vacation days calculation (rough: 2.5 days per month)
|
||||||
|
$monthsWorked = 0;
|
||||||
|
if ($employee->hire_date) {
|
||||||
|
$hireDate = \Carbon\Carbon::parse($employee->hire_date);
|
||||||
|
$settlementCarbon = \Carbon\Carbon::parse($settlementDate);
|
||||||
|
$monthsWorked = $hireDate->diffInMonths($settlementCarbon);
|
||||||
|
}
|
||||||
|
$vacationDays = min($monthsWorked * 2.5, 26); // max 26 days
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'employee' => $employee->only(['id', 'first_name', 'last_name', 'base_salary']),
|
||||||
|
'unpaid_financials' => $unpaidFinancials->map(function ($f) {
|
||||||
|
return [
|
||||||
|
'id' => $f->id,
|
||||||
|
'type' => $f->type,
|
||||||
|
'amount' => $f->amount,
|
||||||
|
'month' => $f->month,
|
||||||
|
'year' => $f->year,
|
||||||
|
'description' => $f->description,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
'total_payable' => $totalPayable,
|
||||||
|
'total_deductions' => $totalDeductions,
|
||||||
|
'total_advances' => $totalAdvances,
|
||||||
|
'net_payable' => $netPayable,
|
||||||
|
'months_worked' => $monthsWorked,
|
||||||
|
'vacation_days' => $vacationDays,
|
||||||
|
'financial_count' => $unpaidFinancials->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process settlement.
|
||||||
|
*/
|
||||||
|
public function processSettlement(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'employee_id' => 'required|exists:employees,id',
|
||||||
|
'settlement_type' => 'required|in:resignation,termination,end_of_contract',
|
||||||
|
'settlement_date' => 'required|date',
|
||||||
|
'last_working_date' => 'nullable|date',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$employee = Employee::findOrFail($validated['employee_id']);
|
||||||
|
$tenantId = $this->getTenantId();
|
||||||
|
$settlementDate = \App\Helpers\CalendarHelper::parseDate($validated['settlement_date']);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($employee, $validated, $tenantId, $settlementDate) {
|
||||||
|
// Get all unpaid financials
|
||||||
|
$unpaidFinancials = EmployeeFinancial::where('employee_id', $employee->id)
|
||||||
|
->where('is_paid', false)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalPayable = $unpaidFinancials->whereIn('type', ['salary', 'overtime', 'bonus', 'allowance'])->sum('amount');
|
||||||
|
$totalDeductions = $unpaidFinancials->where('type', 'deduction')->sum('amount');
|
||||||
|
$totalAdvances = $unpaidFinancials->where('type', 'advance')->sum('amount');
|
||||||
|
$netPayable = $totalPayable - $totalDeductions - $totalAdvances;
|
||||||
|
|
||||||
|
// Create settlement record
|
||||||
|
$settlement = \App\Models\EmployeeSettlement::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'employee_id' => $employee->id,
|
||||||
|
'settlement_type' => $validated['settlement_type'],
|
||||||
|
'settlement_date' => $settlementDate,
|
||||||
|
'last_working_date' => !empty($validated['last_working_date'])
|
||||||
|
? \App\Helpers\CalendarHelper::parseDate($validated['last_working_date'])
|
||||||
|
: null,
|
||||||
|
'total_payable' => $totalPayable,
|
||||||
|
'total_deductions' => $totalDeductions + $totalAdvances,
|
||||||
|
'net_payable' => $netPayable,
|
||||||
|
'currency_id' => $employee->currency_id,
|
||||||
|
'notes' => $validated['notes'] ?? null,
|
||||||
|
'processed_by' => Auth::id(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark all unpaid as paid and link to settlement
|
||||||
|
$unpaidFinancials->each->update([
|
||||||
|
'is_paid' => true,
|
||||||
|
'paid_at' => now(),
|
||||||
|
'is_settlement' => true,
|
||||||
|
'settlement_id' => $settlement->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update employee status
|
||||||
|
$employee->update([
|
||||||
|
'status' => 'terminated',
|
||||||
|
'termination_date' => $settlementDate,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('payroll.index')
|
||||||
|
->with('success', __('payroll.settlement_processed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
375
app/Http/Controllers/PettyCashController.php
Normal file
375
app/Http/Controllers/PettyCashController.php
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\PettyCash;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class PettyCashController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of petty cash requests.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', PettyCash::class);
|
||||||
|
|
||||||
|
$query = PettyCash::with(['project', 'currency', 'requestedBy', 'approvedBy']);
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range filter
|
||||||
|
if ($request->filled('from_date')) {
|
||||||
|
$query->where('request_date', '>=', \App\Helpers\CalendarHelper::parseDate($request->from_date));
|
||||||
|
}
|
||||||
|
if ($request->filled('to_date')) {
|
||||||
|
$query->where('request_date', '<=', \App\Helpers\CalendarHelper::parseDate($request->to_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy('request_date', 'desc');
|
||||||
|
|
||||||
|
$pettyCashes = $query->paginate(20)->appends($request->query());
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$statuses = ['pending', 'approved', 'rejected', 'settled'];
|
||||||
|
|
||||||
|
// Summary statistics
|
||||||
|
$baseQuery = PettyCash::query();
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$baseQuery->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$baseQuery->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalAmount = (clone $baseQuery)->where('status', '!=', 'rejected')->sum('amount');
|
||||||
|
$totalExpenses = (clone $baseQuery)->where('status', '!=', 'rejected')->with('expenses')
|
||||||
|
->get()->sum(function ($pc) {
|
||||||
|
return $pc->expenses->whereIn('status', ['approved', 'paid'])->sum('amount');
|
||||||
|
});
|
||||||
|
$totalRemaining = $totalAmount - $totalExpenses;
|
||||||
|
|
||||||
|
return view('petty-cashes.index', compact('pettyCashes', 'projects', 'statuses', 'totalAmount', 'totalExpenses', 'totalRemaining'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new petty cash request.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->authorize('create', PettyCash::class);
|
||||||
|
|
||||||
|
$pettyCash = null;
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('petty-cashes.form', compact('pettyCash', 'projects', 'currencies', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for adding an expense to a petty cash.
|
||||||
|
*/
|
||||||
|
public function createExpense(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $pettyCash);
|
||||||
|
|
||||||
|
if ($pettyCash->status !== 'approved') {
|
||||||
|
return redirect()->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('error', __('pettyCash.only_approved_can_add_expense'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$pettyCash->load(['project', 'currency']);
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$categories = ['materials', 'labor', 'equipment', 'transport', 'services', 'food', 'accommodation', 'other'];
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('petty-cashes.expense-form', compact('pettyCash', 'currencies', 'categories', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an expense linked to a petty cash.
|
||||||
|
*/
|
||||||
|
public function storeExpense(Request $request, PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $pettyCash);
|
||||||
|
|
||||||
|
if ($pettyCash->status !== 'approved') {
|
||||||
|
return redirect()->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('error', __('pettyCash.only_approved_can_add_expense'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'required|exists:currencies,id',
|
||||||
|
'category' => 'required|string',
|
||||||
|
'expense_date' => 'required|date',
|
||||||
|
'receipt' => 'nullable|file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
'attachments' => 'nullable|array|max:5',
|
||||||
|
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $pettyCash->tenant_id;
|
||||||
|
$validated['project_id'] = $pettyCash->project_id;
|
||||||
|
$validated['petty_cash_id'] = $pettyCash->id;
|
||||||
|
$validated['requested_by'] = Auth::id();
|
||||||
|
$validated['status'] = 'pending';
|
||||||
|
|
||||||
|
// Parse Jalali date
|
||||||
|
if (!empty($validated['expense_date'])) {
|
||||||
|
$validated['expense_date'] = \App\Helpers\CalendarHelper::parseDate($validated['expense_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($validated['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$validated['original_amount'] = $validated['amount'];
|
||||||
|
$validated['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $validated['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$validated['receipt_path'] = $request->file('receipt')->store('receipts', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expense = \App\Models\Expense::create($validated);
|
||||||
|
|
||||||
|
// Handle multiple file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
$expense->attachFiles($request->file('attachments'), 'receipt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update petty cash remaining
|
||||||
|
$pettyCash->updateRemaining();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('success', __('pettyCash.expense_added_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created petty cash request.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', PettyCash::class);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'project_id' => 'required|exists:projects,id',
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'required|exists:currencies,id',
|
||||||
|
'request_date' => 'required|date',
|
||||||
|
'receipt' => 'nullable|file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
'attachments' => 'nullable|array|max:5',
|
||||||
|
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['requested_by'] = Auth::id();
|
||||||
|
$validated['status'] = 'pending';
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($validated['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$validated['original_amount'] = $validated['amount'];
|
||||||
|
$validated['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $validated['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload (legacy)
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
$validated['receipt_path'] = $request->file('receipt')->store('receipts/petty-cash', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pettyCash = PettyCash::create($validated);
|
||||||
|
|
||||||
|
// Set initial remaining = amount
|
||||||
|
$pettyCash->remaining = $pettyCash->amount;
|
||||||
|
$pettyCash->saveQuietly();
|
||||||
|
|
||||||
|
// Handle multiple file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
$pettyCash->attachFiles($request->file('attachments'), 'receipt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('success', __('pettyCash.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function show(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $pettyCash);
|
||||||
|
|
||||||
|
$pettyCash->load(['project', 'currency', 'requestedBy', 'approvedBy', 'expenses', 'attachments']);
|
||||||
|
|
||||||
|
return view('petty-cashes.show', compact('pettyCash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function edit(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $pettyCash);
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
|
||||||
|
return view('petty-cashes.form', compact('pettyCash', 'projects', 'currencies', 'baseCurrency'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $pettyCash);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'project_id' => 'required|exists:projects,id',
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'required|exists:currencies,id',
|
||||||
|
'request_date' => 'required|date',
|
||||||
|
'receipt' => 'nullable|file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
'attachments' => 'nullable|array|max:5',
|
||||||
|
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx|max:10240',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle currency conversion
|
||||||
|
$baseCurrency = CurrencyHelper::getBaseCurrency();
|
||||||
|
$selectedCurrency = Currency::find($validated['currency_id']);
|
||||||
|
|
||||||
|
if ($selectedCurrency && $baseCurrency && $selectedCurrency->id !== $baseCurrency->id) {
|
||||||
|
$validated['original_amount'] = $validated['amount'];
|
||||||
|
$validated['amount'] = CurrencyHelper::convert(
|
||||||
|
(float) $validated['amount'],
|
||||||
|
$selectedCurrency->code,
|
||||||
|
$baseCurrency->code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle receipt upload
|
||||||
|
if ($request->hasFile('receipt')) {
|
||||||
|
// Delete old receipt if exists
|
||||||
|
if ($pettyCash->receipt_path) {
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('public')->delete($pettyCash->receipt_path);
|
||||||
|
}
|
||||||
|
$validated['receipt_path'] = $request->file('receipt')->store('receipts/petty-cash', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pettyCash->update($validated);
|
||||||
|
|
||||||
|
// Handle multiple file attachments
|
||||||
|
if ($request->hasFile('attachments')) {
|
||||||
|
$pettyCash->attachFiles($request->file('attachments'), 'receipt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.show', $pettyCash)
|
||||||
|
->with('success', __('pettyCash.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified petty cash request.
|
||||||
|
*/
|
||||||
|
public function destroy(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $pettyCash);
|
||||||
|
|
||||||
|
$pettyCash->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('petty-cashes.index')
|
||||||
|
->with('success', __('pettyCash.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a petty cash request.
|
||||||
|
*/
|
||||||
|
public function approve(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('approve', $pettyCash);
|
||||||
|
|
||||||
|
$pettyCash->update([
|
||||||
|
'status' => 'approved',
|
||||||
|
'approved_by' => Auth::id(),
|
||||||
|
'approval_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('pettyCash.approved_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a petty cash request.
|
||||||
|
*/
|
||||||
|
public function reject(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('approve', $pettyCash);
|
||||||
|
|
||||||
|
$pettyCash->update([
|
||||||
|
'status' => 'rejected',
|
||||||
|
'approved_by' => Auth::id(),
|
||||||
|
'approval_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('pettyCash.rejected_successfully'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle a petty cash request (mark as settled after usage).
|
||||||
|
*/
|
||||||
|
public function settle(PettyCash $pettyCash)
|
||||||
|
{
|
||||||
|
$this->authorize('approve', $pettyCash);
|
||||||
|
|
||||||
|
// Update remaining before settling
|
||||||
|
$pettyCash->updateRemaining();
|
||||||
|
|
||||||
|
$pettyCash->update([
|
||||||
|
'status' => 'settled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('pettyCash.settled_successfully'),
|
||||||
|
'remaining' => $pettyCash->remaining,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
269
app/Http/Controllers/ProjectController.php
Normal file
269
app/Http/Controllers/ProjectController.php
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreProjectRequest;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of projects with filters and pagination.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
|
$query = Project::with(['manager', 'defaultCurrency', 'tasks']);
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('description', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
$sortField = $request->get('sort', 'created_at');
|
||||||
|
$sortDirection = $request->get('direction', 'desc');
|
||||||
|
$allowedSorts = ['name', 'code', 'status', 'progress', 'budget', 'start_date', 'created_at'];
|
||||||
|
if (!in_array($sortField, $allowedSorts)) {
|
||||||
|
$sortField = 'created_at';
|
||||||
|
}
|
||||||
|
if (!in_array($sortDirection, ['asc', 'desc'])) {
|
||||||
|
$sortDirection = 'desc';
|
||||||
|
}
|
||||||
|
$query->orderBy($sortField, $sortDirection);
|
||||||
|
|
||||||
|
$projects = $query->paginate(12)->appends($request->query());
|
||||||
|
|
||||||
|
return view('projects.index', compact('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new project.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->authorize('create', Project::class);
|
||||||
|
|
||||||
|
$managers = User::where('tenant_id', $this->getTenantId())->where('is_active', true)->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$statuses = ['planning', 'in_progress', 'on_hold', 'completed', 'cancelled'];
|
||||||
|
$project = null;
|
||||||
|
|
||||||
|
return view('projects.form', compact('project', 'managers', 'currencies', 'statuses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created project in storage.
|
||||||
|
*/
|
||||||
|
public function store(StoreProjectRequest $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Project::class);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$data['tenant_id'] = $this->getTenantId();
|
||||||
|
|
||||||
|
$project = Project::create($data);
|
||||||
|
|
||||||
|
// Attach manager as project member
|
||||||
|
if (!empty($data['manager_id'])) {
|
||||||
|
$project->users()->attach($data['manager_id'], ['role' => 'manager']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('projects.show', $project)
|
||||||
|
->with('success', __('project.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified project.
|
||||||
|
*/
|
||||||
|
public function show(Project $project)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
|
||||||
|
$project->load([
|
||||||
|
'manager',
|
||||||
|
'defaultCurrency',
|
||||||
|
'tasks.assignee',
|
||||||
|
'tasks' => fn($q) => $q->orderBy('id')->orderBy('due_date'),
|
||||||
|
//'employees',
|
||||||
|
'expenses.currency',
|
||||||
|
'users',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$taskStats = [
|
||||||
|
'total' => $project->tasks->count(),
|
||||||
|
'pending' => $project->tasks->where('status', 'pending')->count() + $project->tasks->where('status', 'todo')->count(),
|
||||||
|
'in_progress' => $project->tasks->where('status', 'in_progress')->count(),
|
||||||
|
'review' => $project->tasks->where('status', 'review')->count(),
|
||||||
|
'done' => $project->tasks->where('status', 'done')->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('projects.show', compact('project', 'taskStats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified project.
|
||||||
|
*/
|
||||||
|
public function edit(Project $project)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $project);
|
||||||
|
|
||||||
|
$managers = User::where('tenant_id', $this->getTenantId())->where('is_active', true)->get();
|
||||||
|
$currencies = Currency::where('is_active', true)->get();
|
||||||
|
$statuses = ['planning', 'in_progress', 'on_hold', 'completed', 'cancelled'];
|
||||||
|
|
||||||
|
return view('projects.form', compact('project', 'managers', 'currencies', 'statuses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified project in storage.
|
||||||
|
*/
|
||||||
|
public function update(StoreProjectRequest $request, Project $project)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $project);
|
||||||
|
|
||||||
|
$project->update($request->validated());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('projects.show', $project)
|
||||||
|
->with('success', __('project.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete the specified project.
|
||||||
|
*/
|
||||||
|
public function destroy(Project $project)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $project);
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('projects.index')
|
||||||
|
->with('success', __('project.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project status via AJAX.
|
||||||
|
*/
|
||||||
|
public function updateStatus(Request $request, Project $project)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $project);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'status' => 'required|in:planning,in_progress,on_hold,completed,cancelled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$oldStatus = $project->status;
|
||||||
|
$project->status = $request->status;
|
||||||
|
|
||||||
|
if ($request->status === 'completed') {
|
||||||
|
$project->progress = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$project->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('project.status_updated'),
|
||||||
|
'status' => $project->status,
|
||||||
|
'status_label' => $project->status_label,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export projects to Excel.
|
||||||
|
*/
|
||||||
|
public function exportExcel(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
|
$query = Project::with(['manager', 'defaultCurrency']);
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects = $query->get();
|
||||||
|
|
||||||
|
// Simple CSV export
|
||||||
|
$filename = 'projects_' . now()->format('Y-m-d_His') . '.csv';
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||||
|
];
|
||||||
|
|
||||||
|
$callback = function () use ($projects) {
|
||||||
|
$file = fopen('php://output', 'w');
|
||||||
|
// BOM for UTF-8
|
||||||
|
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
|
|
||||||
|
fputcsv($file, [
|
||||||
|
__('project.code'),
|
||||||
|
__('project.name'),
|
||||||
|
__('project.status'),
|
||||||
|
__('project.progress'),
|
||||||
|
__('project.budget'),
|
||||||
|
__('project.manager'),
|
||||||
|
__('project.start_date'),
|
||||||
|
__('project.end_date'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
fputcsv($file, [
|
||||||
|
$project->code,
|
||||||
|
$project->name,
|
||||||
|
$project->status_label,
|
||||||
|
$project->progress . '%',
|
||||||
|
$project->budget,
|
||||||
|
$project->manager?->name,
|
||||||
|
$project->start_date?->format('Y-m-d'),
|
||||||
|
$project->end_date?->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return response()->stream($callback, 200, $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export projects to PDF.
|
||||||
|
*/
|
||||||
|
public function exportPdf(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
|
$query = Project::with(['manager', 'defaultCurrency', 'tasks']);
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects = $query->get();
|
||||||
|
|
||||||
|
$pdf = Pdf::loadView('projects.pdf', compact('projects'));
|
||||||
|
$filename = 'projects_' . now()->format('Y-m-d_His') . '.pdf';
|
||||||
|
|
||||||
|
return $pdf->download($filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Http/Controllers/ReportController.php
Normal file
201
app/Http/Controllers/ReportController.php
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Expense;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\EmployeeFinancial;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Item;
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
use App\Models\WorkLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display reports index.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('reports.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display financial report.
|
||||||
|
*/
|
||||||
|
public function financial(Request $request)
|
||||||
|
{
|
||||||
|
$query = Expense::with(['project', 'currency']);
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('from_date')) {
|
||||||
|
$query->where('expense_date', '>=', $request->from_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('to_date')) {
|
||||||
|
$query->where('expense_date', '<=', $request->to_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expenses = $query->orderBy('expense_date', 'desc')->get();
|
||||||
|
|
||||||
|
$totalAmount = $expenses->sum('amount');
|
||||||
|
$approvedAmount = $expenses->where('status', 'approved')->sum('amount');
|
||||||
|
$pendingAmount = $expenses->where('status', 'pending')->sum('amount');
|
||||||
|
|
||||||
|
$byProject = $expenses->groupBy('project_id')->map(function ($group) {
|
||||||
|
return [
|
||||||
|
'project' => $group->first()?->project?->name ?? '-',
|
||||||
|
'total' => $group->sum('amount'),
|
||||||
|
'count' => $group->count(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$byCategory = $expenses->groupBy('category')->map(function ($group) {
|
||||||
|
return [
|
||||||
|
'category' => $group->first()->category ?? '-',
|
||||||
|
'total' => $group->sum('amount'),
|
||||||
|
'count' => $group->count(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$projects = Project::all();
|
||||||
|
|
||||||
|
return view('reports.financial', compact('expenses', 'totalAmount', 'approvedAmount', 'pendingAmount', 'byProject', 'byCategory', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export financial report to Excel.
|
||||||
|
*/
|
||||||
|
public function financialExportExcel(Request $request)
|
||||||
|
{
|
||||||
|
// Stub - will implement with Excel export library
|
||||||
|
return back()->with('info', __('reports.export_coming_soon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export financial report to PDF.
|
||||||
|
*/
|
||||||
|
public function financialExportPdf(Request $request)
|
||||||
|
{
|
||||||
|
// Stub - will implement with PDF library
|
||||||
|
return back()->with('info', __('reports.export_coming_soon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display employee work-log report.
|
||||||
|
*/
|
||||||
|
public function employeeWorklog(Request $request)
|
||||||
|
{
|
||||||
|
$query = WorkLog::with(['employee', 'project', 'task']);
|
||||||
|
|
||||||
|
if ($request->filled('employee_id')) {
|
||||||
|
$query->where('employee_id', $request->employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('from_date')) {
|
||||||
|
$query->where('date', '>=', $request->from_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('to_date')) {
|
||||||
|
$query->where('date', '<=', $request->to_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workLogs = $query->orderBy('date', 'desc')->paginate(30)->appends($request->query());
|
||||||
|
$employees = Employee::where('status', 'active')->get();
|
||||||
|
$projects = Project::all();
|
||||||
|
|
||||||
|
return view('reports.employee-worklog', compact('workLogs', 'employees', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export employee work-log report to Excel.
|
||||||
|
*/
|
||||||
|
public function employeeWorklogExportExcel(Request $request)
|
||||||
|
{
|
||||||
|
return back()->with('info', __('reports.export_coming_soon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display inventory report.
|
||||||
|
*/
|
||||||
|
public function inventory(Request $request)
|
||||||
|
{
|
||||||
|
$query = InventoryTransaction::with(['item', 'warehouse', 'project']);
|
||||||
|
|
||||||
|
if ($request->filled('warehouse_id')) {
|
||||||
|
$query->where('warehouse_id', $request->warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->where('type', $request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('from_date')) {
|
||||||
|
$query->where('created_at', '>=', $request->from_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('to_date')) {
|
||||||
|
$query->where('created_at', '<=', $request->to_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $query->orderBy('created_at', 'desc')->paginate(30)->appends($request->query());
|
||||||
|
|
||||||
|
$items = Item::orderBy('name')->get();
|
||||||
|
$warehouses = \App\Models\Warehouse::orderBy('name')->get();
|
||||||
|
|
||||||
|
return view('reports.inventory', compact('transactions', 'items', 'warehouses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export inventory report to Excel.
|
||||||
|
*/
|
||||||
|
public function inventoryExportExcel(Request $request)
|
||||||
|
{
|
||||||
|
return back()->with('info', __('reports.export_coming_soon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display payroll report.
|
||||||
|
*/
|
||||||
|
public function payroll(Request $request)
|
||||||
|
{
|
||||||
|
$query = EmployeeFinancial::with(['employee', 'currency']);
|
||||||
|
|
||||||
|
if ($request->filled('month')) {
|
||||||
|
$query->where('month', $request->month);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('year')) {
|
||||||
|
$query->where('year', $request->year);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->byType($request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$financials = $query->orderBy('effective_date', 'desc')->paginate(30)->appends($request->query());
|
||||||
|
|
||||||
|
$employees = Employee::where('status', 'active')->get();
|
||||||
|
$months = range(1, 12);
|
||||||
|
$currentYear = now()->year;
|
||||||
|
$years = range($currentYear - 2, $currentYear + 1);
|
||||||
|
|
||||||
|
return view('reports.payroll', compact('financials', 'employees', 'months', 'years'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export payroll report to Excel.
|
||||||
|
*/
|
||||||
|
public function payrollExportExcel(Request $request)
|
||||||
|
{
|
||||||
|
return back()->with('info', __('reports.export_coming_soon'));
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/Http/Controllers/SettingsController.php
Normal file
178
app/Http/Controllers/SettingsController.php
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class SettingsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display settings index.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$tenant = Tenant::find($this->getTenantId());
|
||||||
|
$users = User::where('tenant_id', $this->getTenantId())->get();
|
||||||
|
$currencies = Currency::all();
|
||||||
|
|
||||||
|
return view('settings.index', compact('tenant', 'users', 'currencies'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update organization settings.
|
||||||
|
*/
|
||||||
|
public function updateOrganization(Request $request)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::find($this->getTenantId());
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'default_locale' => 'required|in:fa,en,ar',
|
||||||
|
'default_calendar' => 'required|in:jalali,gregorian',
|
||||||
|
'logo' => 'nullable|image|max:2048',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
// Delete old logo
|
||||||
|
if ($tenant->logo) {
|
||||||
|
Storage::disk('public')->delete($tenant->logo);
|
||||||
|
}
|
||||||
|
$validated['logo'] = $request->file('logo')->store('logos', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.organization_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new user.
|
||||||
|
*/
|
||||||
|
public function storeUser(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|unique:users,email',
|
||||||
|
'password' => 'required|string|min:8|confirmed',
|
||||||
|
'roles' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $this->getTenantId();
|
||||||
|
$validated['password'] = Hash::make($validated['password']);
|
||||||
|
$validated['is_active'] = true;
|
||||||
|
|
||||||
|
$roles = $validated['roles'] ?? [];
|
||||||
|
unset($validated['roles']);
|
||||||
|
|
||||||
|
$user = User::create($validated);
|
||||||
|
|
||||||
|
if (!empty($roles)) {
|
||||||
|
$user->assignRole($roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.user_created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user.
|
||||||
|
*/
|
||||||
|
public function updateUser(Request $request, User $user)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|unique:users,email,' . $user->id,
|
||||||
|
'password' => 'nullable|string|min:8|confirmed',
|
||||||
|
'roles' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($validated['password'])) {
|
||||||
|
$validated['password'] = Hash::make($validated['password']);
|
||||||
|
} else {
|
||||||
|
unset($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $validated['roles'] ?? [];
|
||||||
|
unset($validated['roles']);
|
||||||
|
|
||||||
|
$user->update($validated);
|
||||||
|
|
||||||
|
$user->syncRoles($roles);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.user_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle user active status.
|
||||||
|
*/
|
||||||
|
public function toggleUser(User $user)
|
||||||
|
{
|
||||||
|
$user->update(['is_active' => !$user->is_active]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.user_status_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new currency.
|
||||||
|
*/
|
||||||
|
public function storeCurrency(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => 'required|string|max:10|unique:currencies,code',
|
||||||
|
'name' => 'required|string|max:100',
|
||||||
|
'symbol' => 'required|string|max:10',
|
||||||
|
'decimal_places' => 'required|integer|min:0|max:6',
|
||||||
|
'exchange_rate_to_usd' => 'required|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Currency::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.currency_created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a currency.
|
||||||
|
*/
|
||||||
|
public function updateCurrency(Request $request, Currency $currency)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:100',
|
||||||
|
'symbol' => 'required|string|max:10',
|
||||||
|
'decimal_places' => 'required|integer|min:0|max:6',
|
||||||
|
'exchange_rate_to_usd' => 'required|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currency->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.currency_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle currency active status.
|
||||||
|
*/
|
||||||
|
public function toggleCurrency(Currency $currency)
|
||||||
|
{
|
||||||
|
$currency->update(['is_active' => !$currency->is_active]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('settings.index')
|
||||||
|
->with('success', __('settings.currency_status_updated'));
|
||||||
|
}
|
||||||
|
}
|
||||||
362
app/Http/Controllers/TaskController.php
Normal file
362
app/Http/Controllers/TaskController.php
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreTaskRequest;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\TaskLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class TaskController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of tasks with filters.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Task::class);
|
||||||
|
|
||||||
|
$query = Task::with(['project', 'assignee', 'reporter']);
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->byStatus($request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority
|
||||||
|
if ($request->filled('priority')) {
|
||||||
|
$query->byPriority($request->priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by assignee
|
||||||
|
if ($request->filled('assignee_id')) {
|
||||||
|
$query->where('assignee_id', $request->assignee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only top-level tasks by default
|
||||||
|
$query->topLevel();
|
||||||
|
|
||||||
|
$query->orderBy('order')->orderBy('due_date', 'asc');
|
||||||
|
|
||||||
|
$tasks = $query->paginate(20)->appends($request->query());
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$users = User::where('tenant_id', $this->getTenantId())->where('is_active', true)->get();
|
||||||
|
$statuses = ['pending', 'in_progress', 'review', 'done', 'cancelled'];
|
||||||
|
$priorities = ['low', 'medium', 'high', 'urgent'];
|
||||||
|
|
||||||
|
return view('tasks.index', compact('tasks', 'projects', 'users', 'statuses', 'priorities'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new task.
|
||||||
|
*/
|
||||||
|
public function create(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Task::class);
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$users = User::where('tenant_id', $this->getTenantId())->where('is_active', true)->get();
|
||||||
|
$statuses = ['pending', 'in_progress', 'review', 'done', 'cancelled'];
|
||||||
|
$priorities = ['low', 'medium', 'high', 'urgent'];
|
||||||
|
|
||||||
|
$preselectedProject = $request->get('project_id');
|
||||||
|
|
||||||
|
return view('tasks.form', compact('projects', 'users', 'statuses', 'priorities', 'preselectedProject'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created task in storage.
|
||||||
|
*/
|
||||||
|
public function store(StoreTaskRequest $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Task::class);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$data['tenant_id'] = $this->getTenantId();
|
||||||
|
$data['reporter_id'] = Auth::id();
|
||||||
|
|
||||||
|
// Convert jalali dates if needed
|
||||||
|
if (!empty($data['due_date'])) {
|
||||||
|
$data['due_date'] = \App\Helpers\CalendarHelper::parseDate($data['due_date']);
|
||||||
|
}
|
||||||
|
if (!empty($data['start_date'])) {
|
||||||
|
$data['start_date'] = \App\Helpers\CalendarHelper::parseDate($data['start_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = Task::create($data);
|
||||||
|
|
||||||
|
// Log the creation
|
||||||
|
TaskLog::create([
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'field_changed' => 'created',
|
||||||
|
'old_value' => null,
|
||||||
|
'new_value' => ['status' => $task->status],
|
||||||
|
'comment' => __('task.log_created'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('tasks.show', $task)
|
||||||
|
->with('success', __('task.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified task with logs.
|
||||||
|
*/
|
||||||
|
public function show(Task $task)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $task);
|
||||||
|
|
||||||
|
$task->load([
|
||||||
|
'project',
|
||||||
|
'assignee',
|
||||||
|
'reporter',
|
||||||
|
'logs.user',
|
||||||
|
'children.assignee',
|
||||||
|
'workLogs.employee',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return view('tasks.show', compact('task'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified task.
|
||||||
|
*/
|
||||||
|
public function edit(Task $task)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $task);
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$users = User::where('tenant_id', $this->getTenantId())->where('is_active', true)->get();
|
||||||
|
$statuses = ['pending', 'in_progress', 'review', 'done', 'cancelled'];
|
||||||
|
$priorities = ['low', 'medium', 'high', 'urgent'];
|
||||||
|
|
||||||
|
return view('tasks.form', compact('task', 'projects', 'users', 'statuses', 'priorities'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified task in storage.
|
||||||
|
*/
|
||||||
|
public function update(StoreTaskRequest $request, Task $task)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $task);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Convert jalali dates if needed
|
||||||
|
if (!empty($data['due_date'])) {
|
||||||
|
$data['due_date'] = \App\Helpers\CalendarHelper::parseDate($data['due_date']);
|
||||||
|
}
|
||||||
|
if (!empty($data['start_date'])) {
|
||||||
|
$data['start_date'] = \App\Helpers\CalendarHelper::parseDate($data['start_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track changes for TaskLog
|
||||||
|
$changedFields = [];
|
||||||
|
$watchFields = ['status', 'priority', 'assignee_id', 'title', 'due_date', 'project_id'];
|
||||||
|
|
||||||
|
foreach ($watchFields as $field) {
|
||||||
|
if (isset($data[$field]) && $task->$field != $data[$field]) {
|
||||||
|
$changedFields[$field] = [
|
||||||
|
'old' => $task->$field,
|
||||||
|
'new' => $data[$field],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$task->update($data);
|
||||||
|
|
||||||
|
// Create TaskLog entries for changed fields
|
||||||
|
foreach ($changedFields as $field => $changes) {
|
||||||
|
TaskLog::create([
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'field_changed' => $field,
|
||||||
|
'old_value' => $changes['old'],
|
||||||
|
'new_value' => $changes['new'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('tasks.show', $task)
|
||||||
|
->with('success', __('task.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified task from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Task $task)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $task);
|
||||||
|
|
||||||
|
$task->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('tasks.index')
|
||||||
|
->with('success', __('task.deleted_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update task status via AJAX.
|
||||||
|
*/
|
||||||
|
public function updateStatus(Request $request, Task $task)
|
||||||
|
{
|
||||||
|
$this->authorize('updateStatus', $task);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'status' => 'required|in:pending,in_progress,review,done,cancelled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$oldStatus = $task->status;
|
||||||
|
$task->status = $request->status;
|
||||||
|
|
||||||
|
if ($request->status === 'done') {
|
||||||
|
$task->completed_at = now();
|
||||||
|
} elseif ($oldStatus === 'done' && $request->status !== 'done') {
|
||||||
|
$task->completed_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$task->save();
|
||||||
|
|
||||||
|
// Log the status change
|
||||||
|
TaskLog::create([
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'field_changed' => 'status',
|
||||||
|
'old_value' => $oldStatus,
|
||||||
|
'new_value' => $request->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('task.status_updated'),
|
||||||
|
'status' => $task->status,
|
||||||
|
'status_label' => $task->status_label,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display tasks in Kanban board view.
|
||||||
|
*/
|
||||||
|
public function kanban(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Task::class);
|
||||||
|
|
||||||
|
$query = Task::with(['project', 'assignee']);
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->topLevel();
|
||||||
|
|
||||||
|
$allTasks = $query->get();
|
||||||
|
|
||||||
|
$kanbanColumns = [
|
||||||
|
'pending' => $allTasks->where('status', 'pending'),
|
||||||
|
'in_progress' => $allTasks->where('status', 'in_progress'),
|
||||||
|
'review' => $allTasks->where('status', 'review'),
|
||||||
|
'done' => $allTasks->where('status', 'done'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
$selectedProject = $request->get('project_id');
|
||||||
|
|
||||||
|
return view('tasks.kanban', compact('kanbanColumns', 'projects', 'selectedProject'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display tasks in Gantt chart view.
|
||||||
|
*/
|
||||||
|
public function gantt(Request $request, ?Task $task = null)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Task::class);
|
||||||
|
|
||||||
|
$query = Task::with(['project', 'assignee']);
|
||||||
|
|
||||||
|
if ($task && $task->exists) {
|
||||||
|
$query->where('project_id', $task->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tasks = $query->get();
|
||||||
|
$projects = Project::whereIn('status', ['planning', 'active'])->get();
|
||||||
|
|
||||||
|
// Group tasks by project for virtual project rows
|
||||||
|
$ganttData = [];
|
||||||
|
$projectIdCounter = 10000;
|
||||||
|
|
||||||
|
$grouped = $tasks->groupBy('project_id');
|
||||||
|
|
||||||
|
foreach ($grouped as $projectId => $projectTasks) {
|
||||||
|
$project = $projectTasks->first()?->project;
|
||||||
|
if (!$project) continue;
|
||||||
|
|
||||||
|
// Calculate project date range from tasks or use project dates
|
||||||
|
$minStart = $projectTasks->filter(fn($t) => $t->start_date)->min('start_date')
|
||||||
|
?? $project->start_date
|
||||||
|
?? now();
|
||||||
|
$maxDue = $projectTasks->filter(fn($t) => $t->due_date)->max('due_date')
|
||||||
|
?? $project->end_date
|
||||||
|
?? now()->addDays(30);
|
||||||
|
|
||||||
|
// Add virtual project row
|
||||||
|
$ganttData[] = [
|
||||||
|
'id' => $projectIdCounter + $projectId,
|
||||||
|
'text' => $project->name,
|
||||||
|
'start_date' => \Carbon\Carbon::parse($minStart)->format('d-m-Y'),
|
||||||
|
'duration' => max(1, \Carbon\Carbon::parse($minStart)->diffInDays(\Carbon\Carbon::parse($maxDue))),
|
||||||
|
'progress' => 0,
|
||||||
|
'open' => true,
|
||||||
|
'type' => 'project',
|
||||||
|
'project_id' => $projectId,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add task rows — handle missing dates
|
||||||
|
foreach ($projectTasks as $t) {
|
||||||
|
$startDate = $t->start_date
|
||||||
|
? \Carbon\Carbon::parse($t->start_date)
|
||||||
|
: \Carbon\Carbon::parse($minStart);
|
||||||
|
$dueDate = $t->due_date
|
||||||
|
? \Carbon\Carbon::parse($t->due_date)
|
||||||
|
: $startDate->copy()->addDays(7);
|
||||||
|
$duration = max(1, $startDate->diffInDays($dueDate));
|
||||||
|
|
||||||
|
$statusColor = match($t->status) {
|
||||||
|
'done' => '#10b981',
|
||||||
|
'in_progress' => '#3b82f6',
|
||||||
|
'review' => '#f59e0b',
|
||||||
|
'cancelled' => '#ef4444',
|
||||||
|
default => '#14b8a6',
|
||||||
|
};
|
||||||
|
|
||||||
|
$ganttData[] = [
|
||||||
|
'id' => $t->id,
|
||||||
|
'text' => $t->title,
|
||||||
|
'start_date' => $startDate->format('d-m-Y'),
|
||||||
|
'duration' => $duration,
|
||||||
|
'progress' => (float) ($t->progress ?? 0) / 100,
|
||||||
|
'parent' => $projectIdCounter + $projectId,
|
||||||
|
'color' => $statusColor,
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'status' => $t->status,
|
||||||
|
'assignee' => $t->assignee?->name ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('tasks.gantt', compact('ganttData', 'projects'));
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/Http/Controllers/WarehouseController.php
Normal file
117
app/Http/Controllers/WarehouseController.php
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class WarehouseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$tenantId = session('tenant_id') ?? Auth::user()?->tenant_id;
|
||||||
|
$query = Warehouse::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->with(['project']);
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('location', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('is_active')) {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$warehouses = $query->orderBy('name')->paginate(20)->appends($request->query());
|
||||||
|
$projects = Project::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->whereIn('status', ['planning', 'in_progress'])->get();
|
||||||
|
|
||||||
|
return view('warehouses.index', compact('warehouses', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$tenantId = session('tenant_id') ?? Auth::user()?->tenant_id;
|
||||||
|
$projects = Project::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->whereIn('status', ['planning', 'in_progress'])->get();
|
||||||
|
|
||||||
|
return view('warehouses.form', compact('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$tenantId = session('tenant_id') ?? Auth::user()?->tenant_id;
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['tenant_id'] = $tenantId;
|
||||||
|
$validated['is_active'] = $request->has('is_active');
|
||||||
|
|
||||||
|
Warehouse::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('warehouses.index')
|
||||||
|
->with('success', __('warehouse.created_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$warehouse->load(['project']);
|
||||||
|
|
||||||
|
return view('warehouses.show', compact('warehouse'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$tenantId = session('tenant_id') ?? Auth::user()?->tenant_id;
|
||||||
|
$projects = Project::when($tenantId, function($q) use ($tenantId) {
|
||||||
|
$q->where('tenant_id', $tenantId);
|
||||||
|
})->whereIn('status', ['planning', 'in_progress'])->get();
|
||||||
|
|
||||||
|
return view('warehouses.form', compact('warehouse', 'projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->has('is_active');
|
||||||
|
|
||||||
|
$warehouse->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('warehouses.index')
|
||||||
|
->with('success', __('warehouse.updated_successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$warehouse->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('warehouses.index')
|
||||||
|
->with('success', __('warehouse.deleted_successfully'));
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Kernel.php
Normal file
67
app/Http/Kernel.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
|
|
||||||
|
class Kernel extends HttpKernel
|
||||||
|
{
|
||||||
|
protected $middleware = [
|
||||||
|
\Illuminate\Http\Middleware\HandleCors::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $middlewareGroups = [
|
||||||
|
'web' => [
|
||||||
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\SetTenant::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'api' => [
|
||||||
|
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||||
|
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\SetTenant::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $middlewareAliases = [
|
||||||
|
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||||
|
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||||
|
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||||
|
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||||
|
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||||
|
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||||
|
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
|
||||||
|
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||||
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
|
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
||||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||||
|
'tenant.scope' => \App\Http\Middleware\TenantScope::class,
|
||||||
|
'audit.log' => \App\Http\Middleware\AuditLogMiddleware::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
'web' => [
|
||||||
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToclass,
|
||||||
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\SetTenant::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
\App\Http\Middleware\ContentSecurityPolicy::class,
|
||||||
|
],
|
||||||
|
}
|
||||||
187
app/Http/Middleware/AuditLogMiddleware.php
Normal file
187
app/Http/Middleware/AuditLogMiddleware.php
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class AuditLogMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request and log mutating actions to the audit log.
|
||||||
|
*
|
||||||
|
* Automatically logs all POST, PUT, PATCH, DELETE requests with
|
||||||
|
* user_id, tenant_id, model info, action, old/new values, and request metadata.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$method = $request->method();
|
||||||
|
$isMutating = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
|
// For PUT/PATCH/DELETE, capture the current state before the request runs
|
||||||
|
$oldValues = null;
|
||||||
|
$modelType = null;
|
||||||
|
$modelId = null;
|
||||||
|
|
||||||
|
if ($isMutating && in_array($method, ['PUT', 'PATCH', 'DELETE'])) {
|
||||||
|
$resolvedModel = $this->resolveRouteModel($request);
|
||||||
|
|
||||||
|
if ($resolvedModel) {
|
||||||
|
$modelType = get_class($resolvedModel);
|
||||||
|
$modelId = $resolvedModel->getKey();
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
$oldValues = $resolvedModel->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the request
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Only log if the request was successful (2xx status)
|
||||||
|
if ($isMutating && $response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
|
||||||
|
$action = $this->determineAction($method, $request);
|
||||||
|
$newValues = null;
|
||||||
|
|
||||||
|
// For POST/PUT/PATCH, capture new values from the model
|
||||||
|
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
|
||||||
|
$resolvedModel = $this->resolveRouteModel($request);
|
||||||
|
|
||||||
|
if ($resolvedModel) {
|
||||||
|
$modelType = get_class($resolvedModel);
|
||||||
|
$modelId = $resolvedModel->getKey();
|
||||||
|
$newValues = $resolvedModel->toArray();
|
||||||
|
} elseif ($method === 'POST') {
|
||||||
|
// For POST with resource creation, try to get the model from the response redirect
|
||||||
|
$createdModel = $this->extractCreatedModel($request, $response);
|
||||||
|
if ($createdModel) {
|
||||||
|
$modelType = get_class($createdModel);
|
||||||
|
$modelId = $createdModel->getKey();
|
||||||
|
$newValues = $createdModel->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out sensitive fields from values
|
||||||
|
$sensitiveFields = ['password', 'password_hash', 'remember_token', 'national_code', 'bank_account', 'base_salary'];
|
||||||
|
$oldValues = $this->filterSensitiveFields($oldValues, $sensitiveFields);
|
||||||
|
$newValues = $this->filterSensitiveFields($newValues, $sensitiveFields);
|
||||||
|
|
||||||
|
try {
|
||||||
|
AuditLog::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'tenant_id' => session('tenant_id'),
|
||||||
|
'model_type' => $modelType,
|
||||||
|
'model_id' => $modelId,
|
||||||
|
'action' => $action,
|
||||||
|
'old_values' => $oldValues,
|
||||||
|
'new_values' => $newValues,
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
'url' => $request->fullUrl(),
|
||||||
|
'method' => $method,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently fail — audit logging should never break the request
|
||||||
|
logger()->error('Audit log failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Eloquent model from the current route.
|
||||||
|
*/
|
||||||
|
protected function resolveRouteModel(Request $request): ?\Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
$route = $request->route();
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($route->parameters() as $parameter) {
|
||||||
|
if ($parameter instanceof \Illuminate\Database\Eloquent\Model) {
|
||||||
|
return $parameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the action label based on HTTP method.
|
||||||
|
*/
|
||||||
|
protected function determineAction(string $method, Request $request): string
|
||||||
|
{
|
||||||
|
// Check if the route name suggests an approve/reject action
|
||||||
|
$routeName = $request->route()?->getName() ?? '';
|
||||||
|
|
||||||
|
if (str_contains($routeName, 'approve')) {
|
||||||
|
return 'approve';
|
||||||
|
}
|
||||||
|
if (str_contains($routeName, 'reject')) {
|
||||||
|
return 'reject';
|
||||||
|
}
|
||||||
|
if (str_contains($routeName, 'settle')) {
|
||||||
|
return 'settle';
|
||||||
|
}
|
||||||
|
if (str_contains($routeName, 'status')) {
|
||||||
|
return 'status_change';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($method) {
|
||||||
|
'POST' => 'create',
|
||||||
|
'PUT', 'PATCH' => 'update',
|
||||||
|
'DELETE' => 'delete',
|
||||||
|
default => strtolower($method),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract a newly created model from a redirect response.
|
||||||
|
*/
|
||||||
|
protected function extractCreatedModel(Request $request, $response): ?\Illuminate\Database\Eloquent\Model
|
||||||
|
{
|
||||||
|
// If the controller set a 'created_model' on the request, use it
|
||||||
|
if ($request->attributes->has('_audit_created_model')) {
|
||||||
|
return $request->attributes->get('_audit_created_model');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the response redirects to a show route, try to extract model from redirect target
|
||||||
|
if ($response instanceof \Illuminate\Http\RedirectResponse) {
|
||||||
|
$targetUrl = $response->getTargetUrl();
|
||||||
|
$route = app('router')->getRoutes()->match(
|
||||||
|
\Illuminate\Http\Request::create($targetUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($route->parameters() as $parameter) {
|
||||||
|
if ($parameter instanceof \Illuminate\Database\Eloquent\Model) {
|
||||||
|
return $parameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out sensitive fields from an array of values.
|
||||||
|
*/
|
||||||
|
protected function filterSensitiveFields(?array $values, array $sensitiveFields): ?array
|
||||||
|
{
|
||||||
|
if ($values === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sensitiveFields as $field) {
|
||||||
|
unset($values[$field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Http/Middleware/Authenticate.php
Normal file
17
app/Http/Middleware/Authenticate.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class Authenticate extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the path the user should be redirected to when they are not authenticated.
|
||||||
|
*/
|
||||||
|
protected function redirectTo(Request $request): ?string
|
||||||
|
{
|
||||||
|
return $request->expectsJson() ? null : route('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Middleware/ContentSecurityPolicy.php
Normal file
22
app/Http/Middleware/ContentSecurityPolicy.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ContentSecurityPolicy
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
$response->headers->set(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://cdn.dhtmlx.com; style-src 'self' 'unsafe-inline' https://cdn.dhtmlx.com https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:;",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Http/Middleware/EncryptCookies.php
Normal file
17
app/Http/Middleware/EncryptCookies.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
|
||||||
|
|
||||||
|
class EncryptCookies extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The names of the cookies that should not be encrypted.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
26
app/Http/Middleware/EnsureEmailIsVerified.php
Normal file
26
app/Http/Middleware/EnsureEmailIsVerified.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureEmailIsVerified
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! $request->user() ||
|
||||||
|
($request->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail &&
|
||||||
|
! $request->user()->hasVerifiedEmail())) {
|
||||||
|
return $request->expectsJson()
|
||||||
|
? abort(403, 'Your email address is not verified.')
|
||||||
|
: redirect()->route('verification.notice');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
27
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Providers\RouteServiceProvider;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RedirectIfAuthenticated
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||||
|
{
|
||||||
|
$guards = empty($guards) ? [null] : $guards;
|
||||||
|
|
||||||
|
foreach ($guards as $guard) {
|
||||||
|
if (auth()->guard($guard)->check()) {
|
||||||
|
return redirect(RouteServiceProvider::HOME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Http/Middleware/SetLocale.php
Normal file
77
app/Http/Middleware/SetLocale.php
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
* Set the application locale based on priority:
|
||||||
|
* user preference > tenant preference > session > browser > config default.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$locale = null;
|
||||||
|
$calendar = null;
|
||||||
|
|
||||||
|
// 1. Session preference (highest priority - user just changed it)
|
||||||
|
$locale = session('locale');
|
||||||
|
$calendar = session('calendar');
|
||||||
|
|
||||||
|
// 2. Authenticated user preference (database stored preference)
|
||||||
|
if (!$locale && $request->user()) {
|
||||||
|
$locale = $request->user()->getLocale();
|
||||||
|
$calendar = $request->user()->getCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Tenant preference
|
||||||
|
if (!$locale && session('tenant_id')) {
|
||||||
|
$tenant = \App\Models\Tenant::find(session('tenant_id'));
|
||||||
|
if ($tenant) {
|
||||||
|
$locale = $locale ?? $tenant->default_locale;
|
||||||
|
$calendar = $calendar ?? $tenant->default_calendar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Browser preference
|
||||||
|
if (!$locale) {
|
||||||
|
$browserLang = substr($request->getPreferredLanguage(), 0, 2);
|
||||||
|
$supportedLocales = config('projectra.supported_locales', ['fa', 'en']);
|
||||||
|
if (in_array($browserLang, $supportedLocales)) {
|
||||||
|
$locale = $browserLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Config default (lowest priority)
|
||||||
|
if (!$locale) {
|
||||||
|
$locale = config('projectra.default_locale', 'fa');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$calendar) {
|
||||||
|
$calendar = config('projectra.default_calendar', 'jalali');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the application locale
|
||||||
|
App::setLocale($locale);
|
||||||
|
|
||||||
|
// Store in session for subsequent requests
|
||||||
|
session(['locale' => $locale, 'calendar' => $calendar]);
|
||||||
|
|
||||||
|
// Share locale, direction, and calendar with all views
|
||||||
|
$rtlLocales = config('projectra.rtl_locales', ['fa', 'ar', 'he']);
|
||||||
|
$direction = in_array($locale, $rtlLocales) ? 'rtl' : 'ltr';
|
||||||
|
|
||||||
|
view()->share([
|
||||||
|
'currentLocale' => $locale,
|
||||||
|
'currentDirection' => $direction,
|
||||||
|
'currentCalendar' => $calendar,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/Http/Middleware/SetTenant.php
Normal file
117
app/Http/Middleware/SetTenant.php
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetTenant
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
* Resolve the tenant from the subdomain, store in session, and share with views.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$host = $request->getHost();
|
||||||
|
$parts = explode('.', $host);
|
||||||
|
|
||||||
|
// Skip for the main domain (e.g., Vernova.test or www.Vernova.test)
|
||||||
|
$mainDomain = config('Vernova.main_domain', env('APP_DOMAIN'));
|
||||||
|
|
||||||
|
if ($mainDomain && ($host === $mainDomain || $host === 'www.' . $mainDomain)) {
|
||||||
|
// Even on main domain, try to set tenant from authenticated user
|
||||||
|
$this->setTenantFromUser();
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve tenant from subdomain
|
||||||
|
if (count($parts) > 2 || (count($parts) === 2 && $parts[1] === $mainDomain)) {
|
||||||
|
$subdomain = $parts[0];
|
||||||
|
|
||||||
|
// Skip 'www' subdomain
|
||||||
|
if ($subdomain === 'www') {
|
||||||
|
$this->setTenantFromUser();
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::where('slug', $subdomain)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$tenant) {
|
||||||
|
abort(404, 'Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
session(['tenant_id' => $tenant->id]);
|
||||||
|
session(['tenant' => $tenant]);
|
||||||
|
|
||||||
|
// Share tenant with all views
|
||||||
|
view()->share('currentTenant', $tenant);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get tenant from session if already set
|
||||||
|
$tenantId = session('tenant_id');
|
||||||
|
|
||||||
|
if ($tenantId) {
|
||||||
|
$tenant = Tenant::find($tenantId);
|
||||||
|
|
||||||
|
if ($tenant && $tenant->is_active) {
|
||||||
|
view()->share('currentTenant', $tenant);
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve tenant from domain directly
|
||||||
|
$tenant = Tenant::where('domain', $host)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
session(['tenant_id' => $tenant->id]);
|
||||||
|
session(['tenant' => $tenant]);
|
||||||
|
view()->share('currentTenant', $tenant);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to set tenant from authenticated user
|
||||||
|
$this->setTenantFromUser();
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set tenant from the currently authenticated user.
|
||||||
|
* This is a fallback for localhost/XAMPP environments where
|
||||||
|
* subdomain-based tenant resolution doesn't work.
|
||||||
|
*/
|
||||||
|
protected function setTenantFromUser(): void
|
||||||
|
{
|
||||||
|
// Only if tenant_id is not already in session
|
||||||
|
if (session('tenant_id')) {
|
||||||
|
$tenant = Tenant::find(session('tenant_id'));
|
||||||
|
if ($tenant && $tenant->is_active) {
|
||||||
|
view()->share('currentTenant', $tenant);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user && $user->tenant_id) {
|
||||||
|
$tenant = Tenant::find($user->tenant_id);
|
||||||
|
|
||||||
|
if ($tenant && $tenant->is_active) {
|
||||||
|
session(['tenant_id' => $tenant->id]);
|
||||||
|
session(['tenant' => $tenant]);
|
||||||
|
view()->share('currentTenant', $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Http/Middleware/TenantScope.php
Normal file
61
app/Http/Middleware/TenantScope.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class TenantScope
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
* Set the global tenant_id so that queries are scoped to the current tenant.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$tenantId = session('tenant_id');
|
||||||
|
|
||||||
|
if ($tenantId) {
|
||||||
|
// Set the global tenant context for scoped queries
|
||||||
|
app()->instance('tenant_id', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a callback without the tenant scope.
|
||||||
|
* Useful for super-admin operations that span all tenants.
|
||||||
|
*/
|
||||||
|
public static function withoutTenantScope(callable $callback): mixed
|
||||||
|
{
|
||||||
|
$previousTenantId = app('tenant_id');
|
||||||
|
|
||||||
|
// Clear the tenant context
|
||||||
|
app()->instance('tenant_id', null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $callback();
|
||||||
|
} finally {
|
||||||
|
// Restore the previous tenant context
|
||||||
|
app()->instance('tenant_id', $previousTenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current tenant ID.
|
||||||
|
*/
|
||||||
|
public static function getCurrentTenantId(): ?int
|
||||||
|
{
|
||||||
|
return app('tenant_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tenant scope is currently active.
|
||||||
|
*/
|
||||||
|
public static function hasTenantScope(): bool
|
||||||
|
{
|
||||||
|
return app('tenant_id') !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Http/Middleware/ValidateSignature.php
Normal file
17
app/Http/Middleware/ValidateSignature.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateSignature as Middleware;
|
||||||
|
|
||||||
|
class ValidateSignature extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The names of the query string parameters that should be ignored.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
17
app/Http/Middleware/VerifyCsrfToken.php
Normal file
17
app/Http/Middleware/VerifyCsrfToken.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||||
|
|
||||||
|
class VerifyCsrfToken extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The URIs that should be excluded from CSRF verification.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
58
app/Http/Requests/StoreEmployeeRequest.php
Normal file
58
app/Http/Requests/StoreEmployeeRequest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEmployeeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name' => 'required|string|max:100',
|
||||||
|
'last_name' => 'required|string|max:100',
|
||||||
|
'national_code' => 'nullable|string|max:20',
|
||||||
|
'phone' => 'nullable|string|max:20',
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
'contract_type' => 'required|in:full_time,part_time,contractor,intern,consultant',
|
||||||
|
'base_salary' => 'nullable|numeric|min:0',
|
||||||
|
'currency_id' => 'nullable|exists:currencies,id',
|
||||||
|
'bank_account' => 'nullable|string|max:50',
|
||||||
|
'hire_date' => 'required|date',
|
||||||
|
'termination_date' => 'nullable|date|after_or_equal:hire_date',
|
||||||
|
'position' => 'nullable|string|max:100',
|
||||||
|
'department' => 'nullable|string|max:100',
|
||||||
|
'project_id' => 'nullable|exists:projects,id',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'status' => 'nullable|in:active,inactive,terminated,on_leave',
|
||||||
|
'job_title' => 'nullable|string|max:100',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name.required' => __('employee.validation_first_name_required'),
|
||||||
|
'last_name.required' => __('employee.validation_last_name_required'),
|
||||||
|
'contract_type.required' => __('employee.validation_contract_type_required'),
|
||||||
|
'contract_type.in' => __('employee.validation_contract_type_invalid'),
|
||||||
|
'hire_date.required' => __('employee.validation_hire_date_required'),
|
||||||
|
'base_salary.numeric' => __('employee.validation_base_salary_numeric'),
|
||||||
|
'termination_date.after_or_equal' => __('employee.validation_termination_date_after'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Requests/StoreExpenseRequest.php
Normal file
54
app/Http/Requests/StoreExpenseRequest.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreExpenseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => 'required|exists:projects,id',
|
||||||
|
'petty_cash_id' => 'nullable|exists:petty_cashes,id',
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'currency_id' => 'required|exists:currencies,id',
|
||||||
|
'status' => 'nullable|in:pending,approved,rejected,paid',
|
||||||
|
'category' => 'required|in:materials,labor,equipment,transport,services,food,accommodation,other',
|
||||||
|
'expense_date' => 'required|date',
|
||||||
|
'invoice_number' => 'nullable|string|max:100',
|
||||||
|
'receipt' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id.required' => __('expense.validation_project_required'),
|
||||||
|
'amount.required' => __('expense.validation_amount_required'),
|
||||||
|
'amount.numeric' => __('expense.validation_amount_numeric'),
|
||||||
|
'currency_id.required' => __('expense.validation_currency_required'),
|
||||||
|
'currency_id.exists' => __('expense.validation_currency_exists'),
|
||||||
|
'expense_date.required' => __('expense.validation_date_required'),
|
||||||
|
'expense_date.date' => __('expense.validation_date_invalid'),
|
||||||
|
'category.required' => __('expense.validation_category_required'),
|
||||||
|
'category.in' => __('expense.validation_category_invalid'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Requests/StoreProjectRequest.php
Normal file
51
app/Http/Requests/StoreProjectRequest.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreProjectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'code' => 'required|string|max:50|unique:projects,code,' . $this->project?->id,
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'status' => 'nullable|in:planning,active,on_hold,completed,cancelled',
|
||||||
|
'budget' => 'nullable|numeric|min:0',
|
||||||
|
'default_currency_id' => 'nullable|exists:currencies,id',
|
||||||
|
'manager_id' => 'nullable|exists:users,id',
|
||||||
|
'start_date' => 'nullable|date',
|
||||||
|
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name.required' => __('project.validation_name_required'),
|
||||||
|
'code.required' => __('project.validation_code_required'),
|
||||||
|
'code.unique' => __('project.validation_code_unique'),
|
||||||
|
'status.in' => __('project.validation_status_invalid'),
|
||||||
|
'budget.numeric' => __('project.validation_budget_numeric'),
|
||||||
|
'end_date.after_or_equal' => __('project.validation_end_date_after'),
|
||||||
|
'manager_id.exists' => __('project.validation_manager_exists'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Http/Requests/StoreTaskRequest.php
Normal file
52
app/Http/Requests/StoreTaskRequest.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreTaskRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'project_id' => 'required|exists:projects,id',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'status' => 'nullable|in:pending,in_progress,review,done,cancelled',
|
||||||
|
'priority' => 'nullable|in:low,medium,high,urgent',
|
||||||
|
'assignee_id' => 'nullable|exists:users,id',
|
||||||
|
'parent_id' => 'nullable|exists:tasks,id',
|
||||||
|
'start_date' => 'nullable|date',
|
||||||
|
'due_date' => 'nullable|date',
|
||||||
|
'estimated_hours' => 'nullable|integer|min:0',
|
||||||
|
'actual_hours' => 'nullable|integer|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title.required' => __('task.validation_title_required'),
|
||||||
|
'project_id.required' => __('task.validation_project_required'),
|
||||||
|
'project_id.exists' => __('task.validation_project_exists'),
|
||||||
|
'status.in' => __('task.validation_status_invalid'),
|
||||||
|
'priority.in' => __('task.validation_priority_invalid'),
|
||||||
|
'due_date.date' => __('task.validation_due_date_invalid'),
|
||||||
|
'assignee_id.exists' => __('task.validation_assignee_exists'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/Models/Attachment.php
Normal file
119
app/Models/Attachment.php
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class Attachment extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'user_id',
|
||||||
|
'attachable_type',
|
||||||
|
'attachable_id',
|
||||||
|
'file_name',
|
||||||
|
'file_path',
|
||||||
|
'mime_type',
|
||||||
|
'file_size',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'file_size' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent attachable model.
|
||||||
|
*/
|
||||||
|
public function attachable()
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user who uploaded the attachment.
|
||||||
|
*/
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tenant.
|
||||||
|
*/
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public URL for the file.
|
||||||
|
*/
|
||||||
|
public function getUrlAttribute(): string
|
||||||
|
{
|
||||||
|
return Storage::url($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable file size.
|
||||||
|
*/
|
||||||
|
public function getFormattedSizeAttribute(): string
|
||||||
|
{
|
||||||
|
$bytes = $this->file_size;
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return number_format($bytes / 1048576, 1) . ' MB';
|
||||||
|
}
|
||||||
|
if ($bytes >= 1024) {
|
||||||
|
return number_format($bytes / 1024, 1) . ' KB';
|
||||||
|
}
|
||||||
|
return $bytes . ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the file is an image.
|
||||||
|
*/
|
||||||
|
public function getIsImageAttribute(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($this->mime_type ?? '', 'image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the file is a PDF.
|
||||||
|
*/
|
||||||
|
public function getIsPdfAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->mime_type === 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file icon based on mime type.
|
||||||
|
*/
|
||||||
|
public function getIconAttribute(): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$this->is_image => 'image',
|
||||||
|
$this->is_pdf => 'pdf',
|
||||||
|
str_contains($this->mime_type ?? '', 'word') || str_contains($this->mime_type ?? '', 'document') => 'doc',
|
||||||
|
str_contains($this->mime_type ?? '', 'sheet') || str_contains($this->mime_type ?? '', 'excel') => 'xls',
|
||||||
|
default => 'file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the file from storage when model is deleted.
|
||||||
|
*/
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::deleting(function (Attachment $attachment) {
|
||||||
|
if ($attachment->file_path && Storage::disk('public')->exists($attachment->file_path)) {
|
||||||
|
Storage::disk('public')->delete($attachment->file_path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/AuditLog.php
Normal file
49
app/Models/AuditLog.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AuditLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'tenant_id',
|
||||||
|
'model_type',
|
||||||
|
'model_id',
|
||||||
|
'action',
|
||||||
|
'old_values',
|
||||||
|
'new_values',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'url',
|
||||||
|
'method',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'old_values' => 'array',
|
||||||
|
'new_values' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the auditable model (polymorphic-like relationship using model_type/model_id).
|
||||||
|
*/
|
||||||
|
public function subject()
|
||||||
|
{
|
||||||
|
if ($this->model_type && class_exists($this->model_type)) {
|
||||||
|
return $this->morphTo(null, 'model_type', 'model_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/Models/Concerns/HasAttachments.php
Normal file
135
app/Models/Concerns/HasAttachments.php
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
trait HasAttachments
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all attachments for this model.
|
||||||
|
*/
|
||||||
|
public function attachments()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Attachment::class, 'attachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get receipts only.
|
||||||
|
*/
|
||||||
|
public function receipts()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Attachment::class, 'attachable')
|
||||||
|
->where('category', 'receipt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get documents only.
|
||||||
|
*/
|
||||||
|
public function documents()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Attachment::class, 'attachable')
|
||||||
|
->where('category', 'document');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload and attach a file.
|
||||||
|
*/
|
||||||
|
public function attachFile(
|
||||||
|
UploadedFile $file,
|
||||||
|
string $category = 'receipt',
|
||||||
|
?string $description = null,
|
||||||
|
?string $folder = null
|
||||||
|
): Attachment {
|
||||||
|
$folder = $folder ?? $this->getAttachmentFolder();
|
||||||
|
$filePath = $file->store($folder, 'public');
|
||||||
|
|
||||||
|
return $this->attachments()->create([
|
||||||
|
'tenant_id' => session('tenant_id') ?? $this->tenant_id,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'file_name' => $file->getClientOriginalName(),
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'mime_type' => $file->getMimeType(),
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'category' => $category,
|
||||||
|
'description' => $description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload multiple files at once.
|
||||||
|
*/
|
||||||
|
public function attachFiles(array $files, string $category = 'receipt', ?string $folder = null): array
|
||||||
|
{
|
||||||
|
$attachments = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file instanceof UploadedFile && $file->isValid()) {
|
||||||
|
$attachments[] = $this->attachFile($file, $category, null, $folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach (soft delete) an attachment by ID.
|
||||||
|
*/
|
||||||
|
public function detachFile(int $attachmentId): bool
|
||||||
|
{
|
||||||
|
$attachment = $this->attachments()->find($attachmentId);
|
||||||
|
|
||||||
|
if ($attachment) {
|
||||||
|
$attachment->delete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy receipt_path to the attachment system.
|
||||||
|
*/
|
||||||
|
public function migrateLegacyReceipt(): ?Attachment
|
||||||
|
{
|
||||||
|
$receiptPath = $this->getOriginal('receipt_path') ?? $this->receipt_path ?? null;
|
||||||
|
|
||||||
|
if (!$receiptPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already migrated
|
||||||
|
$exists = $this->attachments()
|
||||||
|
->where('file_path', $receiptPath)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Storage::disk('public')->exists($receiptPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->attachments()->create([
|
||||||
|
'tenant_id' => $this->tenant_id,
|
||||||
|
'user_id' => null,
|
||||||
|
'file_name' => basename($receiptPath),
|
||||||
|
'file_path' => $receiptPath,
|
||||||
|
'mime_type' => Storage::disk('public')->mimeType($receiptPath),
|
||||||
|
'file_size' => Storage::disk('public')->size($receiptPath),
|
||||||
|
'category' => 'receipt',
|
||||||
|
'description' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default storage folder for this model's attachments.
|
||||||
|
*/
|
||||||
|
protected function getAttachmentFolder(): string
|
||||||
|
{
|
||||||
|
$className = strtolower(class_basename($this));
|
||||||
|
return "attachments/{$className}";
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/Currency.php
Normal file
30
app/Models/Currency.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Currency extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'symbol',
|
||||||
|
'decimal_places',
|
||||||
|
'exchange_rate_to_usd',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'decimal_places' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenants()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Tenant::class, 'base_currency_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Models/Document.php
Normal file
82
app/Models/Document.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Document extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'file_path',
|
||||||
|
'file_type',
|
||||||
|
'uploaded_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploader()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'uploaded_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSearch($query, $term)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) use ($term) {
|
||||||
|
$q->where('title', 'like', "%{$term}%")
|
||||||
|
->orWhere('description', 'like', "%{$term}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFileSizeAttribute(): ?int
|
||||||
|
{
|
||||||
|
$path = storage_path('app/public/' . $this->file_path);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return filesize($path);
|
||||||
|
}
|
||||||
|
// Try the local disk
|
||||||
|
$path = storage_path('app/' . $this->file_path);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return filesize($path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMimeTypeAttribute(): ?string
|
||||||
|
{
|
||||||
|
$path = storage_path('app/public/' . $this->file_path);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return mime_content_type($path);
|
||||||
|
}
|
||||||
|
$path = storage_path('app/' . $this->file_path);
|
||||||
|
if (file_exists($path)) {
|
||||||
|
return mime_content_type($path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFileIconAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->file_type) {
|
||||||
|
'pdf' => 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||||
|
'image' => 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||||
|
'word', 'excel', 'text' => 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||||
|
default => 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Models/Employee.php
Normal file
92
app/Models/Employee.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Employee extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'national_code',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'phone',
|
||||||
|
'job_title',
|
||||||
|
'contract_type',
|
||||||
|
'hire_date',
|
||||||
|
'termination_date',
|
||||||
|
'status',
|
||||||
|
'base_salary',
|
||||||
|
'currency_id',
|
||||||
|
'bank_account',
|
||||||
|
'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'hire_date' => 'date',
|
||||||
|
'termination_date' => 'date',
|
||||||
|
'national_code' => 'encrypted',
|
||||||
|
'bank_account' => 'encrypted',
|
||||||
|
'base_salary' => 'decimal:0', // ✅ جایگزین
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workLogs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function financials()
|
||||||
|
{
|
||||||
|
return $this->hasMany(EmployeeFinancial::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expenses()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Expense::class, 'employee_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFullNameAttribute()
|
||||||
|
{
|
||||||
|
return $this->first_name . ' ' . $this->last_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContractTypeLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'full_time' => __('employee.contract_full_time'),
|
||||||
|
'part_time' => __('employee.contract_part_time'),
|
||||||
|
'contractor' => __('employee.contract_contractor'),
|
||||||
|
'daily' => __('employee.contract_daily'),
|
||||||
|
][$this->contract_type] ?? $this->contract_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'active' => __('employee.active'),
|
||||||
|
'inactive' => __('employee.inactive'),
|
||||||
|
'terminated' => __('employee.terminated'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Models/EmployeeFinancial.php
Normal file
57
app/Models/EmployeeFinancial.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class EmployeeFinancial extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'employee_id',
|
||||||
|
'currency_id',
|
||||||
|
'type',
|
||||||
|
'amount',
|
||||||
|
'original_amount',
|
||||||
|
'description',
|
||||||
|
'effective_date',
|
||||||
|
'month',
|
||||||
|
'year',
|
||||||
|
'is_paid',
|
||||||
|
'paid_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'original_amount' => 'decimal:2',
|
||||||
|
'effective_date' => 'date',
|
||||||
|
'is_paid' => 'boolean',
|
||||||
|
'paid_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employee()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by type
|
||||||
|
*/
|
||||||
|
public function scopeByType($query, $type)
|
||||||
|
{
|
||||||
|
return $query->where('type', $type);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Models/EmployeeSettlement.php
Normal file
70
app/Models/EmployeeSettlement.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class EmployeeSettlement extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'employee_id',
|
||||||
|
'settlement_type',
|
||||||
|
'settlement_date',
|
||||||
|
'last_working_date',
|
||||||
|
'total_payable',
|
||||||
|
'total_deductions',
|
||||||
|
'net_payable',
|
||||||
|
'currency_id',
|
||||||
|
'notes',
|
||||||
|
'processed_by',
|
||||||
|
'processed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'settlement_date' => 'date',
|
||||||
|
'last_working_date' => 'date',
|
||||||
|
'processed_at' => 'datetime',
|
||||||
|
'total_payable' => 'decimal:0',
|
||||||
|
'total_deductions' => 'decimal:0',
|
||||||
|
'net_payable' => 'decimal:0',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employee()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'processed_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function financials()
|
||||||
|
{
|
||||||
|
return $this->hasMany(EmployeeFinancial::class, 'settlement_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSettlementTypeLabelAttribute()
|
||||||
|
{
|
||||||
|
return match($this->settlement_type) {
|
||||||
|
'resignation' => 'استعفا',
|
||||||
|
'termination' => 'اخراج',
|
||||||
|
'end_of_contract' => 'پایان قرارداد',
|
||||||
|
default => $this->settlement_type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/Models/Expense.php
Normal file
116
app/Models/Expense.php
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use App\Models\Concerns\HasAttachments;
|
||||||
|
|
||||||
|
class Expense extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes, HasAttachments;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'petty_cash_id',
|
||||||
|
'employee_id',
|
||||||
|
'currency_id',
|
||||||
|
'title',
|
||||||
|
'amount',
|
||||||
|
'amount_usd',
|
||||||
|
'original_amount',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'expense_date',
|
||||||
|
'invoice_number',
|
||||||
|
'receipt_path',
|
||||||
|
'requested_by',
|
||||||
|
'approved_by',
|
||||||
|
'approval_date',
|
||||||
|
'approved_at',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'amount_usd' => 'decimal:2',
|
||||||
|
'original_amount' => 'decimal:2',
|
||||||
|
'expense_date' => 'date',
|
||||||
|
'approval_date' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pettyCash()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PettyCash::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employee()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'requested_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approvedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for approvedBy (backward compatibility).
|
||||||
|
*/
|
||||||
|
public function approver()
|
||||||
|
{
|
||||||
|
return $this->approvedBy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by category.
|
||||||
|
*/
|
||||||
|
public function scopeByCategory($query, $category)
|
||||||
|
{
|
||||||
|
return $query->where('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by status.
|
||||||
|
*/
|
||||||
|
public function scopeByStatus($query, $status)
|
||||||
|
{
|
||||||
|
return $query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status label for display.
|
||||||
|
*/
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('expense.pending'),
|
||||||
|
'approved' => __('expense.approved'),
|
||||||
|
'rejected' => __('expense.rejected'),
|
||||||
|
'paid' => __('expense.paid'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Models/InventoryTransaction.php
Normal file
75
app/Models/InventoryTransaction.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class InventoryTransaction extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'item_id',
|
||||||
|
'warehouse_id',
|
||||||
|
'project_id',
|
||||||
|
'currency_id',
|
||||||
|
'type',
|
||||||
|
'quantity',
|
||||||
|
'unit_price',
|
||||||
|
'total_price',
|
||||||
|
'total_price_usd',
|
||||||
|
'reference',
|
||||||
|
'reference_number',
|
||||||
|
'description',
|
||||||
|
'notes',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'quantity' => 'decimal:2',
|
||||||
|
'unit_price' => 'decimal:2',
|
||||||
|
'total_price' => 'decimal:2',
|
||||||
|
'total_price_usd' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function item()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Item::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warehouse()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Warehouse::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createdBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by type
|
||||||
|
*/
|
||||||
|
public function scopeByType($query, $type)
|
||||||
|
{
|
||||||
|
return $query->where('type', $type);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Models/Item.php
Normal file
38
app/Models/Item.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Item extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'unit',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'min_stock',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'min_stock' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inventoryTransactions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventoryTransaction::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Models/Letter.php
Normal file
66
app/Models/Letter.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Letter extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'parent_id',
|
||||||
|
'type',
|
||||||
|
'subject',
|
||||||
|
'content',
|
||||||
|
'sender',
|
||||||
|
'recipient',
|
||||||
|
'date',
|
||||||
|
'reference_number',
|
||||||
|
'status',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parentLetter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Letter::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replies()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Letter::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments()
|
||||||
|
{
|
||||||
|
return $this->hasMany(LetterAttachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createdBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Models/LetterAttachment.php
Normal file
83
app/Models/LetterAttachment.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class LetterAttachment extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'letter_id',
|
||||||
|
'file_name',
|
||||||
|
'file_path',
|
||||||
|
'file_size',
|
||||||
|
'mime_type',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'file_size' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function letter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Letter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL for the attachment.
|
||||||
|
*/
|
||||||
|
public function getUrlAttribute(): string
|
||||||
|
{
|
||||||
|
return Storage::disk('public')->url($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the attachment is an image.
|
||||||
|
*/
|
||||||
|
public function getIsImageAttribute(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($this->mime_type ?? '', 'image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the attachment is a PDF.
|
||||||
|
*/
|
||||||
|
public function getIsPdfAttribute(): bool
|
||||||
|
{
|
||||||
|
return ($this->mime_type ?? '') === 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted file size.
|
||||||
|
*/
|
||||||
|
public function getFormattedSizeAttribute(): string
|
||||||
|
{
|
||||||
|
$bytes = $this->file_size ?? 0;
|
||||||
|
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return round($bytes / 1048576, 1) . ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes >= 1024) {
|
||||||
|
return round($bytes / 1024, 1) . ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes . ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted()
|
||||||
|
{
|
||||||
|
static::creating(function ($attachment) {
|
||||||
|
if (empty($attachment->created_at)) {
|
||||||
|
$attachment->created_at = now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Models/PayrollPeriod.php
Normal file
58
app/Models/PayrollPeriod.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PayrollPeriod extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'month',
|
||||||
|
'year',
|
||||||
|
'status',
|
||||||
|
'total_gross',
|
||||||
|
'total_deductions',
|
||||||
|
'total_net',
|
||||||
|
'confirmed_by',
|
||||||
|
'confirmed_at',
|
||||||
|
'notes',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'confirmed_at' => 'datetime',
|
||||||
|
'total_gross' => 'decimal:0',
|
||||||
|
'total_deductions' => 'decimal:0',
|
||||||
|
'total_net' => 'decimal:0',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'confirmed_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createdBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPeriodNameAttribute()
|
||||||
|
{
|
||||||
|
$persianMonths = [
|
||||||
|
1 => 'فروردین', 2 => 'اردیبهشت', 3 => 'خرداد',
|
||||||
|
4 => 'تیر', 5 => 'مرداد', 6 => 'شهریور',
|
||||||
|
7 => 'مهر', 8 => 'آبان', 9 => 'آذر',
|
||||||
|
10 => 'دی', 11 => 'بهمن', 12 => 'اسفند'
|
||||||
|
];
|
||||||
|
return ($persianMonths[$this->month] ?? $this->month) . ' ' . $this->year;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Models/Permission.php
Normal file
15
app/Models/Permission.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Spatie\Permission\Models\Permission as SpatiePermission;
|
||||||
|
|
||||||
|
class Permission extends SpatiePermission
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Custom Permission model for Vernova.
|
||||||
|
*
|
||||||
|
* Permissions are global (not tenant-scoped), but we extend
|
||||||
|
* the base model to allow future customization.
|
||||||
|
*/
|
||||||
|
}
|
||||||
127
app/Models/PettyCash.php
Normal file
127
app/Models/PettyCash.php
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use App\Models\Concerns\HasAttachments;
|
||||||
|
|
||||||
|
class PettyCash extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes, HasAttachments;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'currency_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'amount',
|
||||||
|
'original_amount',
|
||||||
|
'amount_usd',
|
||||||
|
'remaining',
|
||||||
|
'request_date',
|
||||||
|
'receipt_path',
|
||||||
|
'requested_by',
|
||||||
|
'approved_by',
|
||||||
|
'approval_date',
|
||||||
|
'approved_at',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'original_amount' => 'decimal:2',
|
||||||
|
'amount_usd' => 'decimal:2',
|
||||||
|
'remaining' => 'decimal:2',
|
||||||
|
'request_date' => 'date',
|
||||||
|
'approval_date' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'requested_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approvedBy()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expenses()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Expense::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByStatus($query, $status)
|
||||||
|
{
|
||||||
|
return $query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('pettyCash.pending'),
|
||||||
|
'approved' => __('pettyCash.approved'),
|
||||||
|
'rejected' => __('pettyCash.rejected'),
|
||||||
|
'settled' => __('pettyCash.settled'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate remaining amount based on approved expenses.
|
||||||
|
*/
|
||||||
|
public function calculateRemaining(): float
|
||||||
|
{
|
||||||
|
$totalExpenses = $this->expenses()
|
||||||
|
->whereIn('status', ['approved', 'paid'])
|
||||||
|
->sum('amount');
|
||||||
|
|
||||||
|
return (float) $this->amount - (float) $totalExpenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the remaining amount.
|
||||||
|
*/
|
||||||
|
public function updateRemaining(): void
|
||||||
|
{
|
||||||
|
$this->remaining = $this->calculateRemaining();
|
||||||
|
$this->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total approved expenses.
|
||||||
|
*/
|
||||||
|
public function getTotalExpensesAttribute(): float
|
||||||
|
{
|
||||||
|
return (float) $this->expenses()
|
||||||
|
->whereIn('status', ['approved', 'paid'])
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get utilization percentage.
|
||||||
|
*/
|
||||||
|
public function getUtilizationAttribute(): float
|
||||||
|
{
|
||||||
|
if ($this->amount <= 0) return 0;
|
||||||
|
return min(100, round(($this->total_expenses / (float) $this->amount) * 100, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Models/Project.php
Normal file
103
app/Models/Project.php
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Project extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'progress',
|
||||||
|
'budget',
|
||||||
|
'default_currency_id',
|
||||||
|
'budget_usd',
|
||||||
|
'manager_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'start_date' => 'date',
|
||||||
|
'end_date' => 'date',
|
||||||
|
'progress' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function manager()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'manager_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class, 'default_currency_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function users()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'project_users')
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tasks()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Task::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expenses()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Expense::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pettyCashes()
|
||||||
|
{
|
||||||
|
return $this->hasMany(PettyCash::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workLogs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeTenantScope($query)
|
||||||
|
{
|
||||||
|
return $query->where('tenant_id', session('tenant_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'planning' => __('project.status_planning'),
|
||||||
|
'active' => __('project.status_active'),
|
||||||
|
'on_hold' => __('project.status_on_hold'),
|
||||||
|
'completed' => __('project.status_completed'),
|
||||||
|
'cancelled' => __('project.status_cancelled'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultCurrency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class, 'default_currency_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employees()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Employee::class, 'employee_project', 'project_id', 'employee_id')
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Models/ProjectUser.php
Normal file
27
app/Models/ProjectUser.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProjectUser extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'project_id',
|
||||||
|
'user_id',
|
||||||
|
'role',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Models/Role.php
Normal file
28
app/Models/Role.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Spatie\Permission\Models\Role as SpatieRole;
|
||||||
|
|
||||||
|
class Role extends SpatieRole
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Custom Role model for Vernova that supports multi-tenant scoping.
|
||||||
|
*
|
||||||
|
* Adds tenant_id to the fillable array so that roles can be scoped
|
||||||
|
* to a specific tenant (or be platform-level when tenant_id is null).
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'guard_name',
|
||||||
|
'tenant_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tenant this role belongs to (null = platform-level role).
|
||||||
|
*/
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/Models/Task.php
Normal file
180
app/Models/Task.php
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Task extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'parent_id',
|
||||||
|
'assignee_id',
|
||||||
|
'reporter_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'start_date',
|
||||||
|
'due_date',
|
||||||
|
'progress',
|
||||||
|
'estimated_hours',
|
||||||
|
'completed_at',
|
||||||
|
'order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'start_date' => 'date',
|
||||||
|
'due_date' => 'date',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'progress' => 'integer',
|
||||||
|
'estimated_hours' => 'decimal:2',
|
||||||
|
'order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Basic Relationships ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parent()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Task::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function children()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Task::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignee()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assignee_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reporter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reporter_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(TaskLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workLogs()
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dependency Relationships ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tasks that this task depends on (predecessors).
|
||||||
|
* e.g., "Task A depends on Task B" means B must finish before A starts.
|
||||||
|
*/
|
||||||
|
public function dependencies()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Task::class, 'task_dependencies', 'task_id', 'depends_on_task_id')
|
||||||
|
->withPivot('dependency_type')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tasks that depend on this task (successors).
|
||||||
|
* e.g., "Task A blocks Task C" means C cannot start until A finishes.
|
||||||
|
*/
|
||||||
|
public function dependents()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Task::class, 'task_dependencies', 'depends_on_task_id', 'task_id')
|
||||||
|
->withPivot('dependency_type')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scopes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function scopeByStatus($query, $status)
|
||||||
|
{
|
||||||
|
return $query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByPriority($query, $priority)
|
||||||
|
{
|
||||||
|
return $query->where('priority', $priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeTopLevel($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithDependencies($query)
|
||||||
|
{
|
||||||
|
return $query->with(['dependencies', 'dependents', 'assignee', 'project']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Accessors ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('task.status_pending'),
|
||||||
|
'todo' => __('task.status_pending'),
|
||||||
|
'in_progress' => __('task.status_in_progress'),
|
||||||
|
'review' => __('task.status_review'),
|
||||||
|
'done' => __('task.status_done'),
|
||||||
|
'cancelled' => __('task.status_cancelled'),
|
||||||
|
][$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all dependency tasks are completed.
|
||||||
|
*/
|
||||||
|
public function getCanStartAttribute(): bool
|
||||||
|
{
|
||||||
|
if ($this->dependencies->isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->dependencies->every(fn ($dep) => $dep->status === 'done');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this task is blocked by incomplete dependencies.
|
||||||
|
*/
|
||||||
|
public function getIsBlockedAttribute(): bool
|
||||||
|
{
|
||||||
|
return !$this->canStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the blocking tasks (incomplete dependencies).
|
||||||
|
*/
|
||||||
|
public function getBlockingTasksAttribute()
|
||||||
|
{
|
||||||
|
return $this->dependencies->filter(fn ($dep) => $dep->status !== 'done');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate duration in days.
|
||||||
|
*/
|
||||||
|
public function getDurationDaysAttribute(): ?int
|
||||||
|
{
|
||||||
|
if (!$this->start_date || !$this->due_date) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->start_date->diffInDays($this->due_date) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Models/TaskLog.php
Normal file
39
app/Models/TaskLog.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class TaskLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'task_id',
|
||||||
|
'user_id',
|
||||||
|
'action',
|
||||||
|
'field_changed',
|
||||||
|
'old_value',
|
||||||
|
'new_value',
|
||||||
|
'comment',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'old_value' => 'array',
|
||||||
|
'new_value' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function task()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Task::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Models/Tenant.php
Normal file
66
app/Models/Tenant.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Tenant extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'domain',
|
||||||
|
'is_active',
|
||||||
|
'default_locale',
|
||||||
|
'default_calendar',
|
||||||
|
'base_currency_id',
|
||||||
|
'allow_multi_currency',
|
||||||
|
'logo_path',
|
||||||
|
'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'allow_multi_currency' => 'boolean',
|
||||||
|
'settings' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted()
|
||||||
|
{
|
||||||
|
static::creating(function ($tenant) {
|
||||||
|
if (empty($tenant->slug)) {
|
||||||
|
$tenant->slug = Str::slug($tenant->name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseCurrency()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Currency::class, 'base_currency_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function users()
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function projects()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employees()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expenses()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Expense::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,46 +2,71 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, HasRoles, SoftDeletes;
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'locale',
|
||||||
|
'preferred_calendar',
|
||||||
|
'is_active',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that should be hidden for serialization.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
protected $casts = [
|
||||||
* Get the attributes that should be cast.
|
'email_verified_at' => 'datetime',
|
||||||
*
|
'password' => 'hashed',
|
||||||
* @return array<string, string>
|
'is_active' => 'boolean',
|
||||||
*/
|
];
|
||||||
protected function casts(): array
|
|
||||||
|
public function tenant()
|
||||||
{
|
{
|
||||||
return [
|
return $this->belongsTo(Tenant::class);
|
||||||
'email_verified_at' => 'datetime',
|
}
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
public function managedProjects()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Project::class, 'manager_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function projects()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Project::class, 'project_users')
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignedTasks()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Task::class, 'assignee_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocale(): string
|
||||||
|
{
|
||||||
|
return $this->locale ?? $this->tenant?->default_locale ?? config('Vernova.default_locale', 'fa');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCalendar(): string
|
||||||
|
{
|
||||||
|
return $this->preferred_calendar ?? $this->tenant?->default_calendar ?? config('Vernova.default_calendar', 'jalali');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDirection(): string
|
||||||
|
{
|
||||||
|
return in_array($this->getLocale(), config('Vernova.rtl_locales', [])) ? 'rtl' : 'ltr';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
app/Models/Warehouse.php
Normal file
39
app/Models/Warehouse.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Warehouse extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'project_id',
|
||||||
|
'name',
|
||||||
|
'location',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inventoryTransactions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventoryTransaction::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Models/WorkLog.php
Normal file
66
app/Models/WorkLog.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class WorkLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'employee_id',
|
||||||
|
'project_id',
|
||||||
|
'task_id',
|
||||||
|
'date',
|
||||||
|
'hours_worked',
|
||||||
|
'overtime_hours',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => 'date',
|
||||||
|
'hours_worked' => 'decimal:2',
|
||||||
|
'overtime_hours' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function employee()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function task()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Task::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter by date range
|
||||||
|
*/
|
||||||
|
public function scopeDateRange($query, $from, $to)
|
||||||
|
{
|
||||||
|
return $query->whereBetween('date', [$from, $to]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: overtime logs (overtime_hours > 0)
|
||||||
|
*/
|
||||||
|
public function scopeOvertime($query)
|
||||||
|
{
|
||||||
|
return $query->where('overtime_hours', '>', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Policies/AttachmentPolicy.php
Normal file
68
app/Policies/AttachmentPolicy.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class AttachmentPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the attachment.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Attachment $attachment): bool
|
||||||
|
{
|
||||||
|
// Admin can view all
|
||||||
|
if ($user->hasRole('admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tenant scope
|
||||||
|
if ($attachment->tenant_id && $attachment->tenant_id !== session('tenant_id')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can view the parent model
|
||||||
|
if ($attachment->attachable) {
|
||||||
|
try {
|
||||||
|
return $user->can('view', $attachment->attachable);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return true; // If no policy exists for parent model, allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User who uploaded can view
|
||||||
|
return $attachment->user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the attachment.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Attachment $attachment): bool
|
||||||
|
{
|
||||||
|
// Admin can delete all
|
||||||
|
if ($user->hasRole('admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tenant scope
|
||||||
|
if ($attachment->tenant_id && $attachment->tenant_id !== session('tenant_id')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can update the parent model
|
||||||
|
if ($attachment->attachable) {
|
||||||
|
try {
|
||||||
|
return $user->can('update', $attachment->attachable);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User who uploaded can delete
|
||||||
|
return $attachment->user_id === $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Policies/DocumentPolicy.php
Normal file
40
app/Policies/DocumentPolicy.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Document;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class DocumentPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('documents.view') || $user->can('documents.manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $document->tenant_id
|
||||||
|
&& ($user->can('documents.view') || $user->can('documents.manage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('documents.create') || $user->can('documents.manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $document->tenant_id
|
||||||
|
&& ($user->can('documents.update') || $user->can('documents.manage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $document->tenant_id
|
||||||
|
&& ($user->can('documents.delete') || $user->can('documents.manage'));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Policies/EmployeePolicy.php
Normal file
76
app/Policies/EmployeePolicy.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Employee;
|
||||||
|
|
||||||
|
class EmployeePolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any employees.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasRole('hr')
|
||||||
|
|| $user->hasPermissionTo('view employees'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the employee.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Employee $employee): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $employee->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasRole('hr')
|
||||||
|
|| $user->hasPermissionTo('view employees'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create employees.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('hr')
|
||||||
|
|| $user->hasPermissionTo('create employees'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the employee.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Employee $employee): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $employee->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('hr')
|
||||||
|
|| $user->hasPermissionTo('edit employees'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the employee.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Employee $employee): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $employee->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('delete employees'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view employee financials.
|
||||||
|
*/
|
||||||
|
public function viewFinancials(User $user, Employee $employee): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $employee->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('hr')
|
||||||
|
|| $user->hasPermissionTo('view employee financials'));
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Policies/ExpensePolicy.php
Normal file
92
app/Policies/ExpensePolicy.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Expense;
|
||||||
|
|
||||||
|
class ExpensePolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any expenses.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasRole('accountant')
|
||||||
|
|| $user->hasPermissionTo('view expenses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the expense.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Expense $expense): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $expense->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager') || $user->hasRole('accountant')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can view their own expenses
|
||||||
|
return $expense->requested_by === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create expenses.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasRole('accountant')
|
||||||
|
|| $user->hasPermissionTo('create expenses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the expense.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Expense $expense): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $expense->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pending expenses can be updated
|
||||||
|
if ($expense->status !== 'pending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager') || $user->hasRole('accountant')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $expense->requested_by === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the expense.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Expense $expense): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $expense->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('delete expenses'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can approve/reject expenses.
|
||||||
|
*/
|
||||||
|
public function approve(User $user, Expense $expense): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $expense->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('approve expenses'));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Policies/InventoryTransactionPolicy.php
Normal file
55
app/Policies/InventoryTransactionPolicy.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class InventoryTransactionPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any inventory transactions.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('inventory.view') || $user->can('inventory.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the inventory transaction.
|
||||||
|
*/
|
||||||
|
public function view(User $user, InventoryTransaction $inventoryTransaction): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $inventoryTransaction->tenant_id
|
||||||
|
&& ($user->can('inventory.view') || $user->can('inventory.update'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create inventory transactions.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('inventory.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the inventory transaction.
|
||||||
|
*/
|
||||||
|
public function update(User $user, InventoryTransaction $inventoryTransaction): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $inventoryTransaction->tenant_id
|
||||||
|
&& $user->can('inventory.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the inventory transaction.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, InventoryTransaction $inventoryTransaction): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $inventoryTransaction->tenant_id
|
||||||
|
&& $user->can('inventory.delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Policies/ItemPolicy.php
Normal file
55
app/Policies/ItemPolicy.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Item;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class ItemPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any items.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('inventory.view') || $user->can('inventory.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the item.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Item $item): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $item->tenant_id
|
||||||
|
&& ($user->can('inventory.view') || $user->can('inventory.update'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create items.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('inventory.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the item.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Item $item): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $item->tenant_id
|
||||||
|
&& $user->can('inventory.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the item.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Item $item): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $item->tenant_id
|
||||||
|
&& $user->can('inventory.delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Policies/LetterPolicy.php
Normal file
55
app/Policies/LetterPolicy.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Letter;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class LetterPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any letters.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('letters.view') || $user->can('letters.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the letter.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Letter $letter): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $letter->tenant_id
|
||||||
|
&& ($user->can('letters.view') || $user->can('letters.update'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create letters.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('letters.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the letter.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Letter $letter): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $letter->tenant_id
|
||||||
|
&& $user->can('letters.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the letter.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Letter $letter): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $letter->tenant_id
|
||||||
|
&& $user->can('letters.delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Policies/PettyCashPolicy.php
Normal file
86
app/Policies/PettyCashPolicy.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\PettyCash;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class PettyCashPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any petty cash requests.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('petty-cash.view') || $user->can('petty-cash.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the petty cash request.
|
||||||
|
*/
|
||||||
|
public function view(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $pettyCash->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->can('petty-cash.view') || $user->can('petty-cash.update')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can view their own petty cash requests
|
||||||
|
return $pettyCash->requested_by === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create petty cash requests.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('petty-cash.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the petty cash request.
|
||||||
|
*/
|
||||||
|
public function update(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $pettyCash->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pending requests can be updated
|
||||||
|
if ($pettyCash->status !== 'pending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->can('petty-cash.update')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can update their own pending requests
|
||||||
|
return $pettyCash->requested_by === $user->id && $user->can('petty-cash.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the petty cash request.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $pettyCash->tenant_id
|
||||||
|
&& $user->can('petty-cash.delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can approve the petty cash request.
|
||||||
|
* Only users with approve permission can approve.
|
||||||
|
*/
|
||||||
|
public function approve(User $user, PettyCash $pettyCash): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $pettyCash->tenant_id
|
||||||
|
&& $user->can('petty-cash.approve');
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Policies/ProjectPolicy.php
Normal file
76
app/Policies/ProjectPolicy.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Project;
|
||||||
|
|
||||||
|
class ProjectPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any projects.
|
||||||
|
* Must belong to same tenant and have the permission.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('view projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the project.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Project $project): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $project->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('view projects')
|
||||||
|
|| $project->users()->where('user_id', $user->id)->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create projects.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('create projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the project.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Project $project): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $project->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| ($user->hasRole('manager') && $project->manager_id === $user->id)
|
||||||
|
|| $user->hasPermissionTo('edit projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the project.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Project $project): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $project->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('delete projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can manage project members.
|
||||||
|
*/
|
||||||
|
public function manageMembers(User $user, Project $project): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $project->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| ($user->hasRole('manager') && $project->manager_id === $user->id)
|
||||||
|
|| $user->hasPermissionTo('manage project members'));
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/Policies/TaskPolicy.php
Normal file
96
app/Policies/TaskPolicy.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Task;
|
||||||
|
|
||||||
|
class TaskPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any tasks.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('view tasks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the task.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Task $task): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $task->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin and managers can view all
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is a member of the project
|
||||||
|
return $task->project
|
||||||
|
&& $task->project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create tasks.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('create tasks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the task.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Task $task): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $task->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can update if they are the assignee or reporter
|
||||||
|
return $task->assignee_id === $user->id
|
||||||
|
|| $task->reporter_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the task.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Task $task): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $task->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('delete tasks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the task status.
|
||||||
|
*/
|
||||||
|
public function updateStatus(User $user, Task $task): bool
|
||||||
|
{
|
||||||
|
if ($user->tenant_id !== $task->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('admin') || $user->hasRole('manager')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can update status if they are a member of the project
|
||||||
|
return $task->project
|
||||||
|
&& $task->project->users()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Policies/WarehousePolicy.php
Normal file
63
app/Policies/WarehousePolicy.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
|
||||||
|
class WarehousePolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any warehouses.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the warehouse.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Warehouse $warehouse): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $warehouse->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create warehouses.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === session('tenant_id')
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the warehouse.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Warehouse $warehouse): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $warehouse->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasRole('manager')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the warehouse.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Warehouse $warehouse): bool
|
||||||
|
{
|
||||||
|
return $user->tenant_id === $warehouse->tenant_id
|
||||||
|
&& ($user->hasRole('admin')
|
||||||
|
|| $user->hasPermissionTo('manage inventory'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,22 +3,56 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use App\Models\Item;
|
||||||
|
use App\Models\PettyCash;
|
||||||
|
use App\Models\InventoryTransaction;
|
||||||
|
use App\Models\Expense;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\Letter;
|
||||||
|
use App\Policies\ItemPolicy;
|
||||||
|
use App\Policies\PettyCashPolicy;
|
||||||
|
use App\Policies\InventoryTransactionPolicy;
|
||||||
|
use App\Policies\ExpensePolicy;
|
||||||
|
use App\Policies\EmployeePolicy;
|
||||||
|
use App\Policies\TaskPolicy;
|
||||||
|
use App\Policies\ProjectPolicy;
|
||||||
|
use App\Policies\AttachmentPolicy;
|
||||||
|
use App\Policies\LetterPolicy;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
if ($locale = Session::get('locale')) {
|
||||||
|
if (in_array($locale, array_keys(config('Vernova.supported_locales')))) {
|
||||||
|
App::setLocale($locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app()->environment('production')) {
|
||||||
|
\URL::forceScheme('https');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register all policies explicitly
|
||||||
|
Gate::policy(Item::class, ItemPolicy::class);
|
||||||
|
Gate::policy(PettyCash::class, PettyCashPolicy::class);
|
||||||
|
Gate::policy(InventoryTransaction::class, InventoryTransactionPolicy::class);
|
||||||
|
Gate::policy(Expense::class, ExpensePolicy::class);
|
||||||
|
Gate::policy(Employee::class, EmployeePolicy::class);
|
||||||
|
Gate::policy(Task::class, TaskPolicy::class);
|
||||||
|
Gate::policy(Project::class, ProjectPolicy::class);
|
||||||
|
Gate::policy(Attachment::class, AttachmentPolicy::class);
|
||||||
|
Gate::policy(Letter::class, LetterPolicy::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
app/Providers/BladeServiceProvider.php
Normal file
40
app/Providers/BladeServiceProvider.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use App\Helpers\NumberHelper;
|
||||||
|
use App\Helpers\CalendarHelper;
|
||||||
|
use App\Helpers\CurrencyHelper;
|
||||||
|
|
||||||
|
class BladeServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Blade::directive('localizeNumber', function ($expression) {
|
||||||
|
return "<?php echo \\App\\Helpers\\NumberHelper::formatNumber($expression); ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
Blade::directive('localizeDate', function ($expression) {
|
||||||
|
return "<?php echo \\App\\Helpers\\CalendarHelper::formatDate($expression); ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
Blade::directive('localizeCurrency', function ($expression) {
|
||||||
|
return "<?php echo \\App\\Helpers\\CurrencyHelper::formatWithSymbol($expression); ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
Blade::directive('direction', function () {
|
||||||
|
return "<?php echo in_array(app()->getLocale(), config('Vernova.rtl_locales')) ? 'rtl' : 'ltr'; ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
Blade::if('isRtl', function () {
|
||||||
|
return in_array(app()->getLocale(), config('Vernova.rtl_locales'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Providers/RouteServiceProvider.php
Normal file
36
app/Providers/RouteServiceProvider.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
class RouteServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The path to your home route.
|
||||||
|
*/
|
||||||
|
public const HOME = '/dashboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define your route model bindings, pattern filters, and other route configuration.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('api', function (Request $request) {
|
||||||
|
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->routes(function () {
|
||||||
|
Route::middleware('api')
|
||||||
|
->prefix('api')
|
||||||
|
->group(base_path('routes/api.php'));
|
||||||
|
|
||||||
|
Route::middleware('web')
|
||||||
|
->group(base_path('routes/web.php'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/helpers.php
Normal file
67
app/helpers.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Helpers\CalendarHelper;
|
||||||
|
|
||||||
|
if (!function_exists('calendar_date')) {
|
||||||
|
function calendar_date($date, $format = 'Y/m/d')
|
||||||
|
{
|
||||||
|
if (!$date) return '';
|
||||||
|
|
||||||
|
$calendar = session('calendar', 'jalali');
|
||||||
|
|
||||||
|
if ($calendar === 'jalali') {
|
||||||
|
try {
|
||||||
|
$jalali = \Morilog\Jalali\Jalalian::fromDateTime($date);
|
||||||
|
return $jalali->format($format);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $date->format($format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date->format($format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('persian_num')) {
|
||||||
|
function persian_num($number)
|
||||||
|
{
|
||||||
|
$persian = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||||
|
$english = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
return str_replace($english, $persian, (string) $number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('format_currency')) {
|
||||||
|
function format_currency($amount, $currency = 'IRR')
|
||||||
|
{
|
||||||
|
$formatted = number_format((float) $amount, $currency === 'IRR' ? 0 : 2);
|
||||||
|
|
||||||
|
if (app()->getLocale() === 'fa') {
|
||||||
|
$formatted = persian_num($formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
$symbol = '';
|
||||||
|
switch ($currency) {
|
||||||
|
case 'IRR': $symbol = 'ریال'; break;
|
||||||
|
case 'USD': $symbol = '$'; break;
|
||||||
|
case 'EUR': $symbol = '€'; break;
|
||||||
|
case 'AED': $symbol = 'د.إ'; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted . ' ' . $symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('get_direction')) {
|
||||||
|
function get_direction()
|
||||||
|
{
|
||||||
|
return app()->getLocale() === 'fa' ? 'rtl' : 'ltr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('is_rtl')) {
|
||||||
|
function is_rtl()
|
||||||
|
{
|
||||||
|
return app()->getLocale() === 'fa';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,11 +7,22 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
//
|
$middleware->web(append: [
|
||||||
|
\App\Http\Middleware\SetTenant::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||||
|
'tenant.scope' => \App\Http\Middleware\TenantScope::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|||||||
@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\RouteServiceProvider::class,
|
||||||
|
App\Providers\BladeServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,29 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "laravel/laravel",
|
"name": "vernova/vernova",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "Vernova - Multi-tenant Project Management Platform",
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "project-management", "multi-tenant", "saas"],
|
||||||
"license": "MIT",
|
"license": "proprietary",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^11.0",
|
"laravel/framework": "^11.0",
|
||||||
"laravel/tinker": "^2.9"
|
"laravel/sanctum": "^4.0",
|
||||||
|
"laravel/tinker": "^2.9",
|
||||||
|
"spatie/laravel-permission": "^6.0",
|
||||||
|
"morilog/jalali": "^3.0",
|
||||||
|
"maatwebsite/excel": "^1.1",
|
||||||
|
"barryvdh/laravel-dompdf": "^2.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.8"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"laravel/pint": "^1.13",
|
|
||||||
"laravel/sail": "^1.26",
|
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.0",
|
"nunomaduro/collision": "^8.0",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^11.0",
|
||||||
"spatie/laravel-ignition": "^2.4"
|
"pestphp/pest": "^2.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"app/Helpers/helpers.php"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@ -37,24 +44,8 @@
|
|||||||
],
|
],
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
],
|
|
||||||
"post-root-package-install": [
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
|
||||||
],
|
|
||||||
"post-create-project-cmd": [
|
|
||||||
"@php artisan key:generate --ansi",
|
|
||||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
|
||||||
"@php artisan migrate --ansi"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "11.x-dev"
|
|
||||||
},
|
|
||||||
"laravel": {
|
|
||||||
"dont-discover": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
|
|||||||
9844
composer.lock
generated
Normal file
9844
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user