diff --git a/app/Abstracts/Listeners/Report.php b/app/Abstracts/Listeners/Report.php index f50f0acfc..553c7fd9b 100644 --- a/app/Abstracts/Listeners/Report.php +++ b/app/Abstracts/Listeners/Report.php @@ -5,13 +5,14 @@ namespace App\Abstracts\Listeners; use App\Models\Banking\Account; use App\Models\Common\Contact; use App\Models\Setting\Category; +use App\Traits\Categories; use App\Traits\Contacts; use App\Traits\DateTime; use App\Traits\SearchString; abstract class Report { - use Contacts, DateTime, SearchString; + use Categories, Contacts, DateTime, SearchString; protected $classes = []; @@ -90,22 +91,24 @@ abstract class Report public function getItemCategories($limit = false) { - return $this->getCategories('item', $limit); + return $this->getCategories($this->getItemCategoryTypes(), $limit); } public function getIncomeCategories($limit = false) { - return $this->getCategories('income', $limit); + return $this->getCategories($this->getIncomeCategoryTypes(), $limit); } public function getExpenseCategories($limit = false) { - return $this->getCategories('expense', $limit); + return $this->getCategories($this->getExpenseCategoryTypes(), $limit); } public function getIncomeExpenseCategories($limit = false) { - return $this->getCategories(['income', 'expense'], $limit); + $types = array_merge($this->getIncomeCategoryTypes(), $this->getExpenseCategoryTypes()); + + return $this->getCategories($types, $limit); } public function getCategories($types, $limit = false) diff --git a/app/Http/Controllers/Settings/Defaults.php b/app/Http/Controllers/Settings/Defaults.php index 2970a72f1..0b1163537 100644 --- a/app/Http/Controllers/Settings/Defaults.php +++ b/app/Http/Controllers/Settings/Defaults.php @@ -6,9 +6,12 @@ use App\Abstracts\Http\SettingController; use App\Models\Banking\Account; use App\Models\Setting\Category; use App\Models\Setting\Tax; +use App\Traits\Categories; class Defaults extends SettingController { + use Categories; + public function edit() { $accounts = Account::enabled()->orderBy('name')->get()->pluck('title', 'id'); @@ -39,11 +42,16 @@ class Defaults extends SettingController $taxes = Tax::enabled()->orderBy('name')->get()->pluck('title', 'id'); + $income_category_types = $this->getIncomeCategoryTypes('string'); + $expense_category_types = $this->getExpenseCategoryTypes('string'); + return view('settings.default.edit', compact( 'accounts', 'sales_categories', 'purchases_categories', 'taxes', + 'income_category_types', + 'expense_category_types', )); } } diff --git a/app/Listeners/Report/AddExpenseCategories.php b/app/Listeners/Report/AddExpenseCategories.php index 36cec3f63..9685b3465 100644 --- a/app/Listeners/Report/AddExpenseCategories.php +++ b/app/Listeners/Report/AddExpenseCategories.php @@ -27,7 +27,7 @@ class AddExpenseCategories extends Listener // send true for add limit on search and filter.. $event->class->filters['categories'] = $this->getExpenseCategories(true); - $event->class->filters['routes']['categories'] = ['categories.index', 'search=type:expense enabled:1']; + $event->class->filters['routes']['categories'] = ['categories.index', 'search=type:' . $this->getExpenseCategoryTypes('string') . ' enabled:1']; $event->class->filters['multiple']['categories'] = true; } diff --git a/app/Listeners/Report/AddIncomeCategories.php b/app/Listeners/Report/AddIncomeCategories.php index ec7476144..d492a955d 100644 --- a/app/Listeners/Report/AddIncomeCategories.php +++ b/app/Listeners/Report/AddIncomeCategories.php @@ -27,7 +27,7 @@ class AddIncomeCategories extends Listener // send true for add limit on search and filter.. $event->class->filters['categories'] = $this->getIncomeCategories(true); - $event->class->filters['routes']['categories'] = ['categories.index', 'search=type:income enabled:1']; + $event->class->filters['routes']['categories'] = ['categories.index', 'search=type:' . $this->getIncomeCategoryTypes('string') . ' enabled:1']; $event->class->filters['multiple']['categories'] = true; } diff --git a/app/Listeners/Report/AddIncomeExpenseCategories.php b/app/Listeners/Report/AddIncomeExpenseCategories.php index 3f9a681d5..9552e4fd4 100644 --- a/app/Listeners/Report/AddIncomeExpenseCategories.php +++ b/app/Listeners/Report/AddIncomeExpenseCategories.php @@ -28,7 +28,7 @@ class AddIncomeExpenseCategories extends Listener } $event->class->filters['categories'] = $this->getIncomeExpenseCategories(true); - $event->class->filters['routes']['categories'] = ['categories.index', 'search=type:income,expense enabled:1']; + $event->class->filters['routes']['categories'] = ['categories.index', 'search=type:' . implode(',', array_merge($this->getIncomeCategoryTypes(), $this->getExpenseCategoryTypes())) . ' enabled:1']; $event->class->filters['multiple']['categories'] = true; } @@ -69,7 +69,7 @@ class AddIncomeExpenseCategories extends Listener return; } - $categories = Category::type(['income', 'expense'])->orderBy('name')->get(); + $categories = Category::type(array_merge($this->getIncomeCategoryTypes(), $this->getExpenseCategoryTypes()))->orderBy('name')->get(); $rows = $categories->pluck('name', 'id')->toArray(); $this->setRowNamesAndValuesForCategories($event, $rows, $categories); @@ -83,10 +83,12 @@ class AddIncomeExpenseCategories extends Listener { foreach ($event->class->dates as $date) { foreach ($event->class->tables as $table_key => $table_name) { + $table_keys = $table_key == Category::INCOME_TYPE ? $this->getIncomeCategoryTypes() : $this->getExpenseCategoryTypes(); + foreach ($rows as $id => $name) { $category = $categories->where('id', $id)->first(); - if ($category->type != $table_key) { + if (!in_array($category->type, $table_keys)) { continue; } @@ -100,10 +102,12 @@ class AddIncomeExpenseCategories extends Listener public function setTreeNodesForCategories($event, $nodes, $categories) { foreach ($event->class->tables as $table_key => $table_name) { + $table_keys = $table_key == Category::INCOME_TYPE ? $this->getIncomeCategoryTypes() : $this->getExpenseCategoryTypes(); + foreach ($nodes as $id => $node) { $category = $categories->where('id', $id)->first(); - if ($category->type != $table_key) { + if (!in_array($category->type, $table_keys)) { continue; } diff --git a/app/Models/Common/Company.php b/app/Models/Common/Company.php index b526a4345..002d83b3f 100644 --- a/app/Models/Common/Company.php +++ b/app/Models/Common/Company.php @@ -616,9 +616,10 @@ class Company extends Eloquent implements Ownable setting()->forgetAll(); setting()->load(true); - // Override settings and currencies + // Override settings, currencies, and category types Overrider::load('settings'); Overrider::load('currencies'); + Overrider::load('categoryTypes'); event(new CompanyMadeCurrent($this)); diff --git a/app/Traits/Categories.php b/app/Traits/Categories.php index a965169dc..aca78c98b 100644 --- a/app/Traits/Categories.php +++ b/app/Traits/Categories.php @@ -8,7 +8,97 @@ use Illuminate\Support\Str; trait Categories { - public function getCategoryTypes(bool $translate = true): array + public function isIncomeCategory(): bool + { + $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::INCOME_TYPE; + + return in_array($type, $this->getIncomeCategoryTypes()); + } + + public function isExpenseCategory(): bool + { + $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::EXPENSE_TYPE; + + return in_array($type, $this->getExpenseCategoryTypes()); + } + + public function isItemCategory(): bool + { + $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::ITEM_TYPE; + + return in_array($type, $this->getItemCategoryTypes()); + } + + public function isOtherCategory(): bool + { + $type = $this->type ?? $this->category->type ?? $this->model->type ?? Category::OTHER_TYPE; + + return in_array($type, $this->getOtherCategoryTypes()); + } + + public function getIncomeCategoryTypes(string $return = 'array'): string|array + { + return $this->getCategoryTypesByIndex(Category::INCOME_TYPE, $return); + } + + public function getExpenseCategoryTypes(string $return = 'array'): string|array + { + return $this->getCategoryTypesByIndex(Category::EXPENSE_TYPE, $return); + } + + public function getItemCategoryTypes(string $return = 'array'): string|array + { + return $this->getCategoryTypesByIndex(Category::ITEM_TYPE, $return); + } + + public function getOtherCategoryTypes(string $return = 'array'): string|array + { + return $this->getCategoryTypesByIndex(Category::OTHER_TYPE, $return); + } + + public function getCategoryTypesByIndex(string $index, string $return = 'array'): string|array + { + $types = (string) setting('category.type.' . $index); + + return ($return == 'array') ? explode(',', $types) : $types; + } + + public function addIncomeCategoryType(string $new_type): void + { + $this->addCategoryType($new_type, Category::INCOME_TYPE); + } + + public function addExpenseCategoryType(string $new_type): void + { + $this->addCategoryType($new_type, Category::EXPENSE_TYPE); + } + + public function addItemCategoryType(string $new_type): void + { + $this->addCategoryType($new_type, Category::ITEM_TYPE); + } + + public function addOtherCategoryType(string $new_type): void + { + $this->addCategoryType($new_type, Category::OTHER_TYPE); + } + + public function addCategoryType(string $new_type, string $index): void + { + $types = !empty(setting('category.type.' . $index)) ? explode(',', setting('category.type.' . $index)) : []; + + if (in_array($new_type, $types)) { + return; + } + + $types[] = $new_type; + + setting([ + 'category.type.' . $index => implode(',', $types), + ])->save(); + } + + public function getCategoryTypes(bool $translate = true, bool $group = false): array { $types = []; $configs = config('type.category'); @@ -22,12 +112,48 @@ trait Categories $name = $attr['alias'] . '::' . $name; } - $types[$type] = $translate ? trans_choice($name, 1) : $name; + if ($group) { + $group_key = $attr['group'] ?? $type; + $types[$group_key][$type] = $translate ? trans_choice($name, 1) : $name; + } else { + $types[$type] = $translate ? trans_choice($name, 1) : $name; + } } return $types; } + public function getCategoryTabs(): array + { + $tabs = []; + $configs = config('type.category'); + + foreach ($configs as $type => $attr) { + $tab_key = 'categories-' . ($attr['group'] ?? $type); + + if (isset($tabs[$tab_key])) { + $tabs[$tab_key]['key'] .= ',' . $type; + continue; + } + + $plural_type = Str::plural($attr['group'] ?? $type); + + $name = $attr['translation']['prefix'] . '.' . $plural_type; + + if (!empty($attr['alias'])) { + $name = $attr['alias'] . '::' . $name; + } + + $tabs[$tab_key] = [ + 'key' => $type, + 'name' => trans_choice($name, 2), + 'show_code' => $attr['show_code'] ?? false, + ]; + } + + return $tabs; + } + public function getCategoryWithoutChildren(int $id): mixed { return Category::getWithoutChildren()->find($id); @@ -36,7 +162,7 @@ trait Categories public function getTransferCategoryId(): mixed { // 1 hour set cache for same query - return Cache::remember('transferCategoryId', 60, function () { + return Cache::remember('transferCategoryId.' . company_id(), 60, function () { return Category::other()->pluck('id')->first(); }); } @@ -62,4 +188,16 @@ trait Categories return $ids; } + + /** + * Finds existing maximum code and increase it + * + * @return mixed + */ + public function getNextCategoryCode() + { + return Category::isNotSubCategory()->get(['code'])->reject(function ($category) { + return !preg_match('/^[0-9]*$/', $category->code); + })->max('code') + 1; + } } diff --git a/app/Traits/ViewComponents.php b/app/Traits/ViewComponents.php index 753161346..64d2d2295 100644 --- a/app/Traits/ViewComponents.php +++ b/app/Traits/ViewComponents.php @@ -4,6 +4,7 @@ namespace App\Traits; use Akaunting\Module\Module; use App\Events\Common\BulkActionsAdding; +use App\Models\Setting\Category; use App\Traits\Modules; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Route; @@ -188,19 +189,19 @@ trait ViewComponents case 'bill': case 'expense': case 'purchase': - $category_type = 'expense'; + $category_type = Category::EXPENSE_TYPE; break; case 'item': - $category_type = 'item'; + $category_type = Category::ITEM_TYPE; break; case 'other': - $category_type = 'other'; + $category_type = Category::OTHER_TYPE; break; case 'transfer': $category_type = 'transfer'; break; default: - $category_type = 'income'; + $category_type = Category::INCOME_TYPE; break; } diff --git a/app/Utilities/Overrider.php b/app/Utilities/Overrider.php index aac21fe94..1ee269994 100644 --- a/app/Utilities/Overrider.php +++ b/app/Utilities/Overrider.php @@ -3,7 +3,9 @@ namespace App\Utilities; use Akaunting\Money\Money; +use App\Models\Setting\Category; use App\Models\Setting\Currency; +use Illuminate\Support\Str; class Overrider { @@ -60,7 +62,7 @@ class Overrider } // Set locale for Money package - Money::setLocale(app()->getLocale()); + Money::setLocale(app()->getLocale()); // Money config(['money.defaults.currency' => setting('default.currency')]); @@ -71,6 +73,45 @@ class Overrider } } + protected static function loadCategoryTypes() + { + $category = new Category; + + $income_types = $category->getIncomeCategoryTypes('string'); + $expense_types = $category->getExpenseCategoryTypes('string'); + $item_types = $category->getItemCategoryTypes('string'); + $other_types = $category->getOtherCategoryTypes('string'); + + $search_string = config('search-string'); + + foreach ($search_string as $model => &$model_config) { + $route = $model_config['columns']['category_id']['route'] ?? null; + + // Only update category_id routes that point to categories.index + if (!is_array($route) || ($route[0] ?? '') !== 'categories.index' || !isset($route[1])) { + continue; + } + + // Longest match first (income,expense must come before income) + $replacements = [ + 'type:' . Category::INCOME_TYPE . ',' . Category::EXPENSE_TYPE => 'type:' . $income_types . ',' . $expense_types, + 'type:' . Category::INCOME_TYPE => 'type:' . $income_types, + 'type:' . Category::EXPENSE_TYPE => 'type:' . $expense_types, + 'type:' . Category::ITEM_TYPE => 'type:' . $item_types, + 'type:' . Category::OTHER_TYPE => 'type:' . $other_types, + ]; + + foreach ($replacements as $search => $replace) { + if (Str::contains($route[1], $search)) { + $model_config['columns']['category_id']['route'][1] = Str::replace($search, $replace, $route[1]); + break; + } + } + } + + config(['search-string' => $search_string]); + } + protected static function loadCurrencies() { $currencies = Currency::all(); diff --git a/app/View/Components/Form/Group/Category.php b/app/View/Components/Form/Group/Category.php index c9e1e7db1..e6abde7e0 100644 --- a/app/View/Components/Form/Group/Category.php +++ b/app/View/Components/Form/Group/Category.php @@ -4,10 +4,15 @@ namespace App\View\Components\Form\Group; use App\Abstracts\View\Components\Form; use App\Models\Setting\Category as Model; +use App\Traits\Categories; +use App\Traits\Modules; +use Illuminate\Support\Arr; class Category extends Form { - public $type = 'income'; + use Categories, Modules; + + public $type = Model::INCOME_TYPE; public $path; @@ -15,6 +20,14 @@ class Category extends Form public $categories; + /** @var bool */ + public $group; + + public $option_field = [ + 'key' => 'id', + 'value' => 'name', + ]; + /** * Get the view / contents that represent the component. * @@ -26,26 +39,133 @@ class Category extends Form $this->name = 'category_id'; } - $this->path = route('modals.categories.create', ['type' => $this->type]); - $this->remoteAction = route('categories.index', ['search' => 'type:' . $this->type . ' enabled:1']); + switch ($this->type) { + case Model::INCOME_TYPE: + $types = $this->getIncomeCategoryTypes(); + break; + case Model::EXPENSE_TYPE: + $types = $this->getExpenseCategoryTypes(); + break; + case Model::ITEM_TYPE: + $types = $this->getItemCategoryTypes(); + break; + case Model::OTHER_TYPE: + $types = $this->getOtherCategoryTypes(); + break; + default: + $types = [$this->type]; + } - $this->categories = Model::type($this->type)->enabled()->orderBy('name')->take(setting('default.select_limit'))->get(); + $this->path = route('modals.categories.create', ['type' => $this->type]); + $this->remoteAction = route('categories.index', ['search' => 'type:' . implode(',', $types) . ' enabled:1']); + + $typeLabels = collect($this->getCategoryTypes())->only($types)->all(); + + $is_code = false; + + foreach (config('type.category', []) as $type => $config) { + if (! in_array($type, $types)) { + continue; + } + + if (empty($config['hide']) || ! in_array('code', $config['hide'])) { + $is_code = true; + $this->group = true; + break; + } + } + + $order_by = $is_code ? 'code' : 'name'; + + if ($this->group) { + $this->option_field = [ + 'key' => 'id', + 'value' => 'title', + ]; + } + + $query = Model::type($types); + + $query->enabled() + ->orderBy($order_by); + + if (! $this->group) { + $query->take(setting('default.select_limit')); + } + + $this->categories = $query->get(); + + if ($this->group) { + $groups = []; + + foreach ($this->categories as $category) { + $group = $typeLabels[$category->type] ?? trans_choice('general.others', 1); + + $category->title = ($category->code ? $category->code . ' - ' : '') . $category->name; + $category->group = $group; + + $groups[$group][$category->id] = $category; + } + + ksort($groups); + + $this->categories = $groups; + } $model = $this->getParentData('model'); + $selected_category = null; + + $categoryExists = function ($categoryId): bool { + if (! $this->group) { + return $this->categories->contains(function ($category) use ($categoryId) { + return (int) $category->id === (int) $categoryId; + }); + } + + foreach ($this->categories as $group_categories) { + foreach ($group_categories as $category) { + if ((int) $category->id === (int) $categoryId) { + return true; + } + } + } + + return false; + }; + + $appendCategory = function ($category) use ($typeLabels): void { + if (empty($category)) { + return; + } + + $category->title = ($category->code ? $category->code . ' - ' : '') . $category->name; + + if (! $this->group) { + $this->categories->push($category); + + return; + } + + $group = $typeLabels[$category->type] ?? trans_choice('general.others', 1); + + if (! isset($this->categories[$group])) { + $this->categories[$group] = []; + } + + $this->categories[$group][$category->id] = $category; + + ksort($this->categories); + }; $category_id = old('category.id', old('category_id', null)); if (! empty($category_id)) { $this->selected = $category_id; - $has_category = $this->categories->search(function ($category, int $key) use ($category_id) { - return $category->id === $category_id; - }); - - if ($has_category === false) { + if (! $categoryExists($category_id)) { $category = Model::find($category_id); - $this->categories->push($category); + $appendCategory($category); } } @@ -61,15 +181,15 @@ class Category extends Form $selected_category = Model::find($this->selected); } + if (empty($selected_category) && ! empty($this->selected)) { + $selected_category = Model::find($this->selected); + } + if (! empty($selected_category)) { $selected_category_id = $selected_category->id; - $has_selected_category = $this->categories->search(function ($category, int $key) use ($selected_category_id) { - return $category->id === $selected_category_id; - }); - - if ($has_selected_category === false) { - $this->categories->push($selected_category); + if (! $categoryExists($selected_category_id)) { + $appendCategory($selected_category); } } diff --git a/resources/assets/js/components/AkauntingSelectRemote.vue b/resources/assets/js/components/AkauntingSelectRemote.vue index 0be791ebd..56316d2bd 100644 --- a/resources/assets/js/components/AkauntingSelectRemote.vue +++ b/resources/assets/js/components/AkauntingSelectRemote.vue @@ -20,6 +20,7 @@ :collapse-tags="collapse" :remote-method="remoteMethod" :loading="loading" + :class="[{ 'with-color-prefix': selectedOptionColor, 'with-icon-prefix': icon }]" >
- +
@@ -141,12 +145,19 @@ - {{ addNew.new_text }} + {{ addNew.new_text }} + + + {{ selectedGroupLabel }} + - @@ -159,6 +170,7 @@ :collapse-tags="collapse" :remote-method="remoteMethod" :loading="loading" + :class="[{ 'with-color-prefix': selectedOptionColor, 'with-icon-prefix': icon }]" >

@@ -185,21 +197,25 @@

-
    - -
+ +
@@ -265,12 +281,19 @@
- - {{ addNew.new_text }} + {{ addNew.new_text }} + + + {{ selectedGroupLabel }} +