diff --git a/app/Http/Controllers/Modals/Categories.php b/app/Http/Controllers/Modals/Categories.php index 370b4afab..d3a58631a 100644 --- a/app/Http/Controllers/Modals/Categories.php +++ b/app/Http/Controllers/Modals/Categories.php @@ -6,10 +6,14 @@ use App\Abstracts\Http\Controller; use App\Http\Requests\Setting\Category as Request; use App\Jobs\Setting\CreateCategory; use App\Models\Setting\Category; +use App\Traits\Categories as Helper; +use App\Traits\Modules; use Illuminate\Http\Request as IRequest; class Categories extends Controller { + use Helper, Modules; + /** * Instantiate a new controller instance. */ @@ -29,19 +33,42 @@ class Categories extends Controller */ public function create(IRequest $request) { - $type = $request->get('type', 'item'); + $type = $request->get('type', Category::ITEM_TYPE); + + switch ($type) { + case Category::INCOME_TYPE: + $types = $this->getIncomeCategoryTypes(); + break; + case Category::EXPENSE_TYPE: + $types = $this->getExpenseCategoryTypes(); + break; + case Category::ITEM_TYPE: + $types = $this->getItemCategoryTypes(); + break; + case Category::OTHER_TYPE: + $types = $this->getOtherCategoryTypes(); + break; + default: + $types = [$type]; + } $categories = collect(); - Category::type($type)->enabled()->orderBy('name')->get()->each(function ($category) use (&$categories) { - $categories->push([ - 'id' => $category->id, - 'title' => $category->name, - 'level' => $category->level, - ]); - }); + Category::type($types) + ->enabled() + ->orderBy('name') + ->get() + ->each(function ($category) use (&$categories) { + $categories->push([ + 'id' => $category->id, + 'title' => $category->name, + 'level' => $category->level, + ]); + }); - $html = view('modals.categories.create', compact('type', 'categories'))->render(); + $has_code = $this->moduleIsEnabled('double-entry'); + + $html = view('modals.categories.create', compact('type', 'types', 'categories', 'has_code'))->render(); return response()->json([ 'success' => true, @@ -61,7 +88,7 @@ class Categories extends Controller public function store(Request $request) { $request['enabled'] = 1; - $request['type'] = $request->get('type', 'income'); + $request['type'] = $request->get('type', Category::ITEM_TYPE); $request['color'] = $request->get('color', '#' . dechex(rand(0x000000, 0xFFFFFF))); $response = $this->ajaxDispatch(new CreateCategory($request)); diff --git a/app/Models/Setting/Category.php b/app/Models/Setting/Category.php index 239965176..2f68a75ee 100644 --- a/app/Models/Setting/Category.php +++ b/app/Models/Setting/Category.php @@ -4,11 +4,13 @@ namespace App\Models\Setting; use App\Abstracts\Model; use App\Builders\Category as Builder; +use App\Models\Banking\Transaction; use App\Models\Document\Document; use App\Interfaces\Export\WithParentSheet; use App\Relations\HasMany\Category as HasMany; use App\Scopes\Category as Scope; use App\Traits\Categories; +use App\Traits\DateTime; use App\Traits\Tailwind; use App\Traits\Transactions; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; @@ -17,7 +19,7 @@ use Illuminate\Database\Eloquent\Model as EloquentModel; class Category extends Model { - use Categories, HasFactory, Tailwind, Transactions; + use Categories, HasFactory, Tailwind, Transactions, DateTime; public const INCOME_TYPE = 'income'; public const EXPENSE_TYPE = 'expense'; @@ -33,14 +35,14 @@ class Category extends Model * * @var array */ - protected $fillable = ['company_id', 'name', 'type', 'color', 'enabled', 'created_from', 'created_by', 'parent_id']; + protected $fillable = ['company_id', 'code', 'name', 'type', 'color', 'description', 'enabled', 'created_from', 'created_by', 'parent_id']; /** * Sortable columns. * * @var array */ - public $sortable = ['name', 'type', 'enabled']; + public $sortable = ['code', 'name', 'type', 'enabled']; /** * The "booted" method of the model. @@ -137,6 +139,18 @@ class Category extends Model return $this->hasMany('App\Models\Banking\Transaction'); } + /** + * Scope code. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param $code + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeCode($query, $code) + { + return $query->where('code', $code); + } + /** * Scope to only include categories of a given type. * @@ -155,46 +169,50 @@ class Category extends Model /** * Scope to include only income. + * Uses Categories trait to support multiple income types (e.g. from modules). * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ public function scopeIncome($query) { - return $query->where($this->qualifyColumn('type'), '=', 'income'); + return $query->whereIn($this->qualifyColumn('type'), $this->getIncomeCategoryTypes()); } /** * Scope to include only expense. + * Uses Categories trait to support multiple expense types (e.g. from modules). * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ public function scopeExpense($query) { - return $query->where($this->qualifyColumn('type'), '=', 'expense'); + return $query->whereIn($this->qualifyColumn('type'), $this->getExpenseCategoryTypes()); } /** * Scope to include only item. + * Uses Categories trait to support multiple item types (e.g. from modules). * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ public function scopeItem($query) { - return $query->where($this->qualifyColumn('type'), '=', 'item'); + return $query->whereIn($this->qualifyColumn('type'), $this->getItemCategoryTypes()); } /** * Scope to include only other. + * Uses Categories trait to support multiple other types (e.g. from modules). * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ public function scopeOther($query) { - return $query->where($this->qualifyColumn('type'), '=', 'other'); + return $query->whereIn($this->qualifyColumn('type'), $this->getOtherCategoryTypes()); } public function scopeName($query, $name) @@ -213,6 +231,17 @@ class Category extends Model return $query->withoutGlobalScope(new Scope); } + /** + * Scope gets only parent categories. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeIsNotSubCategory($query) + { + return $query->whereNull('parent_id'); + } + /** * Scope to export the rows of the current page filtered and sorted. * @@ -233,7 +262,7 @@ class Category extends Model $search = $request->get('search'); - $query->withSubcategory(); + $query->withSubCategory(); $query->usingSearchString($search)->sortable($sort); @@ -261,9 +290,92 @@ class Category extends Model /** * Get the display name of the category. */ - public function getDisplayNameAttribute() + public function getDisplayNameAttribute(): string { - return $this->name . ' (' . ucfirst($this->type) . ')'; + $typeConfig = config('type.category.' . $this->type, []); + $hideCode = isset($typeConfig['hide']) && in_array('code', $typeConfig['hide']); + + $typeNames = $this->getCategoryTypes(); + $typeName = $typeNames[$this->type] ?? ucfirst($this->type); + + $prefix = (!$hideCode && $this->code) ? $this->code . ' - ' : ''; + + return $prefix . $this->name . ' (' . $typeName . ')'; + } + + /** + * Get the balance of a category. + * + * @return double + */ + public function getBalanceAttribute() + { + // If view composer has set the balance, return it directly + if (isset($this->de_balance)) { + return $this->de_balance; + } + + $financial_year = $this->getFinancialYear(); + + $start_date = $financial_year->getStartDate(); + $end_date = $financial_year->getEndDate(); + + $this->transactions->whereBetween('paid_at', [$start_date, $end_date]) + ->each(function ($transaction) use (&$incomes, &$expenses) { + if (($transaction->isNotIncome() && $transaction->isNotExpense()) || $transaction->isTransferTransaction()) { + return; + } + + if ($transaction->isIncome()) { + $incomes += $transaction->getAmountConvertedToDefault(); + } else { + $expenses += $transaction->getAmountConvertedToDefault(); + } + }); + + $balance = $incomes - $expenses; + + $this->sub_categories() + ->each(function ($sub_category) use (&$balance) { + $balance += $sub_category->balance; + }); + + return $balance; + } + + /** + * Get the balance of a category without considering sub categories. + * + * @return double + */ + public function getBalanceWithoutSubcategoriesAttribute() + { + // If view composer has set the balance, return it directly + if (isset($this->without_subcategory_de_balance)) { + return $this->without_subcategory_de_balance; + } + + $financial_year = $this->getFinancialYear(); + + $start_date = $financial_year->getStartDate(); + $end_date = $financial_year->getEndDate(); + + $this->transactions->whereBetween('paid_at', [$start_date, $end_date]) + ->each(function ($transaction) use (&$incomes, &$expenses) { + if (($transaction->isNotIncome() && $transaction->isNotExpense()) || $transaction->isTransferTransaction()) { + return; + } + + if ($transaction->isIncome()) { + $incomes += $transaction->getAmountConvertedToDefault(); + } else { + $expenses += $transaction->getAmountConvertedToDefault(); + } + }); + + $balance = $incomes - $expenses; + + return $balance; } /** @@ -303,6 +415,19 @@ class Category extends Model return $actions; } + /** + * A no-op callback that gets fired when a model is cloning but before it gets + * committed to the database + * + * @param Illuminate\Database\Eloquent\Model $src + * @param boolean $child + * @return void + */ + public function onCloning($src, $child = null) + { + $this->code = $this->getNextCategoryCode(); + } + /** * Create a new factory instance for the model. * diff --git a/config/search-string.php b/config/search-string.php index 75945eb85..d717c5fc8 100644 --- a/config/search-string.php +++ b/config/search-string.php @@ -1,5 +1,7 @@ [ - 'route' => ['categories.index', 'search=type:income,expense enabled:1'], + 'route' => ['categories.index', 'search=type:' . Category::INCOME_TYPE . ',' . Category::EXPENSE_TYPE . ' enabled:1'], 'fields' => [ 'key' => 'id', 'value' => 'display_name', @@ -246,7 +248,7 @@ return [ 'description' => ['searchable' => true], 'enabled' => ['boolean' => true], 'category_id' => [ - 'route' => ['categories.index', 'search=type:item enabled:1'], + 'route' => ['categories.index', 'search=type:' . Category::ITEM_TYPE . ' enabled:1'], 'fields' => [ 'key' => 'id', 'value' => 'name', @@ -352,7 +354,7 @@ return [ 'contact_phone' => ['searchable' => true], 'contact_address' => ['searchable' => true], 'category_id' => [ - 'route' => ['categories.index', 'search=type:income,expense enabled:1'], + 'route' => ['categories.index', 'search=type:' . Category::INCOME_TYPE . ',' . Category::EXPENSE_TYPE . ' enabled:1'], 'multiple' => true, ], 'parent_id', @@ -403,7 +405,7 @@ return [ 'contact_phone' => ['searchable' => true], 'contact_address' => ['searchable' => true], 'category_id' => [ - 'route' => ['categories.index', 'search=type:expense enabled:1'], + 'route' => ['categories.index', 'search=type:' . Category::EXPENSE_TYPE . ' enabled:1'], 'fields' => [ 'key' => 'id', 'value' => 'name', @@ -459,7 +461,7 @@ return [ 'contact_phone' => ['searchable' => true], 'contact_address' => ['searchable' => true], 'category_id' => [ - 'route' => ['categories.index', 'search=type:income enabled:1'], + 'route' => ['categories.index', 'search=type:' . Category::INCOME_TYPE . ' enabled:1'], 'fields' => [ 'key' => 'id', 'value' => 'name', @@ -480,6 +482,8 @@ return [ App\Models\Setting\Category::class => [ 'columns' => [ 'id', + 'code' => ['searchable' => true], + 'description' => ['searchable' => true], 'name' => ['searchable' => true], 'enabled' => ['boolean' => true], 'type' => [ diff --git a/database/migrations/2026_02_17_000000_core_v3122.php b/database/migrations/2026_02_17_000000_core_v3122.php new file mode 100644 index 000000000..2f2a2744b --- /dev/null +++ b/database/migrations/2026_02_17_000000_core_v3122.php @@ -0,0 +1,34 @@ +string('code')->nullable()->after('company_id'); + $table->text('description')->nullable()->after('color'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropColumn('code'); + $table->dropColumn('description'); + }); + } +}; diff --git a/resources/views/modals/categories/create.blade.php b/resources/views/modals/categories/create.blade.php index 2f0076ae0..a1efc5818 100644 --- a/resources/views/modals/categories/create.blade.php +++ b/resources/views/modals/categories/create.blade.php @@ -2,11 +2,22 @@
+ @if ($has_code) + + @endif + - + @if (!empty($types) && count($types) > 1) + + @else + + @endif + + +
diff --git a/resources/views/settings/categories/create.blade.php b/resources/views/settings/categories/create.blade.php index cb979a2ae..ed12cc5dd 100644 --- a/resources/views/settings/categories/create.blade.php +++ b/resources/views/settings/categories/create.blade.php @@ -20,12 +20,18 @@ + @if ($has_code) + + @endif + - + + + diff --git a/resources/views/settings/categories/edit.blade.php b/resources/views/settings/categories/edit.blade.php index f3ad8fefd..35c121729 100644 --- a/resources/views/settings/categories/edit.blade.php +++ b/resources/views/settings/categories/edit.blade.php @@ -14,20 +14,26 @@ + @if ($has_code) + + @endif + @if ($type_disabled) - + @else - + @endif + +