Compare commits

...

53 Commits

Author SHA1 Message Date
Cüneyt Şentürk 9d6cfba94f update composer.json and composer.lock files.. 2026-03-14 11:31:24 +00:00
Cüneyt Şentürk cabef574d7 update app.css file 2026-03-14 10:52:29 +00:00
Cüneyt Şentürk b81befc343 update package-lock.json file.. 2026-03-14 10:18:19 +00:00
Cüneyt Şentürk 729834a0ef
Merge pull request #3348 from CihanSenturk/add-category-tabs
Improve category tabs
2026-03-14 09:33:42 +00:00
Cüneyt Şentürk a776c7b5a9 fixed bill test.. 2026-03-13 21:57:28 +00:00
Cihan Şentürk ce25e05b64 updated code visibility and streamline category type handling 2026-03-14 00:56:18 +03:00
Cihan Şentürk 31df25305d fix typo 2026-03-14 00:54:31 +03:00
Cihan Şentürk 09b4e1641e updated contact type determination in method 2026-03-14 00:54:16 +03:00
Cihan Şentürk 3ad75c4d6b updated category report listeners 2026-03-14 00:52:08 +03:00
Cihan Şentürk 5772eca363 wip 2026-03-14 00:50:44 +03:00
Cihan Şentürk c82b4883a7 added category types trait 2026-03-14 00:50:16 +03:00
Cüneyt Şentürk aba76b12c8 Added contact update for document status.. 2026-03-12 02:53:44 +00:00
Cüneyt Şentürk 311054bad7 Fixed document, transaction and category list page pinned tab search issue.. 2026-03-12 01:47:58 +00:00
Cüneyt Şentürk 9ddab69242
Merge pull request #3347 from CihanSenturk/add-category-tabs
Added Category Tabs
2026-03-12 01:35:55 +00:00
Cihan Şentürk 38677e65c2 wip 2026-03-12 00:31:30 +03:00
Cihan Şentürk 91f1956dc9 updated add new category modal 2026-03-12 00:30:50 +03:00
Cihan Şentürk 1789662440 updated category code field dynamic show 2026-03-11 23:04:45 +03:00
Cihan Şentürk 6f50a4e685 refactored category validation and removed unused module trait 2026-03-10 22:15:51 +03:00
Cihan Şentürk e6c67ef504 added balance component 2026-03-09 22:57:29 +03:00
Cihan Şentürk f9c57bd402 added early return for searchable name in getSearchStringValue method 2026-03-09 22:46:17 +03:00
Cihan Şentürk 3c033af0d6 added styling for select group title in app.css 2026-03-09 22:46:05 +03:00
Cihan Şentürk 3a94319805 added code and description fields to category import and export models 2026-03-09 22:45:01 +03:00
Cihan Şentürk 09574e0335 added code and description fields to category resource 2026-03-09 22:44:35 +03:00
Cihan Şentürk 804cb8e568 added dynamic validation for category code based on module status 2026-03-09 22:44:15 +03:00
Cihan Şentürk 6df965c930 Improved category types 2026-03-09 22:43:05 +03:00
Cihan Şentürk 2c4d52b15e added category new fields(code, description) 2026-03-09 22:35:54 +03:00
Cihan Şentürk 2c12e7524c added category tabs 2026-03-09 22:33:28 +03:00
Cüneyt Şentürk d85c1b51bb revert recurring Transactions.. 2026-03-03 03:04:10 +00:00
Cüneyt Şentürk 952626f52a recurring paid date format.. 2026-03-03 02:49:49 +00:00
Cüneyt Şentürk 939d2b726a Fixed recurring update issue.. 2026-03-02 21:21:52 +00:00
Cüneyt Şentürk 8359383ba9
Merge pull request #3337 from e1um/fix-transaction-contact-sync
fix: sync transaction contact_id when document contact changes
2026-02-28 10:16:21 +00:00
Cüneyt Şentürk 9aed0937ab Enhancement unauthenticated user url intended 2026-02-26 17:49:00 +00:00
Akaunting afb52a52f8
sentry logs 2026-02-16 21:46:03 +00:00
Akaunting 9da40446e6
widget data 2026-02-07 18:55:11 +00:00
Cihan Şentürk a44d9d156b
Merge pull request #3342 from CihanSenturk/fix-date-filter-financial-start-date-issue
Fixed date filter financial start date issue
2026-02-05 20:47:46 +03:00
Cihan Şentürk 2b098e5e51
fixed date filter financial start date issue 2026-02-05 20:32:32 +03:00
Cüneyt Şentürk 072e070d09 Datetime financial year support custom date range.. 2026-02-02 22:40:27 +00:00
Cüneyt Şentürk 752e670a06 Merge branch 'master' of github.com:akaunting/akaunting 2026-01-22 02:41:20 +00:00
Cüneyt Şentürk 24f0dbb961 added setting new form type.. 2026-01-22 02:41:09 +00:00
Cüneyt Şentürk b5d096c615 update package-lock.json file.. 2026-01-21 22:28:01 +00:00
Cüneyt Şentürk 12ec20f8ac update composer.lock file.. 2026-01-21 22:26:50 +00:00
e1um e44fd37bad
fix: sync transaction contact_id when document contact changes
When a document (invoice/bill) is updated to change the contact (vendor/customer),
any existing transactions linked to that document now have their contact_id updated
to match. This fixes an issue where reports filtering by contact would not include
transactions from documents that were reassigned to a different contact.

Fixes #3336
2026-01-21 12:44:51 -05:00
Cüneyt Şentürk 4829c7728d Contact show page layz load issue fixed.. 2026-01-18 01:04:46 +00:00
Cihan Şentürk fb9f7eaaa3
improved search string filter logic 2026-01-16 21:14:56 +03:00
Cüneyt Şentürk 7c130a4ece update package-lock.json file.. 2026-01-11 00:56:02 +00:00
Cüneyt Şentürk 7f1fc69330 update composer.lock file.. 2026-01-11 00:36:11 +00:00
Cüneyt Şentürk 695f8cf6ae Fixed document currency conversation issue.. 2026-01-02 02:37:12 +00:00
Cüneyt Şentürk 4a0419774b
Merge pull request #3333 from cuneytsenturk/master
Fixed category, tax and contact form group search and add new issues..
2026-01-01 20:51:18 +00:00
Cüneyt Şentürk c5d61d8f4e Fixed category, tax and contact form group search and add new issues.. 2026-01-01 16:41:32 +00:00
Cüneyt Şentürk d4f2b5b4da Fixed document transaction updating upload file.. 2025-12-25 13:53:32 +00:00
Cihan Şentürk 6c67e99a1b
version update 3.1.20 to 3.1.21 2025-12-13 22:11:04 +03:00
Cihan Şentürk ef56e7e91e
update composer.lock file.. 2025-12-13 22:09:57 +03:00
Cihan Şentürk a7917f42b3
fixed cache module version issue 2025-12-13 22:08:24 +03:00
74 changed files with 10727 additions and 9577 deletions

View File

@ -16,6 +16,9 @@ use Illuminate\Pagination\Paginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller as BaseController;
/**
* @property string $type
*/
abstract class Controller extends BaseController
{
use AuthorizesRequests, Jobs, Permissions, Relationships, SearchString, ValidatesRequests;
@ -114,6 +117,19 @@ abstract class Controller extends BaseController
}
}
if (! request()->has('list_records') && request()->has('search')) {
$status = $this->getSearchStringValue('status');
if (empty($status)) {
$tab_pins = setting('favorites.tab.' . user()->id, []);
$tab_pins = ! empty($tab_pins) ? json_decode($tab_pins, true) : [];
if (! empty($tab_pins) && (($tab_pins[$this->type] ?? null) === 'all')) {
request()->offsetSet('list_records', 'all');
}
}
}
if (request()->get('list_records') == 'all') {
return;
}
@ -152,5 +168,105 @@ abstract class Controller extends BaseController
}
}
}
if (! request()->has('list_records') && request()->has('search')) {
$type = $this->getSearchStringValue('type');
if (empty($type)) {
$tab_pins = setting('favorites.tab.' . user()->id, []);
$tab_pins = ! empty($tab_pins) ? json_decode($tab_pins, true) : [];
if (! empty($tab_pins) && (($tab_pins['transactions'] ?? null) === 'all')) {
request()->offsetSet('list_records', 'all');
}
}
}
if (request()->get('list_records') == 'all') {
return;
}
$type = $this->getSearchStringValue('type');
if (empty($type)) {
$search = config('type.transaction.transactions.route.params.income.search');
request()->offsetSet('search', $search);
request()->offsetSet('programmatic', '1');
} else {
$income = str_replace('type:', 'income', config('type.transaction.transactions.route.params.income.search'));
$expense = str_replace('type:', 'expense', config('type.transaction.transactions.route.params.expense.search'));
if (($type == $income) || ($type == $expense)) {
return;
}
request()->offsetSet('list_records', 'all');
}
}
public function setActiveTabForCategories(): void
{
if (! request()->has('list_records') && ! request()->has('search')) {
$tab_pins = setting('favorites.tab.' . user()->id, []);
$tab_pins = ! empty($tab_pins) ? json_decode($tab_pins, true) : [];
if (! empty($tab_pins) && ! empty($tab_pins['categories'])) {
$tab = $tab_pins['categories'];
if (! empty($tab)) {
request()->offsetSet('list_records', $tab);
request()->offsetSet('programmatic', '1');
}
}
}
if (! request()->has('list_records') && request()->has('search')) {
$type = $this->getSearchStringValue('type');
if (empty($type)) {
$tab_pins = setting('favorites.tab.' . user()->id, []);
$tab_pins = ! empty($tab_pins) ? json_decode($tab_pins, true) : [];
if (! empty($tab_pins) && (($tab_pins['categories'] ?? null) === 'all')) {
request()->offsetSet('list_records', 'all');
}
}
}
if (request()->get('list_records') == 'all') {
return;
}
$types = $this->getSearchStringValue('type');
if (!empty($types)) {
$types = is_string($types) ? explode(',', $types) : $types;
$tab = config('type.category.' . $types[0] . '.group') ? config('type.category.' . $types[0] . '.group') : 'all';
if (!empty($types) && count($types) > 0) {
request()->offsetSet('list_records', $tab);
$currentSearch = request('search', '');
$searchParts = array_filter(explode(' ', $currentSearch), function($part) {
return !empty(trim($part)) && !str_starts_with(trim($part), 'type:');
});
$searchParts[] = 'type:' . implode(',', $types);
request()->offsetSet('search', implode(' ', $searchParts));
request()->offsetSet('programmatic', '1');
return;
}
}
if (empty($tab)) {
request()->offsetSet('list_records', 'all');
request()->offsetSet('programmatic', '1');
}
}
}

View File

@ -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)

View File

@ -28,6 +28,8 @@ abstract class Widget
'header' => 'components.widgets.header',
];
public array $data = [];
public function __construct($model = null)
{
$this->model = $model;

View File

@ -53,6 +53,7 @@ class Categories extends BulkAction
public function edit($request)
{
$selected = $this->getSelectedInput($request);
$types = $this->getCategoryTypes();
return $this->response('bulk-actions.settings.categories.edit', compact('selected', 'types'));

View File

@ -116,9 +116,9 @@ class Handler extends ExceptionHandler
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
// Store the current url in the session
if ($request->url() !== config('app.url')) {
session(['url.intended' => $request->url()]);
// Store the current url in the session (fullUrl includes query string)
if ($request->fullUrl() !== config('app.url')) {
session(['url.intended' => $request->fullUrl()]);
}
return $request->expectsJson()

View File

@ -25,9 +25,11 @@ class Categories extends Export
public function fields(): array
{
return [
'code',
'name',
'type',
'color',
'description',
'parent_name',
'enabled',
];

View File

@ -17,6 +17,7 @@ use App\Models\Setting\Tax;
use App\Traits\Currencies;
use App\Traits\DateTime;
use App\Traits\Transactions as TransactionsTrait;
use App\Utilities\Date;
class RecurringTransactions extends Controller
{
@ -99,7 +100,9 @@ class RecurringTransactions extends Controller
*/
public function store(Request $request)
{
$response = $this->ajaxDispatch(new CreateTransaction($request->merge(['paid_at' => $request->get('recurring_started_at')])));
$request->merge(['paid_at' => $request->get('recurring_started_at')]);
$response = $this->ajaxDispatch(new CreateTransaction($request));
if ($response['success']) {
$response['redirect'] = route('recurring-transactions.show', $response['data']->id);
@ -203,7 +206,9 @@ class RecurringTransactions extends Controller
*/
public function update(Transaction $recurring_transaction, Request $request)
{
$response = $this->ajaxDispatch(new UpdateTransaction($recurring_transaction, $request->merge(['paid_at' => $request->get('recurring_started_at')])));
$request->merge(['paid_at' => $request->get('recurring_started_at')]);
$response = $this->ajaxDispatch(new UpdateTransaction($recurring_transaction, $request));
if ($response['success']) {
$response['redirect'] = route('recurring-transactions.show', $recurring_transaction->id);

View File

@ -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,29 @@ class Categories extends Controller
*/
public function create(IRequest $request)
{
$type = $request->get('type', 'item');
$type = $request->get('type', Category::ITEM_TYPE);
$category_types = $this->getTypeCategoryTypes($type);
$hide_code_types = $this->hideCodeCategoryTypes($category_types);
$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($category_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();
$type_group = count($category_types) > 1 ? true : false;
$types = $this->getCategoryTypes(group: true, types: $category_types);
$html = view('modals.categories.create', compact('type', 'types', 'categories', 'type_group', 'hide_code_types'))->render();
return response()->json([
'success' => true,
@ -61,7 +75,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));

View File

@ -13,6 +13,7 @@ use App\Jobs\Document\UpdateDocument;
use App\Models\Common\Recurring;
use App\Models\Document\Document;
use App\Traits\Documents;
use App\Utilities\Date;
class RecurringBills extends Controller
{
@ -80,7 +81,11 @@ class RecurringBills extends Controller
*/
public function store(Request $request)
{
$response = $this->ajaxDispatch(new CreateDocument($request->merge(['issued_at' => $request->get('recurring_started_at')])));
$issue_at = Date::parse($request->get('recurring_started_at'))->format('Y-m-d');
$request->merge(['issued_at' => $issue_at]);
$response = $this->ajaxDispatch(new CreateDocument($request));
if ($response['success']) {
$response['redirect'] = route('recurring-bills.show', $response['data']->id);
@ -163,7 +168,11 @@ class RecurringBills extends Controller
*/
public function update(Document $recurring_bill, Request $request)
{
$response = $this->ajaxDispatch(new UpdateDocument($recurring_bill, $request->merge(['issued_at' => $request->get('recurring_started_at')])));
$issue_at = Date::parse($request->get('recurring_started_at'))->format('Y-m-d');
$request->merge(['issued_at' => $issue_at]);
$response = $this->ajaxDispatch(new UpdateDocument($recurring_bill, $request));
if ($response['success']) {
$response['redirect'] = route('recurring-bills.show', $response['data']->id);

View File

@ -13,6 +13,7 @@ use App\Jobs\Document\UpdateDocument;
use App\Models\Common\Recurring;
use App\Models\Document\Document;
use App\Traits\Documents;
use App\Utilities\Date;
class RecurringInvoices extends Controller
{
@ -80,7 +81,9 @@ class RecurringInvoices extends Controller
*/
public function store(Request $request)
{
$request->merge(['issued_at' => $request->get('recurring_started_at')]);
$issue_at = Date::parse($request->get('recurring_started_at'))->format('Y-m-d');
$request->merge(['issued_at' => $issue_at]);
$response = $this->ajaxDispatch(new CreateDocument($request));
@ -165,7 +168,9 @@ class RecurringInvoices extends Controller
*/
public function update(Document $recurring_invoice, Request $request)
{
$request->merge(['issued_at' => $request->get('recurring_started_at')]);
$issue_at = Date::parse($request->get('recurring_started_at'))->format('Y-m-d');
$request->merge(['issued_at' => $issue_at]);
$response = $this->ajaxDispatch(new UpdateDocument($recurring_invoice, $request));

View File

@ -24,17 +24,44 @@ class Categories extends Controller
*/
public function index()
{
$this->setActiveTabForCategories();
$query = Category::with('sub_categories');
if (request()->has('search')) {
$query->withSubcategory();
if (search_string_value('searchable')) {
$query->withSubCategory();
}
$types = $this->getCategoryTypes();
$categories = $query->type(array_keys($types))->collect();
if (request()->get('list_records') == 'all') {
$query->type(array_keys($types));
}
return $this->response('settings.categories.index', compact('categories', 'types'));
$categories = $query->collect();
$tabs = $this->getCategoryTabs();
$tab = request()->get('list_records');
$tab_active = ! empty($tab) ? 'categories-' . $tab : 'categories-all';
$hide_code_column = true;
$search_string_type = search_string_value('type');
$selected_types = ! empty($search_string_type) ? explode(',', $search_string_type) : array_keys($types);
foreach (config('type.category', []) as $type => $config) {
if (! in_array($type, $selected_types)) {
continue;
}
if (empty($config['hide']) || !in_array('code', $config['hide'])) {
$hide_code_column = false;
break;
}
}
return $this->response('settings.categories.index', compact('categories', 'types', 'tabs', 'tab_active', 'hide_code_column'));
}
/**
@ -54,14 +81,17 @@ class Categories extends Controller
*/
public function create()
{
$types = $this->getCategoryTypes();
$categories = [];
foreach (config('type.category') as $type => $config) {
$categories[$type] = [];
}
$type_group = $this->isGroupCategoryType();
$hide_code_types = $this->hideCodeCategoryTypes(array_keys($categories));
$types = $this->getCategoryTypes(group: $type_group);
Category::enabled()->orderBy('name')->get()->each(function ($category) use (&$categories) {
$categories[$category->type][] = [
'id' => $category->id,
@ -70,7 +100,7 @@ class Categories extends Controller
];
});
return view('settings.categories.create', compact('types', 'categories'));
return view('settings.categories.create', compact('types', 'categories', 'type_group', 'hide_code_types'));
}
/**
@ -134,8 +164,6 @@ class Categories extends Controller
*/
public function edit(Category $category)
{
$types = $this->getCategoryTypes();
$type_disabled = (Category::where('type', $category->type)->count() == 1) ?: false;
$edited_category_id = $category->id;
@ -146,6 +174,11 @@ class Categories extends Controller
$categories[$type] = [];
}
$type_group = $this->isGroupCategoryType();
$hide_code_types = $this->hideCodeCategoryTypes(array_keys($categories));
$types = $this->getCategoryTypes(group: $type_group);
$skip_categories = [];
$skip_categories[] = $edited_category_id;
@ -175,7 +208,7 @@ class Categories extends Controller
$parent_categories = $categories[$category->type] ?? [];
return view('settings.categories.edit', compact('category', 'types', 'type_disabled', 'categories', 'parent_categories'));
return view('settings.categories.edit', compact('category', 'types', 'type_disabled', 'categories', 'parent_categories', 'type_group', 'hide_code_types'));
}
/**

View File

@ -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',
));
}
}

View File

@ -15,8 +15,14 @@ class Category extends FormRequest
{
$types = collect(config('type.category'))->keys();
$type = $this->request->get('type');
$config = config('type.category.' . $type, []);
$code_hidden = !empty($config['hide']) && in_array('code', $config['hide']);
$code = $code_hidden ? 'nullable|string' : 'required|string';
return [
'name' => 'required|string',
'code' => $code,
'type' => 'required|string|in:' . $types->implode(','),
'color' => 'required|string|colour',
];

View File

@ -17,9 +17,11 @@ class Category extends JsonResource
return [
'id' => $this->id,
'company_id' => $this->company_id,
'code' => $this->code,
'name' => $this->name,
'type' => $this->type,
'color' => $this->color,
'description' => $this->description,
'enabled' => $this->enabled,
'parent_id' => $this->parent_id,
'created_from' => $this->created_from,

View File

@ -5,6 +5,7 @@ namespace App\Imports\Purchases\Bills\Sheets;
use App\Abstracts\Import;
use App\Http\Requests\Banking\Transaction as Request;
use App\Models\Banking\Transaction as Model;
use App\Models\Setting\Category;
class BillTransactions extends Import
{
@ -36,9 +37,9 @@ class BillTransactions extends Import
$row = parent::map($row);
$row['type'] = 'expense';
$row['type'] = Model::EXPENSE_TYPE;
$row['account_id'] = $this->getAccountId($row);
$row['category_id'] = $this->getCategoryId($row, 'expense');
$row['category_id'] = $this->getCategoryId($row, Category::EXPENSE_TYPE);
$row['contact_id'] = $this->getContactId($row, 'vendor');
$row['currency_code'] = $this->getCurrencyCode($row);
$row['document_id'] = $this->getDocumentId($row);

View File

@ -5,6 +5,7 @@ namespace App\Imports\Sales\Invoices\Sheets;
use App\Abstracts\Import;
use App\Http\Requests\Banking\Transaction as Request;
use App\Models\Banking\Transaction as Model;
use App\Models\Setting\Category;
class InvoiceTransactions extends Import
{
@ -37,10 +38,10 @@ class InvoiceTransactions extends Import
$row = parent::map($row);
$row['type'] = 'income';
$row['type'] = Model::INCOME_TYPE;
$row['currency_code'] = $this->getCurrencyCode($row);
$row['account_id'] = $this->getAccountId($row);
$row['category_id'] = $this->getCategoryId($row, 'income');
$row['category_id'] = $this->getCategoryId($row, Category::INCOME_TYPE);
$row['contact_id'] = $this->getContactId($row, 'customer');
$row['document_id'] = $this->getDocumentId($row);
$row['number'] = $row['transaction_number'];

View File

@ -15,6 +15,8 @@ class Categories extends Import
public $columns = [
'name',
'type',
'code',
'description',
];
public function model(array $row)

View File

@ -39,19 +39,6 @@ class UpdateBankingDocumentTransaction extends Job implements ShouldUpdate
\DB::transaction(function () {
$this->transaction = $this->dispatch(new UpdateTransaction($this->transaction, $this->request));
// Upload attachment
if ($this->request->file('attachment')) {
$this->deleteMediaModel($this->transaction, 'attachment', $this->request);
$media = $this->getMedia($this->request->file('attachment'), 'transactions');
$this->transaction->attachMedia($media, 'attachment');
} elseif ($this->request->isNotApi() && ! $this->request->file('attachment') && $this->transaction->attachment) {
$this->deleteMediaModel($this->transaction, 'attachment', $this->request);
} elseif ($this->request->isApi() && $this->request->has('remove_attachment') && $this->transaction->attachment) {
$this->deleteMediaModel($this->transaction, 'attachment', $this->request);
}
$this->model->save();
$this->createHistory();

View File

@ -18,6 +18,8 @@ class UpdateDocument extends Job implements ShouldUpdate
public function handle(): Document
{
$this->authorize();
if (empty($this->request['amount'])) {
$this->request['amount'] = 0;
}
@ -27,7 +29,10 @@ class UpdateDocument extends Job implements ShouldUpdate
event(new DocumentUpdating($this->model, $this->request));
\DB::transaction(function () {
// Track original contact_id to sync transactions if it changes
$originalContactId = $this->model->contact_id;
\DB::transaction(function () use ($originalContactId) {
// Upload attachment
if ($this->request->file('attachment')) {
$this->deleteMediaModel($this->model, 'attachment', $this->request);
@ -66,6 +71,13 @@ class UpdateDocument extends Job implements ShouldUpdate
$this->model->update($this->request->all());
// Sync transaction contact_id if document contact changed
if (isset($this->request['contact_id']) && $originalContactId != $this->request['contact_id']) {
$this->model->transactions()->update([
'contact_id' => $this->request['contact_id'],
]);
}
$this->model->updateRecurring($this->request->all());
});
@ -73,4 +85,23 @@ class UpdateDocument extends Job implements ShouldUpdate
return $this->model;
}
/**
* Determine if this action is applicable.
*/
public function authorize(): void
{
$lockedStatuses = ['sent', 'received', 'viewed', 'partial', 'paid', 'overdue', 'unpaid', 'cancelled'];
if (
isset($this->request['contact_id']) &&
(int) $this->request['contact_id'] !== (int) $this->model->contact_id &&
in_array($this->model->status, $lockedStatuses)
) {
$type = Str::plural($this->model->type);
$message = trans('messages.warning.contact_change', ['type' => trans_choice("general.$type", 1)]);
throw new \Exception($message);
}
}
}

View File

@ -26,8 +26,8 @@ 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['categories'] = $this->getExpenseCategories();
$event->class->filters['routes']['categories'] = ['categories.index', 'search=type:' . $this->getExpenseCategoryTypes('string') . ' enabled:1'];
$event->class->filters['multiple']['categories'] = true;
}

View File

@ -26,8 +26,8 @@ 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['categories'] = $this->getIncomeCategories();
$event->class->filters['routes']['categories'] = ['categories.index', 'search=type:' . $this->getIncomeCategoryTypes('string') . ' enabled:1'];
$event->class->filters['multiple']['categories'] = true;
}

View File

@ -27,8 +27,10 @@ class AddIncomeExpenseCategories extends Listener
return;
}
$event->class->filters['categories'] = $this->getIncomeExpenseCategories(true);
$event->class->filters['routes']['categories'] = ['categories.index', 'search=type:income,expense enabled:1'];
$types = array_merge($this->getIncomeCategoryTypes(), $this->getExpenseCategoryTypes());
$event->class->filters['categories'] = $this->getIncomeExpenseCategories();
$event->class->filters['routes']['categories'] = ['categories.index', 'search=type:' . implode(',', $types) . ' enabled:1'];
$event->class->filters['multiple']['categories'] = true;
}
@ -69,7 +71,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 +85,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 +104,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;
}

View File

@ -28,8 +28,8 @@ class AddSearchString extends Listener
return;
}
$old = old();
$request = request()->all();
$old = old() ?? [];
$request = request()->all() ?? [];
if ($old || $request) {
$input = request('search');

View File

@ -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));

View File

@ -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,91 @@ class Category extends Model
/**
* Get the display name of the category.
*/
public function getDisplayNameAttribute()
public function getDisplayNameAttribute(): string
{
return $this->name . ' (' . ucfirst($this->type) . ')';
$hideCode = $this->hideCodeCategoryType($this->type);
$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 +414,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.
*

View File

@ -8,10 +8,158 @@ use Illuminate\Support\Str;
trait Categories
{
public function getCategoryTypes(bool $translate = true): array
public function isIncomeCategory(): bool
{
$types = [];
$configs = config('type.category');
$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 getTypeCategoryTypes(string $type, string $return = 'array'): string|array
{
switch ($type) {
case Category::INCOME_TYPE:
$types = $this->getIncomeCategoryTypes($return);
break;
case Category::EXPENSE_TYPE:
$types = $this->getExpenseCategoryTypes($return);
break;
case Category::ITEM_TYPE:
$types = $this->getItemCategoryTypes($return);
break;
case Category::OTHER_TYPE:
$types = $this->getOtherCategoryTypes($return);
break;
default:
$types = ($return == 'array') ? [$type] : $type;
}
return $types;
}
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 isGroupCategoryType(): bool
{
$setting_category_types = setting('category.type');
foreach ($setting_category_types as $type => $category) {
$categories = explode(',', $category);
if (count($categories) > 1) {
return true;
}
}
return false;
}
public function hideCodeCategoryType(string $type, bool $default = true): bool
{
return $this->hideCodeCategoryTypes($type)[$type] ?? $default;
}
public function hideCodeCategoryTypes(string|array $types): array
{
$types = is_string($types) ? explode(',', $types) : $types;
$type_codes = [];
foreach ($types as $type) {
$config_type = config('type.category.' . $type, []);
$type_codes[$type] = ! empty($config_type['hide']) && in_array('code', $config_type['hide']) ? true : false;
}
return $type_codes;
}
public function getCategoryTypes(bool $translate = true, bool $group = false, array $types = []): array
{
$category_types = [];
$configs = empty($types) ? config('type.category') : array_intersect_key(config('type.category'), array_flip($types));
foreach ($configs as $type => $attr) {
$plural_type = Str::plural($type);
@ -22,10 +170,47 @@ trait Categories
$name = $attr['alias'] . '::' . $name;
}
$types[$type] = $translate ? trans_choice($name, 1) : $name;
if ($group) {
$group_key = $attr['group'] ?? $type;
$category_types[$group_key][$type] = $translate ? trans_choice($name, 1) : $name;
} else {
$category_types[$type] = $translate ? trans_choice($name, 1) : $name;
}
}
return $types;
return $category_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
@ -36,7 +221,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 +247,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;
}
}

View File

@ -53,7 +53,7 @@ trait DateTime
return [$start, $end];
}
public function getFinancialStart($year = null): Date
public function getFinancialStart($year = null, $date = null): Date
{
$start_of_year = Date::now()->startOfYear();
@ -64,9 +64,8 @@ trait DateTime
$year = $year ?? $start_of_year->year;
$financial_start = Date::create($year, $month, $day);
if ((setting('localisation.financial_denote') == 'ends') && ($financial_start->dayOfYear != 1) ||
$financial_start->greaterThan(Date::now())
if ((setting('localisation.financial_denote') == 'ends') && ($financial_start->dayOfYear != 1) ||
$financial_start->greaterThan($date ?? Date::now())
) {
$financial_start->subYear();
}
@ -74,10 +73,10 @@ trait DateTime
return $financial_start;
}
public function getFinancialWeek($year = null): CarbonPeriod
public function getFinancialWeek($year = null, $date = null): CarbonPeriod
{
$today = Date::today();
$financial_weeks = $this->getFinancialWeeks($year);
$financial_weeks = $this->getFinancialWeeks($year, $date);
foreach ($financial_weeks as $week) {
if ($today->lessThan($week->getStartDate()) || $today->greaterThan($week->getEndDate())) {
@ -96,10 +95,10 @@ trait DateTime
return $this_week;
}
public function getFinancialMonth($year = null): CarbonPeriod
public function getFinancialMonth($year = null, $date = null): CarbonPeriod
{
$today = Date::today();
$financial_months = $this->getFinancialMonths($year);
$financial_months = $this->getFinancialMonths($year, $date);
foreach ($financial_months as $month) {
if ($today->lessThan($month->getStartDate()) || $today->greaterThan($month->getEndDate())) {
@ -118,10 +117,10 @@ trait DateTime
return $this_month;
}
public function getFinancialQuarter($year = null): CarbonPeriod
public function getFinancialQuarter($year = null, $date = null): CarbonPeriod
{
$today = Date::today();
$financial_quarters = $this->getFinancialQuarters($year);
$financial_quarters = $this->getFinancialQuarters($year, $date);
foreach ($financial_quarters as $quarter) {
if ($today->lessThan($quarter->getStartDate()) || $today->greaterThan($quarter->getEndDate())) {
@ -140,23 +139,23 @@ trait DateTime
return $this_quarter;
}
public function getFinancialYear($year = null): CarbonPeriod
public function getFinancialYear($year = null, $date = null): CarbonPeriod
{
$financial_start = $this->getFinancialStart($year);
$financial_start = $this->getFinancialStart($year, $date);
return CarbonPeriod::create($financial_start, $financial_start->copy()->addYear()->subDay()->endOfDay());
}
public function getFinancialWeeks($year = null): array
public function getFinancialWeeks($year = null, $date = null): array
{
$weeks = [];
$start = $this->getFinancialStart($year);
$start = $this->getFinancialStart($year, $date);
$w = 52;
if (request()->filled('start_date') && request()->filled('end_date')) {
$w = Date::parse(request('start_date'))->diffInWeeks(Date::parse(request('end_date'))) + 1;
$w = Date::parse($start->copy())->diffInWeeks(Date::parse(request('end_date'))) + 1;
}
for ($i = 0; $i < $w; $i++) {
@ -166,16 +165,16 @@ trait DateTime
return $weeks;
}
public function getFinancialMonths($year = null): array
public function getFinancialMonths($year = null, $date = null): array
{
$months = [];
$start = $this->getFinancialStart($year);
$start = $this->getFinancialStart($year, $date);
$m = 12;
if (request()->filled('start_date') && request()->filled('end_date')) {
$m = Date::parse(request('start_date'))->diffInMonths(Date::parse(request('end_date'))) + 1;
$m = Date::parse($start->copy())->diffInMonths(Date::parse(request('end_date'))) + 1;
}
for ($i = 0; $i < $m; $i++) {
@ -185,16 +184,21 @@ trait DateTime
return $months;
}
public function getFinancialQuarters($year = null): array
public function getFinancialQuarters($year = null, $date = null): array
{
$quarters = [];
$start = $this->getFinancialStart($year);
$start = $this->getFinancialStart($year, $date);
$q = 4;
/*
Previously, diffInQuarters was calculated from start_date, which caused errors
when the financial start value differed from the default value.
Therefore, this change has been made. Ticket: #8106
*/
if (request()->filled('start_date') && request()->filled('end_date')) {
$q = Date::parse(request('start_date'))->diffInQuarters(Date::parse(request('end_date'))) + 1;
$q = Date::parse($start->copy())->diffInQuarters(Date::parse(request('end_date'))) + 1;
}
for ($i = 0; $i < $q; $i++) {
@ -285,7 +289,7 @@ trait DateTime
switch ($period) {
case 'yearly':
$financial_year = $this->getFinancialYear($year);
$financial_year = $this->getFinancialYear($year, $date);
if ($date->greaterThanOrEqualTo($financial_year->getStartDate()) && $date->lessThanOrEqualTo($financial_year->getEndDate())) {
if (setting('localisation.financial_denote') == 'begins') {
@ -297,7 +301,7 @@ trait DateTime
break;
case 'quarterly':
$quarters = $this->getFinancialQuarters($year);
$quarters = $this->getFinancialQuarters($year, $date);
foreach ($quarters as $quarter) {
if ($date->lessThan($quarter->getStartDate()) || $date->greaterThan($quarter->getEndDate())) {
@ -314,7 +318,7 @@ trait DateTime
break;
case 'weekly':
$weeks = $this->getFinancialWeeks($year);
$weeks = $this->getFinancialWeeks($year, $date);
foreach ($weeks as $week) {
if ($date->lessThan($week->getStartDate()) || $date->greaterThan($week->getEndDate())) {
@ -366,4 +370,4 @@ trait DateTime
{
return $this->scopeDateFilter($query, $field);
}
}
}

View File

@ -57,7 +57,7 @@ trait Import
event(new ImportViewCreated($import));
return [
$import->view,
$import->view,
$import->data
];
}
@ -85,7 +85,7 @@ trait Import
{
$id = isset($row['category_id']) ? $row['category_id'] : null;
$type = !empty($type) ? $type : (!empty($row['type']) ? $row['type'] : 'income');
$type = !empty($type) ? $type : (!empty($row['type']) ? $row['type'] : Category::INCOME_TYPE);
if (empty($id) && !empty($row['category_name'])) {
$id = $this->getCategoryIdFromName($row, $type);
@ -96,14 +96,14 @@ trait Import
public function getCategoryType($type)
{
return array_key_exists($type, config('type.category')) ? $type : 'other';
return array_key_exists($type, config('type.category')) ? $type : Category::OTHER_TYPE;
}
public function getContactId($row, $type = null)
{
$id = isset($row['contact_id']) ? $row['contact_id'] : null;
$type = !empty($type) ? $type : (!empty($row['type']) ? (($row['type'] == 'income') ? 'customer' : 'vendor') : 'customer');
$type = !empty($type) ? $type : (!empty($row['type']) ? (($row['type'] == Transaction::INCOME_TYPE) ? Contact::CUSTOMER_TYPE : Contact::VENDOR_TYPE) : Contact::CUSTOMER_TYPE);
if (empty($row['contact_id']) && !empty($row['contact_email'])) {
$id = $this->getContactIdFromEmail($row, $type);
@ -180,7 +180,7 @@ trait Import
}
if (empty($id) && !empty($row['invoice_bill_number'])) {
if ($row['type'] == 'income') {
if ($row['type'] == Transaction::INCOME_TYPE) {
$id = Document::invoice()->number($row['invoice_bill_number'])->pluck('id')->first();
} else {
$id = Document::bill()->number($row['invoice_bill_number'])->pluck('id')->first();

View File

@ -457,6 +457,10 @@ trait Modules
$limit->action_status = false;
$limit->view_status = false;
$limit->message = $module_limit->message;
// Clear cache to reflect changes
Cache::forget('updates');
Cache::forget('versions');
}
}
@ -483,12 +487,16 @@ trait Modules
return $limit;
}
$module_companies = Module::allCompanies()->enabled()->alias($alias)->get();
if (! $module_companies->count()) {
return $limit;
}
$limit->action_status = false;
$limit->view_status = false;
$limit->message = "Not able to app $alias.";
$module_companies = Module::allCompanies()->alias($alias)->get();
foreach ($module_companies as $module) {
switch ($version->subscription->action_status) {
case 'disabled':

View File

@ -23,6 +23,10 @@ trait SearchString
foreach ($columns as $column) {
$variable = preg_split('/:|>?<?=/', $column);
if ($name == 'searchable' && count($variable) == 1 && preg_match('/^".*"$/', $variable[0])) {
return trim($variable[0], '"');
}
if (empty($variable[0]) || ($variable[0] != $name) || empty($variable[1])) {
continue;
}

View File

@ -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;
}

View File

@ -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();

View File

@ -36,8 +36,10 @@ class Content extends Component
// Handle documents
$docs = $this->contact->isCustomer() ? 'invoices' : 'bills';
// Eager load transactions with currency to prevent N+1 queries when calling getAmountConvertedToDefault()
$this->documents = $this->contact->$docs()->with(['transactions', 'transactions.currency'])->get();
// Eager load documents with necessary relations to prevent lazy loading
$this->documents = $this->contact->$docs()
->with(['transactions', 'transactions.currency', 'contact', 'last_history', 'items', 'totals'])
->get();
$this->counts['documents'] = $this->documents->count();
@ -63,8 +65,10 @@ class Content extends Component
}
}
// Handle payments - eager load currency to prevent N+1 queries
$this->transactions = $this->contact->transactions()->with(['account', 'category', 'currency'])->get();
// Handle payments - eager load necessary relations to prevent lazy loading
$this->transactions = $this->contact->transactions()
->with(['account', 'category', 'currency', 'contact', 'document', 'recurring'])
->get();
$this->counts['transactions'] = $this->transactions->count();

View File

@ -35,6 +35,16 @@ class Information extends Component
) {
$this->document = $document;
$this->hideShow = $hideShow;
// Load relations if not loaded to prevent lazy loading
$relations = ['contact', 'last_history', 'items', 'totals'];
foreach ($relations as $relation) {
if (! $document->relationLoaded($relation)) {
$document->load($relation);
}
}
$this->showRoute = $this->getShowRoute($document->contact->type, $showRoute);
$this->showDocumentRoute = $this->getShowRoute($document->type, $showDocumentRoute);
$this->placement = (! empty($placement)) ? $placement : 'left';

View File

@ -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,119 @@ 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']);
$types = $this->getTypeCategoryTypes($this->type);
$types_string = implode(',', $types);
$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:' . $types_string . ' enabled:1']);
$typeLabels = $this->getCategoryTypes(types: $types);
$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 +167,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);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\View\Components\Index;
use App\Abstracts\View\Component;
class Balance extends Component
{
/**
* The balance amount.
*
* @var float
*/
public $amount;
/**
* The text color class.
*
* @var string
*/
public $textColor;
/**
* Create a new component instance.
*
* @return void
*/
public function __construct($amount = 0) {
$this->amount = $this->getAmount($amount);
$this->textColor = $this->getTextColor($amount);
}
/**
* Get the view / contents that represent the component.
*
* @return \Illuminate\Contracts\View\View|string
*/
public function render()
{
return view('components.index.balance');
}
/**
* Formats the amount according to the location context.
*
* @param float $amount
* @return string
*/
protected function getAmount($amount)
{
return money($amount, setting('default.currency'), true);
}
/**
* Gets the class of color considering given amount.
*
* @param float $amount
* @return string|null
*/
protected function getTextColor($amount)
{
switch ($amount) {
case $amount > 0:
return 'text-green';
case $amount < 0:
return 'text-red';
default:
return '';
}
}
}

View File

@ -14,6 +14,13 @@ class AccountBalance extends Widget
public $report_class = 'App\Reports\IncomeExpense';
public function show()
{
$this->setData();
return $this->view('widgets.account_balance', $this->data);
}
public function setData(): void
{
$accounts = Account::with('income_transactions', 'expense_transactions')->enabled()->take(5)->get()->map(function($account) {
$account->balance_formatted = money($account->balance, $account->currency_code);
@ -21,8 +28,8 @@ class AccountBalance extends Widget
return $account;
})->all();
return $this->view('widgets.account_balance', [
$this->data = [
'accounts' => $accounts,
]);
];
}
}

View File

@ -31,6 +31,13 @@ class CashFlow extends Widget
public $period;
public function show()
{
$this->setData();
return $this->view('widgets.cash_flow', $this->data);
}
public function setData(): void
{
$this->setFilter();
@ -66,10 +73,10 @@ class CashFlow extends Widget
'profit_for_humans' => $profit_amount->formatForHumans(),
];
return $this->view('widgets.cash_flow', [
$this->data = [
'chart' => $chart,
'totals' => $totals,
]);
];
}
public function setFilter(): void

View File

@ -11,11 +11,17 @@ class Currencies extends Widget
public function show()
{
$currencies = Currency::enabled()->take(5)->get();
$this->setData();
return $this->view('widgets.currencies', [
'currencies' => $currencies,
]);
return $this->view('widgets.currencies', $this->data);
}
}
public function setData(): void
{
$currencies = Currency::enabled()->take(5)->get();
$this->data = [
'currencies' => $currencies,
];
}
}

View File

@ -14,6 +14,13 @@ class ExpensesByCategory extends Widget
public $report_class = 'App\Reports\ExpenseSummary';
public function show()
{
$this->setData();
return $this->view('widgets.donut_chart', $this->data);
}
public function setData(): void
{
Category::with('expense_transactions')->expense()->withSubCategory()->getWithoutChildren()->each(function ($category) {
$amount = 0;
@ -30,8 +37,8 @@ class ExpensesByCategory extends Widget
$chart->options['legend']['width'] = 160;
$chart->options['legend']['position'] = 'right';
return $this->view('widgets.donut_chart', [
$this->data = [
'chart' => $chart,
]);
];
}
}

View File

@ -15,6 +15,13 @@ class Payables extends Widget
public $report_class = 'Modules\AgedReceivablesPayables\Reports\AgedPayables';
public function show()
{
$this->setData();
return $this->view('widgets.receivables_payables', $this->data);
}
public function setData(): void
{
$open = $overdue = 0;
@ -68,12 +75,12 @@ class Payables extends Widget
$grand_total_text = trans('widgets.total_unpaid_bills');
return $this->view('widgets.receivables_payables', [
$this->data = [
'totals' => $totals,
'has_progress' => $has_progress,
'progress' => $progress,
'periods' => $periods,
'grand_total_text' => $grand_total_text,
]);
];
}
}

View File

@ -28,6 +28,13 @@ class ProfitLoss extends Widget
public $period;
public function show()
{
$this->setData();
return $this->view('widgets.bar_chart', $this->data);
}
public function setData(): void
{
$this->setFilter();
@ -44,9 +51,9 @@ class ProfitLoss extends Widget
->setDataset(trans_choice('general.incomes', 1), 'column', array_values($this->getIncome()))
->setDataset(trans_choice('general.expenses', 1), 'column', array_values($this->getExpense()));
return $this->view('widgets.bar_chart', [
$this->data = [
'chart' => $chart,
]);
];
}
public function setFilter(): void

View File

@ -15,6 +15,13 @@ class Receivables extends Widget
public $report_class = 'Modules\AgedReceivablesPayables\Reports\AgedReceivables';
public function show()
{
$this->setData();
return $this->view('widgets.receivables_payables', $this->data);
}
public function setData(): void
{
$open = $overdue = 0;
@ -68,12 +75,12 @@ class Receivables extends Widget
$grand_total_text = trans('widgets.total_unpaid_invoices');
return $this->view('widgets.receivables_payables', [
$this->data = [
'totals' => $totals,
'has_progress' => $has_progress,
'progress' => $progress,
'periods' => $periods,
'grand_total_text' => $grand_total_text,
]);
];
}
}

View File

@ -19,8 +19,8 @@
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-intl": "*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
@ -79,11 +79,11 @@
},
"require-dev": {
"beyondcode/laravel-dump-server": "^1.9",
"brianium/paratest": "^7.1",
"brianium/paratest": "^7.3.2",
"fakerphp/faker": "^1.9.1",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^7.0",
"phpunit/phpunit": "10.5.30",
"phpunit/phpunit": "^10.5.63",
"spatie/laravel-ignition": "^2.0",
"wnx/laravel-stats": "^2.11"
},
@ -166,6 +166,9 @@
"allow-plugins": {
"mnsami/composer-custom-directory-installer": true,
"php-http/discovery": true
},
"audit": {
"ignore": ["PKSA-y2cr-5h3j-g3ys"]
}
}
}

898
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -150,6 +150,11 @@ return [
'processors' => [PsrLogMessageProcessor::class],
],
'sentry_logs' => [
'driver' => 'sentry_logs',
'level' => env('LOG_LEVEL', 'debug'),
],
],
];

View File

@ -1,5 +1,7 @@
<?php
use App\Models\Setting\Category;
return [
/*
@ -184,7 +186,7 @@ return [
'payment_method',
'reference',
'category_id' => [
'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' => [

View File

@ -2,10 +2,11 @@
use App\Models\Common\Contact;
use App\Models\Banking\Transaction;
use App\Models\Setting\Category;
return [
/*
/*
|--------------------------------------------------------------------------
| Enable / Disable auto save
|--------------------------------------------------------------------------
@ -13,7 +14,7 @@ return [
| Auto-save every time the application shuts down
|
*/
'auto_save' => env('SETTING_AUTO_SAVE', false),
'auto_save' => env('SETTING_AUTO_SAVE', false),
/*
|--------------------------------------------------------------------------
@ -31,7 +32,7 @@ return [
'auto_clear' => env('SETTING_CACHE_AUTO_CLEAR', true),
],
/*
/*
|--------------------------------------------------------------------------
| Setting driver
|--------------------------------------------------------------------------
@ -41,9 +42,9 @@ return [
| Supported: "database", "json"
|
*/
'driver' => env('SETTING_DRIVER', 'database'),
'driver' => env('SETTING_DRIVER', 'database'),
/*
/*
|--------------------------------------------------------------------------
| Database driver
|--------------------------------------------------------------------------
@ -52,14 +53,14 @@ return [
| the default connection. Set the table and column names.
|
*/
'database' => [
'connection' => env('SETTING_DATABASE_CONNECTION', null),
'table' => env('SETTING_DATABASE_TABLE', 'settings'),
'key' => env('SETTING_DATABASE_KEY', 'key'),
'value' => env('SETTING_DATABASE_VALUE', 'value'),
],
'database' => [
'connection' => env('SETTING_DATABASE_CONNECTION', null),
'table' => env('SETTING_DATABASE_TABLE', 'settings'),
'key' => env('SETTING_DATABASE_KEY', 'key'),
'value' => env('SETTING_DATABASE_VALUE', 'value'),
],
/*
/*
|--------------------------------------------------------------------------
| JSON driver
|--------------------------------------------------------------------------
@ -67,11 +68,11 @@ return [
| Options for json driver. Enter the full path to the .json file.
|
*/
'json' => [
'path' => env('SETTING_JSON_PATH', storage_path('settings.json')),
],
'json' => [
'path' => env('SETTING_JSON_PATH', storage_path('settings.json')),
],
/*
/*
|--------------------------------------------------------------------------
| Override application config values
|--------------------------------------------------------------------------
@ -83,9 +84,9 @@ return [
| "app.locale" => "settings.locale",
|
*/
'override' => [
'override' => [
],
],
/*
|--------------------------------------------------------------------------
@ -168,6 +169,14 @@ return [
'bill_days' => env('SETTING_FALLBACK_SCHEDULE_BILL_DAYS', '10,5,3,1'),
'time' => env('SETTING_FALLBACK_SCHEDULE_TIME', '09:00'),
],
'category' => [
'type' => [
'income' => env('SETTING_FALLBACK_CATEGORY_TYPE_INCOME', Category::INCOME_TYPE),
'expense' => env('SETTING_FALLBACK_CATEGORY_TYPE_EXPENSE', Category::EXPENSE_TYPE),
'item' => env('SETTING_FALLBACK_CATEGORY_TYPE_ITEM', Category::ITEM_TYPE),
'other' => env('SETTING_FALLBACK_CATEGORY_TYPE_OTHER', Category::OTHER_TYPE),
],
],
'contact' => [
'type' => [
'customer' => env('SETTING_FALLBACK_CONTACT_TYPE_CUSTOMER', Contact::CUSTOMER_TYPE),

View File

@ -10,31 +10,39 @@ return [
// Categories
'category' => [
Category::INCOME_TYPE => [
'alias' => '',
'alias' => '',
'group' => Category::INCOME_TYPE,
'translation' => [
'prefix' => 'general',
],
'hide' => ['code'],
],
Category::EXPENSE_TYPE => [
'alias' => '',
'alias' => '',
'group' => Category::EXPENSE_TYPE,
'translation' => [
'prefix' => 'general',
],
'hide' => ['code'],
],
Category::ITEM_TYPE => [
'alias' => '',
'alias' => '',
'group' => Category::ITEM_TYPE,
'translation' => [
'prefix' => 'general',
],
'hide' => ['code'],
],
Category::OTHER_TYPE => [
'alias' => '',
'alias' => '',
'group' => Category::OTHER_TYPE,
'translation' => [
'prefix' => 'general',
],
'hide' => ['code'],
],
],
@ -58,9 +66,9 @@ return [
'section_billing_description' => 'customers.form_description.billing',
'section_address_description' => 'customers.form_description.address',
],
'category_type' => 'income',
'document_type' => 'invoice',
'transaction_type' => 'income',
'category_type' => Category::INCOME_TYPE,
'document_type' => Document::INVOICE_TYPE,
'transaction_type' => Transaction::INCOME_TYPE,
'hide' => [],
'class' => [],
'script' => [
@ -87,9 +95,9 @@ return [
'section_billing_description' => 'vendors.form_description.billing',
'section_address_description' => 'vendors.form_description.address',
],
'category_type' => 'expense',
'document_type' => 'bill',
'transaction_type' => 'expense',
'category_type' => Category::EXPENSE_TYPE,
'document_type' => Document::BILL_TYPE,
'transaction_type' => Transaction::EXPENSE_TYPE,
'hide' => [],
'class' => [],
'script' => [
@ -130,9 +138,9 @@ return [
'setting' => [
'prefix' => 'invoice',
],
'category_type' => 'income',
'transaction_type' => 'income',
'contact_type' => 'customer', // use contact type
'category_type' => Category::INCOME_TYPE,
'transaction_type' => Transaction::INCOME_TYPE,
'contact_type' => Contact::CUSTOMER_TYPE, // use contact type
'inventory_stock_action' => 'decrease', // decrease stock in stock tracking
'transaction' => [
'email_template' => 'invoice_payment_customer', // use email template
@ -185,9 +193,9 @@ return [
'setting' => [
'prefix' => 'invoice',
],
'category_type' => 'income',
'transaction_type' => 'income',
'contact_type' => 'customer', // use contact type
'category_type' => Category::INCOME_TYPE,
'transaction_type' => Transaction::INCOME_TYPE,
'contact_type' => Contact::CUSTOMER_TYPE, // use contact type
'inventory_stock_action' => 'decrease', // decrease stock in stock tracking
'hide' => [], // for document items
'class' => [],
@ -235,9 +243,9 @@ return [
'setting' => [
'prefix' => 'bill',
],
'category_type' => 'expense',
'transaction_type' => 'expense',
'contact_type' => 'vendor',
'category_type' => Category::EXPENSE_TYPE,
'transaction_type' => Transaction::EXPENSE_TYPE,
'contact_type' => Contact::VENDOR_TYPE,
'inventory_stock_action' => 'increase', // increases stock in stock tracking
'transaction' => [
'email_template' => 'invoice_payment_customer', // use email template
@ -288,9 +296,9 @@ return [
'setting' => [
'prefix' => 'bill',
],
'category_type' => 'expense',
'transaction_type' => 'expense',
'contact_type' => 'vendor',
'category_type' => Category::EXPENSE_TYPE,
'transaction_type' => Transaction::EXPENSE_TYPE,
'contact_type' => Contact::VENDOR_TYPE,
'inventory_stock_action' => 'increase', // increases stock in stock tracking
'hide' => [],
'class' => [],
@ -334,8 +342,9 @@ return [
'related_document_amount' => 'invoices.invoice_amount',
'transactions' => 'general.incomes',
],
'contact_type' => 'customer',
'document_type' => 'invoice',
'category_type' => Category::INCOME_TYPE,
'contact_type' => Contact::CUSTOMER_TYPE,
'document_type' => Document::INVOICE_TYPE,
'split_type' => Transaction::INCOME_SPLIT_TYPE,
'email_template' => 'payment_received_customer',
'script' => [
@ -343,7 +352,7 @@ return [
'file' => 'transactions',
],
],
Transaction::INCOME_TYPE => [
'group' => 'banking',
'route' => [
@ -365,8 +374,9 @@ return [
'related_document_amount' => 'invoices.invoice_amount',
'transactions' => 'general.incomes',
],
'contact_type' => 'customer',
'document_type' => 'invoice',
'category_type' => Category::INCOME_TYPE,
'contact_type' => Contact::CUSTOMER_TYPE,
'document_type' => Document::INVOICE_TYPE,
'split_type' => Transaction::INCOME_SPLIT_TYPE,
'email_template' => 'payment_received_customer',
'script' => [
@ -396,8 +406,9 @@ return [
'related_document_amount' => 'invoices.invoice_amount',
'transactions' => 'general.incomes',
],
'contact_type' => 'customer',
'document_type' => 'invoice',
'category_type' => Category::INCOME_TYPE,
'contact_type' => Contact::CUSTOMER_TYPE,
'document_type' => Document::INVOICE_TYPE,
'split_type' => Transaction::INCOME_SPLIT_TYPE,
'email_template' => 'payment_received_customer',
'script' => [
@ -427,8 +438,9 @@ return [
'related_document_amount' => 'invoices.invoice_amount',
'transactions' => 'general.incomes',
],
'contact_type' => 'customer',
'document_type' => 'invoice',
'category_type' => Category::INCOME_TYPE,
'contact_type' => Contact::CUSTOMER_TYPE,
'document_type' => Document::INVOICE_TYPE,
'email_template' => 'payment_received_customer',
'script' => [
'folder' => 'banking',
@ -489,8 +501,9 @@ return [
'prefix' => 'transactions', // this translation file name.
'related_document_amount' => 'bills.bill_amount',
],
'contact_type' => 'vendor',
'document_type' => 'bill',
'category_type' => Category::EXPENSE_TYPE,
'contact_type' => Contact::VENDOR_TYPE,
'document_type' => Document::BILL_TYPE,
'split_type' => Transaction::EXPENSE_SPLIT_TYPE,
'email_template' => 'payment_made_vendor',
'script' => [
@ -519,8 +532,9 @@ return [
'prefix' => 'transactions', // this translation file name.
'related_document_amount' => 'bills.bill_amount',
],
'contact_type' => 'vendor',
'document_type' => 'bill',
'category_type' => Category::EXPENSE_TYPE,
'contact_type' => Contact::VENDOR_TYPE,
'document_type' => Document::BILL_TYPE,
'split_type' => Transaction::EXPENSE_SPLIT_TYPE,
'email_template' => 'payment_made_vendor',
'script' => [
@ -549,8 +563,9 @@ return [
'prefix' => 'transactions', // this translation file name.
'related_document_amount' => 'bills.bill_amount',
],
'contact_type' => 'vendor',
'document_type' => 'bill',
'category_type' => Category::EXPENSE_TYPE,
'contact_type' => Contact::VENDOR_TYPE,
'document_type' => Document::BILL_TYPE,
'email_template' => 'payment_made_vendor',
'script' => [
'folder' => 'banking',

View File

@ -10,13 +10,13 @@ return [
'minor' => '1',
'patch' => '20',
'patch' => '21',
'build' => '',
'status' => 'Stable',
'date' => '11-December-2025',
'date' => '13-December-2025',
'time' => '22:00',

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->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');
});
}
};

1667
package-lock.json generated

File diff suppressed because it is too large Load Diff

15610
public/css/app.css vendored

File diff suppressed because it is too large Load Diff

View File

@ -48,7 +48,7 @@
default: 'sale'
},
currecyCode: {
currencyCode: {
type: String,
default: 'USD'
},
@ -58,7 +58,7 @@
},
currencySymbol: {
default: {}
default: {}
}
},
@ -76,7 +76,7 @@
if (conversion[0]) {
this.texts.push(conversion[0]);
}
if (conversion[1]) {
this.texts.push(conversion[1].replace(':currency_code', company_currency_code).replace(':currency_rate', ''));
}
@ -92,25 +92,25 @@
watch: {
currencyConversionText: function (text) {
this.conversion = text.replace(':price', this.price).replace(':currency_code', this.currecyCode);
this.conversion = text.replace(':price', this.price).replace(':currency_code', this.currencyCode);
},
price: function (price) {
this.conversion = this.currencyConversionText.replace(':price', price).replace(':currency_code', this.currecyCode).replace();
this.conversion = this.currencyConversionText.replace(':price', price).replace(':currency_code', this.currencyCode).replace();
},
currecyCode: function (currecyCode) {
this.conversion = this.currencyConversionText.replace(':price', this.price).replace(':currency_code', this.currecyCode).replace();
currencyCode: function (currencyCode) {
this.conversion = this.currencyConversionText.replace(':price', this.price).replace(':currency_code', this.currencyCode).replace();
},
currencyRate: function (currencyRate) {
this.rate = currencyRate;
this.conversion = this.currencyConversionText.replace(':price', this.price).replace(':currency_code', this.currecyCode).replace();
this.conversion = this.currencyConversionText.replace(':price', this.price).replace(':currency_code', this.currencyCode).replace();
},
currencySymbol: function (currencySymbol) {
this.conversion = this.currencyConversionText.replace(':price', this.price).replace(':currency_code', this.currecyCode).replace();
this.conversion = this.currencyConversionText.replace(':price', this.price).replace(':currency_code', this.currencyCode).replace();
},
},
};

View File

@ -20,6 +20,7 @@
:collapse-tags="collapse"
:remote-method="remoteMethod"
:loading="loading"
:class="[{ 'with-color-prefix': selectedOptionColor, 'with-icon-prefix': icon }]"
>
<div
v-if="loading"
@ -56,21 +57,24 @@
<div v-if="!loading && addNew.status && options.length == 0">
<el-option class="text-center" disabled :label="noDataText" value="value"></el-option>
<ul class="el-scrollbar__view el-select-dropdown__list">
<li class="el-select-dropdown__item el-select__footer bg-purple sticky bottom-0">
<div class="w-full flex items-center" @click="onAddItem">
<span class="material-icons text-xl text-purple">add</span>
<span class="flex-1 font-bold text-purple">
{{ addNew.text }}
</span>
</div>
</li>
</ul>
<li class="el-select-dropdown__item el-select__footer bg-purple sticky bottom-0">
<div class="w-full flex items-center" @click="onAddItem">
<span class="material-icons text-xl text-purple">add</span>
<span class="flex-1 font-bold text-purple">
{{ addNew.text }}
</span>
</div>
</li>
</div>
<template slot="prefix">
<span class="el-input__suffix-inner el-select-icon">
<i :class="'select-icon-position el-input__icon fa fa-' + icon"></i>
<span class="aka-select-prefix">
<span
v-if="!isDropdownVisible && selectedOptionColor"
class="w-4 h-4 rounded-full mt-1 ml-2"
:style="{ backgroundColor: selectedOptionColor }"
></span>
<i v-if="icon" :class="'select-icon-position el-input__icon fa fa-' + icon"></i>
</span>
</template>
@ -141,12 +145,19 @@
<component v-bind:is="add_new_html" @submit="onSubmit" @cancel="onCancel"></component>
<span slot="infoBlock" class="absolute right-8 top-3 bg-green text-white px-2 py-1 rounded-md text-xs" v-if="new_options[selected] || (sorted_options.length && sorted_options[sorted_options.length - 1].mark_new && sorted_options[sorted_options.length - 1].key == selected)">{{ addNew.new_text }}</span>
<span slot="infoBlock" class="absolute right-8 top-3 bg-green text-white px-2 py-1 rounded-md text-xs" v-if="!isDropdownVisible && (new_options[selected] || (sorted_options.length && sorted_options[sorted_options.length - 1].mark_new && sorted_options[sorted_options.length - 1].key == selected))">{{ addNew.new_text }}</span>
<span
slot="infoBlock"
class="absolute right-8 top-4 rounded-md bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10"
v-if="!isDropdownVisible && group && selectedGroupLabel"
>
{{ selectedGroupLabel }}
</span>
<select :name="name" :id="name + '-' + _uid" class="hidden">
<option v-for="option in sortedOptions" :key="option.key" :value="option.key">{{ option.value }}</option>
</select>
</base-input>
<span v-else>
@ -159,6 +170,7 @@
:collapse-tags="collapse"
:remote-method="remoteMethod"
:loading="loading"
:class="[{ 'with-color-prefix': selectedOptionColor, 'with-icon-prefix': icon }]"
>
<div v-if="loading" class="el-select-dropdown__wrap" slot="empty">
<p class="el-select-dropdown__empty pt-2 pb-0 loading">
@ -185,21 +197,25 @@
<div v-if="!loading && addNew.status && options.length == 0">
<el-option class="text-center" disabled :label="noDataText" value="value"></el-option>
<ul class="el-scrollbar__view el-select-dropdown__list">
<li class="el-select-dropdown__item el-select__footer bg-purple sticky bottom-0">
<div class="w-full flex items-center" @click="onAddItem">
<span class="material-icons text-xl text-purple">add</span>
<span class="flex-1 font-bold text-purple">
{{ addNew.text }}
</span>
</div>
</li>
</ul>
<li class="el-select-dropdown__item el-select__footer bg-purple sticky bottom-0">
<div class="w-full flex items-center" @click="onAddItem">
<span class="material-icons text-xl text-purple">add</span>
<span class="flex-1 font-bold text-purple">
{{ addNew.text }}
</span>
</div>
</li>
</div>
<template slot="prefix">
<span class="el-input__suffix-inner el-select-icon">
<i :class="'select-icon-position el-input__icon fa fa-' + icon"></i>
<span class="aka-select-prefix">
<span
v-if="selectedOptionColor"
class="aka-select-prefix-dot"
:style="{ backgroundColor: selectedOptionColor }"
></span>
<i v-if="icon" :class="'select-icon-position el-input__icon fa fa-' + icon"></i>
</span>
</template>
@ -265,12 +281,19 @@
</span>
</div>
</el-option>
</el-select>
<component v-bind:is="add_new_html" @submit="onSubmit" @cancel="onCancel"></component>
<span slot="infoBlock" class="absolute right-8 top-3 bg-green text-white px-2 py-1 rounded-md text-xs" v-if="new_options[selected] || (sorted_options.length && sorted_options[sorted_options.length - 1].mark_new && sorted_options[sorted_options.length - 1].key == selected)">{{ addNew.new_text }}</span>
<span slot="infoBlock" class="absolute right-8 top-3 bg-green text-white px-2 py-1 rounded-md text-xs" v-if="!isDropdownVisible && (new_options[selected] || (sorted_options.length && sorted_options[sorted_options.length - 1].mark_new && sorted_options[sorted_options.length - 1].key == selected))">{{ addNew.new_text }}</span>
<span
slot="infoBlock"
class="absolute right-8 top-4 rounded-md bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10"
v-if="!isDropdownVisible && group && selectedGroupLabel"
>
{{ selectedGroupLabel }}
</span>
<select :name="name" :id="name + '-' + _uid" v-model="selected" class="d-none">
<option v-for="option in sortedOptions" :key="option.key" :value="option.key">{{ option.value }}</option>
@ -491,7 +514,7 @@ export default {
currencyCode: {
type: String,
default: 'USD',
description: "Get remote item price currecy code"
description: "Get remote item price currency code"
},
optionStyle: {
@ -520,6 +543,7 @@ export default {
full_options:[],
new_options: {},
loading: false,
isDropdownVisible: false,
}
},
@ -549,6 +573,48 @@ export default {
return this.sorted_options;
},
selectedGroupLabel() {
if (!this.group || !Array.isArray(this.sorted_options) || !this.sorted_options.length) {
return '';
}
if (this.multiple) {
if (!Array.isArray(this.selected) || !this.selected.length) {
return '';
}
const labels = this.selected
.map(value => this.findGroupLabelByOptionKey(value))
.filter(Boolean);
return [...new Set(labels)].join(', ');
}
return this.findGroupLabelByOptionKey(this.selected);
},
selectedOptionColor() {
const selectedOption = this.getSelectedOptionData();
if (!selectedOption || typeof selectedOption !== 'object') {
return '';
}
if (selectedOption.color_hex_code) {
return selectedOption.color_hex_code;
}
if (selectedOption.color_hex) {
return selectedOption.color_hex;
}
if (selectedOption.color && selectedOption.color.toString().startsWith('#')) {
return selectedOption.color;
}
return '';
},
},
mounted() {
@ -579,6 +645,78 @@ export default {
},
methods: {
getSelectedOptionData() {
const selectedKey = this.multiple
? (Array.isArray(this.selected) && this.selected.length ? this.selected[0] : null)
: this.selected;
if (selectedKey === null || selectedKey === undefined || selectedKey === '') {
return null;
}
const foundOption = this.findOptionByKey(selectedKey);
if (!foundOption) {
return null;
}
return foundOption.option ? foundOption.option : foundOption;
},
findOptionByKey(optionKey) {
const normalizedKey = optionKey.toString();
if (this.group) {
for (const groupOption of this.sorted_options) {
if (!Array.isArray(groupOption.value)) {
continue;
}
const found = groupOption.value.find(option => option.key == normalizedKey);
if (found) {
return found;
}
}
} else {
const found = this.sorted_options.find(option => option.key == normalizedKey);
if (found) {
return found;
}
}
const foundInFullOptions = this.full_options.find(option => option.key == normalizedKey);
if (foundInFullOptions) {
return foundInFullOptions;
}
return null;
},
findGroupLabelByOptionKey(optionKey) {
if (optionKey === null || optionKey === undefined || optionKey === '') {
return '';
}
const normalizedKey = optionKey.toString();
const foundGroup = this.sorted_options.find(groupOption => {
if (!Array.isArray(groupOption.value)) {
return false;
}
return groupOption.value.some(option => option.key == normalizedKey);
});
if (!foundGroup) {
return '';
}
return foundGroup.key ? foundGroup.key.toString() : '';
},
sortBy(option) {
return (firstEl, secondEl) => {
let first_element = firstEl[option].toUpperCase(); // ignore upper and lowercase
@ -910,6 +1048,8 @@ export default {
visibleChange(event) {
this.$emit('visible-change', event);
this.isDropdownVisible = event;
this.dynamicPlaceholder = this.placeholder;
if (event && this.searchText) {
@ -1492,11 +1632,25 @@ export default {
</script>
<style>
.aka-select-prefix {
display: inline-flex;
align-items: center;
height: 100%;
}
.with-color-prefix .el-input__inner {
padding-left: 2.25rem !important;
}
.with-color-prefix.with-icon-prefix .el-input__inner {
padding-left: 2.8rem !important;
}
.el-select-dropdown__item.el-select__footer.bg-purple.sticky.bottom-0 {
background-color: #fff !important;
}
.el-select-dropdown__item.el-select__footer.bg-purple.sticky.bottom-0:hover {
background-color: 55588b !important;
background-color: #55588b !important;
}
</style>

View File

@ -228,7 +228,7 @@ export default {
<div class="swiper-button-prev bg-body text-white flex items-center justify-center left-0">
<span class="material-icons text-purple text-4xl">chevron_left</span>
</div>
`;
`;
item.querySelector('[data-tabs-swiper]').innerHTML = html;
slides_view = Number(item.getAttribute('data-swiper')) != 0 ? Number(item.getAttribute('data-swiper')) : slides_view;
@ -301,6 +301,28 @@ export default {
this.form[key] = event.target.files[0];
},
isCategoryCodeFieldVisible() {
if (!this.form || !this.form.type || !this.form.type_codes) {
return false;
}
let type_codes = this.form.type_codes;
if (typeof type_codes === 'string') {
try {
type_codes = JSON.parse(type_codes);
} catch (e) {
return false;
}
}
if (typeof type_codes[this.form.type] === 'undefined') {
return true;
}
return !Boolean(type_codes[this.form.type]);
},
// Bulk Action Select all
onSelectAllBulkAction() {
this.bulk_action.selectAll();

View File

@ -30,7 +30,7 @@ const app = new Vue({
form: new Form('category'),
bulk_action: new BulkAction('categories'),
categoriesBasedTypes: null,
selected_type: true
selected_type: true,
}
},

View File

@ -291,6 +291,10 @@
color:#595959 !important;
}
.el-select-group__title {
@apply text-purple-500 font-bold;
}
.el-select-dropdown .popper__arrow::after {
display: none;
}

View File

@ -47,6 +47,7 @@ return [
'payment_cancel' => 'Warning: You have cancelled your recent :method payment!',
'missing_transfer' => 'Warning: The transfer related to this transaction is missing. You should consider deleting this transaction.',
'connect_tax' => 'Warning: This :type has a tax amount. Taxes added to the :type can not be connected, so the tax will be added to the total and calculated accordingly.',
'contact_change' => 'Warning: You are not allowed to change the contact on a :type that has already been sent, received, or paid!',
],
];

View File

@ -8,7 +8,7 @@ return [
'decimal_mark' => 'Punto decimal',
'thousands_separator' => 'Separador de miles',
'precision' => 'Precisión',
'conversion' => 'Conversión de moneda: :price (:currecy_code) a :currency_rate',
'conversion' => 'Conversión de moneda: :price (:currency_code) a :currency_rate',
'symbol' => [
'symbol' => 'Símbolo',
'position' => 'Posición del Símbolo',

View File

@ -8,7 +8,7 @@ return [
'decimal_mark' => 'Punto decimal',
'thousands_separator' => 'Separador de miles',
'precision' => 'Precisión',
'conversion' => 'Conversión de moneda: :price (:currecy_code) a :currency_rate',
'conversion' => 'Conversión de moneda: :price (:currency_code) a :currency_rate',
'symbol' => [
'symbol' => 'Símbolo',
'position' => 'Posición de Símbolo',

View File

@ -229,7 +229,7 @@
<akaunting-currency-conversion
currency-conversion-text="{{ trans('currencies.conversion') }}"
:price="(totals.total / form.currency_rate).toFixed(2)"
:currecy-code="form.currency_code"
:currency-code="form.currency_code"
:currency-rate="form.currency_rate"
:currency-symbol="currency_symbol"
@change="form.currency_rate = $event"

View File

@ -1,4 +1,7 @@
@if ((! $attributes->has('withoutRemote') && ! $attributes->has('without-remote')) && (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new')))
@if (
(! $attributes->has('withoutRemote') && ! $attributes->has('without-remote'))
&& (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new'))
)
<x-form.group.select
remote
remote_action="{{ $remoteAction }}"
@ -11,10 +14,7 @@
:options="$categories"
:selected="$selected"
sort-options="false"
:option_field="[
'key' => 'id',
'value' => 'title'
]"
:option_field="$option_field"
:multiple="$multiple"
:group="$group"
@ -27,12 +27,20 @@
>
<template #option="{option}">
<div class="flex items-center">
<span class="w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
<span class="{{ (! $group) ? 'ltr:ml-2 rtl:mr-2 ' : '' }}w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
@if ($option_field['value'] == 'title')
<span>@{{ option.option.title }}</span>
@else
<span>@{{ option.option.name }}</span>
@endif
</div>
</template>
</x-form.group.select>
@elseif (($attributes->has('withoutRemote') && $attributes->has('without-remote')) && (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new')))
@elseif (
($attributes->has('withoutRemote') || $attributes->has('without-remote'))
&& (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new'))
)
<x-form.group.select
add-new
path="{{ $path }}"
@ -42,10 +50,7 @@
:options="$categories"
:selected="$selected"
sort-options="false"
:option_field="[
'key' => 'id',
'value' => 'title'
]"
:option_field="$option_field"
:multiple="$multiple"
:group="$group"
@ -58,12 +63,20 @@
>
<template #option="{option}">
<div class="flex items-center">
<span class="w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
<span class="{{ (! $group) ? 'ltr:ml-2 rtl:mr-2 ' : '' }}w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
@if ($option_field['value'] == 'title')
<span>@{{ option.option.title }}</span>
@else
<span>@{{ option.option.name }}</span>
@endif
</div>
</template>
</x-form.group.select>
@elseif ((! $attributes->has('withoutRemote') && ! $attributes->has('without-remote')) && ($attributes->has('withoutAddNew') && $attributes->has('without-add-new')))
@elseif (
(! $attributes->has('withoutRemote') && ! $attributes->has('without-remote'))
&& ($attributes->has('withoutAddNew') || $attributes->has('without-add-new'))
)
<x-form.group.select
remote
remote_action="{{ $remoteAction }}"
@ -73,10 +86,7 @@
:options="$categories"
:selected="$selected"
sort-options="false"
:option_field="[
'key' => 'id',
'value' => 'title'
]"
:option_field="$option_field"
:multiple="$multiple"
:group="$group"
@ -89,8 +99,13 @@
>
<template #option="{option}">
<div class="flex items-center">
<span class="w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
<span class="{{ (! $group) ? 'ltr:ml-2 rtl:mr-2 ' : '' }}w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
@if ($option_field['value'] == 'title')
<span>@{{ option.option.title }}</span>
@else
<span>@{{ option.option.name }}</span>
@endif
</div>
</template>
</x-form.group.select>
@ -101,10 +116,7 @@
:options="$categories"
:selected="$selected"
sort-options="false"
:option_field="[
'key' => 'id',
'value' => 'title'
]"
:option_field="$option_field"
:multiple="$multiple"
:group="$group"
@ -117,8 +129,13 @@
>
<template #option="{option}">
<div class="flex items-center">
<span class="w-5 h-4 rounded-full":style="{backgroundColor: option.option.color_hex_code}"></span>
<span class="{{ (! $group) ? 'ltr:ml-2 rtl:mr-2 ' : '' }}w-5 h-4 rounded-full" :style="{backgroundColor: option.option.color_hex_code}"></span>
@if ($option_field['value'] == 'title')
<span>@{{ option.option.title }}</span>
@else
<span>@{{ option.option.name }}</span>
@endif
</div>
</template>
</x-form.group.select>

View File

@ -56,7 +56,7 @@
</template>
@endif
</x-form.group.select>
@elseif (($attributes->has('withoutRemote') && $attributes->has('without-remote')) && (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new')))
@elseif (($attributes->has('withoutRemote') || $attributes->has('without-remote')) && (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new')))
<x-form.group.select
add-new
path="{{ $path }}"
@ -111,7 +111,7 @@
</template>
@endif
</x-form.group.select>
@elseif ((! $attributes->has('withoutRemote') && ! $attributes->has('without-remote')) && ($attributes->has('withoutAddNew') && $attributes->has('without-add-new')))
@elseif ((! $attributes->has('withoutRemote') && ! $attributes->has('without-remote')) && ($attributes->has('withoutAddNew') || $attributes->has('without-add-new')))
<x-form.group.select
remote
remote_action="{{ $remoteAction }}"

View File

@ -60,7 +60,7 @@
</span>
</template>
</x-form.group.select>
@elseif (($attributes->has('withoutRemote') && $attributes->has('without-remote')) && (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new')))
@elseif (($attributes->has('withoutRemote') || $attributes->has('without-remote')) && (! $attributes->has('withoutAddNew') && ! $attributes->has('without-add-new')))
<x-form.group.select
add-new
path="{{ $path }}"
@ -115,7 +115,7 @@
</span>
</template>
</x-form.group.select>
@elseif ((! $attributes->has('withoutRemote') && ! $attributes->has('without-remote')) && ($attributes->has('withoutAddNew') && $attributes->has('without-add-new')))
@elseif ((! $attributes->has('withoutRemote') && ! $attributes->has('without-remote')) && ($attributes->has('withoutAddNew') || $attributes->has('without-add-new')))
<x-form.group.select
remote
remote_action="{{ $remoteAction }}"

View File

@ -0,0 +1,3 @@
<span @class(['font-medium', $textColor])>
{{ $amount }}
</span>

View File

@ -6,7 +6,21 @@
<x-form.group.select name="parent_id" label="{{ trans('general.parent') . ' ' . trans_choice('general.categories', 1) }}" :options="$categories" not-required sort-options="false" searchable form-group-class="col-span-6" />
<x-form.input.hidden name="type" value="{{ $type }}" />
@if (! empty($types) && count($types) > 1)
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" value="{{ $type }}" form-group-class="col-span-6" :group="$type_group" />
<x-form.group.text name="code" label="{{ trans('general.code') }}" form-group-class="col-span-6" v-show="isCategoryCodeFieldVisible()" />
@else
<x-form.input.hidden name="type" value="{{ $type }}" />
@if (empty($hide_code_types[$type]) || ! $hide_code_types[$type])
<x-form.group.text name="code" label="{{ trans('general.code') }}" form-group-class="col-span-6" />
@endif
@endif
<x-form.group.textarea name="description" label="{{ trans('general.description') }}" not-required />
<x-form.input.hidden name="enabled" value="1" />
<x-form.input.hidden name="type_codes" value="{{ json_encode($hide_code_types) }}" />
</div>
</x-form>

View File

@ -22,11 +22,16 @@
<x-form.group.color name="color" label="{{ trans('general.color') }}" />
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" :selected="config('general.types')" change="updateParentCategories" />
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" :selected="config('general.types')" change="updateParentCategories" :group="$type_group" />
<x-form.group.text name="code" label="{{ trans('general.code') }}" v-show="isCategoryCodeFieldVisible()" />
<x-form.group.select name="parent_id" label="{{ trans('general.parent') . ' ' . trans_choice('general.categories', 1) }}" :options="[]" not-required dynamicOptions="categoriesBasedTypes" sort-options="false" v-disabled="selected_type" />
<x-form.group.textarea name="description" label="{{ trans('general.description') }}" not-required />
<x-form.input.hidden name="categories" value="{{ json_encode($categories) }}" />
<x-form.input.hidden name="type_codes" value="{{ json_encode($hide_code_types) }}" />
</x-slot>
</x-form.section>

View File

@ -17,22 +17,27 @@
<x-form.group.color name="color" label="{{ trans('general.color') }}" />
@if ($type_disabled)
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" v-disabled="true" />
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" v-disabled="true" :group="$type_group" />
<input type="hidden" name="type" value="{{ $category->type }}" />
@else
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" change="updateParentCategories" />
<x-form.group.select name="type" label="{{ trans_choice('general.types', 1) }}" :options="$types" change="updateParentCategories" :group="$type_group" />
<x-form.group.text name="code" label="{{ trans('general.code') }}" v-show="isCategoryCodeFieldVisible()" />
<x-form.group.select name="parent_id" label="{{ trans('general.parent') . ' ' . trans_choice('general.categories', 1) }}" :options="$parent_categories" not-required dynamicOptions="categoriesBasedTypes" sort-options="false" />
<x-form.input.hidden name="parent_category_id" value="{{ $category->parent_id }}" />
<x-form.input.hidden name="categories" value="{{ json_encode($categories) }}" />
<x-form.input.hidden name="type_codes" value="{{ json_encode($hide_code_types) }}" />
@endif
<x-form.group.textarea name="description" label="{{ trans('general.description') }}" not-required />
</x-slot>
</x-form.section>
@if (! $type_disabled)
<x-form.group.switch name="enabled" label="{{ trans('general.enabled') }}" />
<x-form.group.switch name="enabled" label="{{ trans('general.enabled') }}" />
@endif
@can('update-settings-categories')

View File

@ -37,95 +37,167 @@
<x-slot name="content">
<x-index.container>
<x-index.search
search-string="App\Models\Setting\Category"
bulk-action="App\BulkActions\Settings\Categories"
/>
<x-table>
<x-table.thead>
<x-table.tr>
<x-table.th kind="bulkaction">
<x-index.bulkaction.all />
</x-table.th>
<x-table.th class="w-6/12">
<x-sortablelink column="name" title="{{ trans('general.name') }}" />
</x-table.th>
<x-table.th class="w-6/12">
<x-sortablelink column="type" title="{{ trans_choice('general.types', 1) }}" />
</x-table.th>
</x-table.tr>
</x-table.thead>
<x-table.tbody>
@foreach($categories as $item)
<x-table.tr href="{{ route('categories.edit', $item->id) }}">
<x-table.td kind="bulkaction">
<x-index.bulkaction.single
id="{{ $item->id }}"
name="{{ $item->name }}"
:disabled="($item->isTransferCategory()) ? true : false"
/>
</x-table.td>
<x-table.td class="w-6/12">
<div class="flex items-center">
@if ($item->sub_categories->count())
<x-tooltip id="tooltip-category-{{ $item->id }}" placement="bottom" message="{{ trans('categories.collapse') }}">
<button
type="button"
class="w-4 h-4 flex items-center justify-center mx-2 leading-none align-text-top rounded-lg"
node="child-{{ $item->id }}"
onClick="toggleSub('child-{{ $item->id }}', event)"
>
<span class="material-icons transform rotate-90 -ml-2 transition-all text-xl leading-none align-middle rounded-full bg-{{ $item->color }} text-white" style="background-color:{{ $item->color }};">chevron_right</span>
</button>
</x-tooltip>
<div class="flex items-center font-bold">
{{ $item->name }}
</div>
</div>
@else
<div class="flex items-center">
<span class="material-icons text-{{ $item->color }}" class="text-3xl" style="color:{{ $item->color }};">circle</span>
<span class="font-bold ltr:ml-2 rtl:mr-2">
{{ $item->name }}
</span>
</div>
@endif
@if (! $item->enabled)
<x-index.disable text="{{ trans_choice('general.categories', 1) }}" />
@endif
</x-table.td>
<x-table.td class="w-6/12">
@if (! empty($types[$item->type]))
{{ $types[$item->type] }}
@else
<x-empty-data />
@endif
</x-table.td>
<x-table.td kind="action">
<x-table.actions :model="$item" />
</x-table.td>
</x-table.tr>
@foreach($item->sub_categories as $sub_category)
@include('settings.categories.sub_category', ['parent_category' => $item, 'sub_category' => $sub_category, 'tree_level' => 1])
@endforeach
<x-tabs active="{{ $tab_active }}">
<x-slot name="navs">
@foreach($tabs as $tab => $data)
@if ($tab_active == $tab)
<x-tabs.nav-pin
id="{{ $tab }}"
name="{{ $data['name'] }}"
type="categories"
tab="{{ $tab }}"
/>
@else
<x-tabs.nav-pin
id="{{ $tab }}"
href="{{ route('categories.index', ['search' => 'type:' . $data['key']]) }}"
name="{{ $data['name'] }}"
type="categories"
tab="{{ $tab }}"
/>
@endif
@endforeach
</x-table.tbody>
</x-table>
<x-pagination :items="$categories" />
@if ($tab_active == 'categories-all')
<x-tabs.nav-pin
id="categories-all"
name="{{ trans('general.all_type', ['type' => trans_choice('general.categories', 2)]) }}"
type="categories"
tab="all"
/>
@else
<x-tabs.nav-pin
id="categories-all"
href="{{ route('categories.index', ['list_records' => 'all']) }}"
name="{{ trans('general.all_type', ['type' => trans_choice('general.categories', 2)]) }}"
type="categories"
tab="all"
/>
@endif
</x-slot>
<x-slot name="content">
@php
$name_class = $hide_code_column ? 'w-5/12' : 'w-4/12';
@endphp
<x-tabs.tab id="{{ $tab_active }}">
<x-index.search
search-string="App\Models\Setting\Category"
bulk-action="App\BulkActions\Settings\Categories"
/>
<x-table>
<x-table.thead>
<x-table.tr>
<x-table.th kind="bulkaction">
<x-index.bulkaction.all />
</x-table.th>
@if (!$hide_code_column)
<x-table.th class="w-1/12">
<x-sortablelink column="code" title="{{ trans('general.code') }}" />
</x-table.th>
@endif
<x-table.th class="{{ $name_class }}">
<x-sortablelink column="name" title="{{ trans('general.name') }}" />
</x-table.th>
<x-table.th class="w-3/12">
<x-sortablelink column="type" title="{{ trans_choice('general.types', 1) }}" />
</x-table.th>
<x-table.th class="w-2/12 ltr:text-right rtl:text-left">
{{ trans('general.balance') }}
</x-table.th>
</x-table.tr>
</x-table.thead>
<x-table.tbody>
@foreach($categories as $item)
<x-table.tr href="{{ route('categories.edit', $item->id) }}">
<x-table.td kind="bulkaction">
<x-index.bulkaction.single
id="{{ $item->id }}"
name="{{ $item->name }}"
:disabled="($item->isTransferCategory()) ? true : false"
/>
</x-table.td>
@if (!$hide_code_column)
<x-table.td class="w-1/12">
@if(!empty($item->code))
{{ $item->code }}
@else
<x-empty-data />
@endif
</x-table.td>
@endif
<x-table.td class="{{ $name_class }}">
@if ($item->sub_categories->count())
<div class="flex items-center">
<x-tooltip id="tooltip-category-{{ $item->id }}" placement="bottom" message="{{ trans('categories.collapse') }}">
<button
type="button"
class="w-4 h-4 flex items-center justify-center mx-2 leading-none align-text-top rounded-lg"
node="child-{{ $item->id }}"
onClick="toggleSub('child-{{ $item->id }}', event)"
>
<span class="material-icons transform rotate-90 -ml-2 transition-all text-xl leading-none align-middle rounded-full bg-{{ $item->color }} text-white" style="background-color:{{ $item->color }};">chevron_right</span>
</button>
</x-tooltip>
<div class="flex items-center font-bold">
{{ $item->name }}
</div>
</div>
@else
<div class="flex items-center">
<span class="material-icons text-{{ $item->color }}" class="text-3xl" style="color:{{ $item->color }};">circle</span>
<span class="font-bold ltr:ml-2 rtl:mr-2">
{{ $item->name }}
</span>
</div>
@endif
@if (! $item->enabled)
<x-index.disable text="{{ trans_choice('general.categories', 1) }}" />
@endif
</x-table.td>
<x-table.td class="w-3/12">
@if (! empty($types[$item->type]))
{{ $types[$item->type] }}
@else
<x-empty-data />
@endif
</x-table.td>
<x-table.td class="w-2/12 ltr:text-right rtl:text-left">
<x-index.balance :amount="$item->balance" />
</x-table.td>
<x-table.td kind="action">
<x-table.actions :model="$item" />
</x-table.td>
</x-table.tr>
@foreach($item->sub_categories as $sub_category)
@include('settings.categories.sub_category', ['parent_category' => $item, 'sub_category' => $sub_category, 'tree_level' => 1, 'hide_code_column' => $hide_code_column, 'name_class' => $name_class])
@endforeach
@endforeach
</x-table.tbody>
</x-table>
<x-pagination :items="$categories" />
</x-tabs.tab>
</x-slot>
</x-tabs>
</x-index.container>
</x-slot>
<x-script folder="settings" file="categories" />
</x-layouts.admin>
</x-layouts.admin>

View File

@ -1,10 +1,64 @@
@if ($sub_category->sub_categories)
@if ($loop->first)
<x-table.tr data-collapse="child-{{ $parent_category->id }}" data-animation class="relative flex items-center hover:bg-gray-100 px-1 group border-b transition-all collapse-sub" href="{{ route('categories.edit', $sub_category->id) }}">
<x-table.td kind="bulkaction">
<x-index.bulkaction.single id="{{ $parent_category->id }}" name="{{ $parent_category->name }}" disabled />
</x-table.td>
@if (!$hide_code_column && (empty(config('type.category.' . $parent_category->type . '.hide', [])) || ! in_array('code', config('type.category.' . $sub_category->type . '.hide'))))
<x-table.td class="w-1/12 py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-medium text-black truncate">
@if(!empty($parent_category->code))
{{ $parent_category->code }}
@else
<x-empty-data />
@endif
</x-table.td>
@endif
<x-table.td class="relative {{ $name_class }} py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-medium text-black truncate" style="padding-left: {{ $tree_level * 30 }}px;">
<div class="flex items-center ml-2">
<span class="material-icons text-3xl text-{{ $parent_category->color }}" style="color:{{ $sub_category->color }};">circle</span>
<div class="flex items-center font-bold table-submenu ltr:ml-2 rtl:mr-2">
{{ $parent_category->name }}
</div>
</div>
@if (! $parent_category->enabled)
<x-index.disable text="{{ trans_choice('general.categories', 1) }}" />
@endif
</x-table.td>
<x-table.td class="w-3/12 py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-normal text-black cursor-pointer truncate">
@if (! empty($types[$item->type]))
{{ $types[$item->type] }}
@else
<x-empty-data />
@endif
</x-table.td>
<x-table.td class="w-2/12 py-4 ltr:text-right rtl:text-left whitespace-nowrap text-sm font-normal text-black cursor-pointer truncate">
<x-index.balance :amount="$parent_category->balance_without_subcategories" />
</x-table.td>
</x-table.tr>
@endif
<x-table.tr data-collapse="child-{{ $parent_category->id }}" data-animation class="relative flex items-center hover:bg-gray-100 px-1 group border-b transition-all collapse-sub" href="{{ route('categories.edit', $sub_category->id) }}">
<x-table.td kind="bulkaction">
<x-index.bulkaction.single id="{{ $sub_category->id }}" name="{{ $sub_category->name }}" />
</x-table.td>
<x-table.td class="relative w-6/12 py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-medium text-black truncate" style="padding-left: {{ $tree_level * 30 }}px;">
@if (!$hide_code_column && (empty(config('type.category.' . $sub_category->type . '.hide', [])) || ! in_array('code', config('type.category.' . $sub_category->type . '.hide'))))
<x-table.td class="w-1/12 py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-medium text-black truncate">
@if(!empty($sub_category->code))
{{ $sub_category->code }}
@else
<x-empty-data />
@endif
</x-table.td>
@endif
<x-table.td class="relative {{ $name_class }} py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-medium text-black truncate" style="padding-left: {{ $tree_level * 30 }}px;">
<div class="flex items-center ml-2">
@if ($sub_category->sub_categories->count())
<x-tooltip id="tooltip-category-{{ $parent_category->id }}" placement="bottom" message="{{ trans('categories.collapse') }}">
@ -12,31 +66,29 @@
type="button"
class="w-4 h-4 flex items-center justify-center mx-2 leading-none align-text-top rounded-lg "
node="child-{{ $sub_category->id }}"
onClick="toggleSub('child-{{ $sub_category->id }}', event)"
onClick="toggleSub('child-{{ $sub_category->id }}', event)"
>
<span class="material-icons -ml-2 transform rotate-90 transition-all text-xl leading-none align-middle rounded-full text-white bg-{{ $sub_category->color }}" style="background-color:{{ $sub_category->color }};">chevron_right</span>
</button>
</x-tooltip>
<div class="flex items-center font-bold table-submenu">
{{ $sub_category->name }}
{{ $sub_category->name }}
</div>
</div>
@else
<div class="flex items-center ml-2">
@else
<span class="material-icons text-3xl text-{{ $sub_category->color }}" style="color:{{ $sub_category->color }};">circle</span>
<div class="flex items-center font-bold table-submenu ltr:ml-2 rtl:mr-2">
{{ $sub_category->name }}
</div>
</div>
@endif
@endif
</div>
@if (! $sub_category->enabled)
<x-index.disable text="{{ trans_choice('general.categories', 1) }}" />
@endif
</x-table.td>
<x-table.td class="w-6/12 py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-normal text-black cursor-pointer truncate">
<x-table.td class="w-3/12 py-4 ltr:text-left rtl:text-right whitespace-nowrap text-sm font-normal text-black cursor-pointer truncate">
@if (! empty($types[$item->type]))
{{ $types[$item->type] }}
@else
@ -44,6 +96,10 @@
@endif
</x-table.td>
<x-table.td class="w-2/12 py-4 ltr:text-right rtl:text-left whitespace-nowrap text-sm font-normal text-black cursor-pointer truncate">
<x-index.balance :amount="$sub_category->balance" />
</x-table.td>
<x-table.td kind="action">
<x-table.actions :model="$sub_category" />
</x-table.td>
@ -55,6 +111,10 @@
@endphp
@foreach($sub_category->sub_categories as $sub_category)
@include('settings.categories.sub_category', ['parent_category' => $parent_category, 'sub_category' => $sub_category, 'tree_level' => $tree_level])
@php
$sub_category->load(['sub_categories']);
@endphp
@include('settings.categories.sub_category', ['parent_category' => $parent_category, 'sub_category' => $sub_category, 'tree_level' => $tree_level, 'hide_code_column' => $hide_code_column, 'name_class' => $name_class])
@endforeach
@endif
@endif

View File

@ -31,9 +31,9 @@
</x-slot>
<x-slot name="body">
<x-form.group.select remote name="income_category" label="{{ trans('settings.default.income_category') }}" :options="$sales_categories" :clearable="'false'" :selected="setting('default.income_category')" remote_action="{{ route('categories.index'). '?search=type:income enabled:1' }}" sort-options="false" />
<x-form.group.select remote name="income_category" label="{{ trans('settings.default.income_category') }}" :options="$sales_categories" :clearable="'false'" :selected="setting('default.income_category')" remote_action="{{ route('categories.index'). '?search=type:' . $income_category_types . ' enabled:1' }}" sort-options="false" />
<x-form.group.select remote name="expense_category" label="{{ trans('settings.default.expense_category') }}" :options="$purchases_categories" :clearable="'false'" :selected="setting('default.expense_category')" remote_action="{{ route('categories.index'). '?search=type:expense enabled:1' }}" sort-options="false" />
<x-form.group.select remote name="expense_category" label="{{ trans('settings.default.expense_category') }}" :options="$purchases_categories" :clearable="'false'" :selected="setting('default.expense_category')" remote_action="{{ route('categories.index'). '?search=type:' . $expense_category_types . ' enabled:1' }}" sort-options="false" />
</x-slot>
</x-form.section>

View File

@ -95,13 +95,27 @@
without-add-new
/>
@break
@case('toggle')
@case('toggleGroup')
@php $value = setting($module->getAlias() . '.' . $field['name'], $field['value'] ?? null); @endphp
<x-form.group.toggle
name="{{ $field['name'] }}"
label="{{ trans($field['title']) }}"
enable="{{ $field['enable'] ? trans($field['enable']) : '' }}"
disable="{{ $field['disable'] ? trans($field['disable']) : '' }}"
:value="$value"
:dynamic-attributes="$field['attributes']"
/>
@break
@default
@php
$type = str_replace('Group', '', $type);
$componentName = 'form.group.' . $type;
$value = setting($module->getAlias() . '.' . $field['name'], $field['value'] ?? null);
@endphp
<x-dynamic-component :component="$componentName" name="{{ $field['name'] }}" label="{{ trans($field['title']) }}" :dynamic-attributes="$field['attributes']" />
<x-dynamic-component :component="$componentName" name="{{ $field['name'] }}" label="{{ trans($field['title']) }}" :value="$value" :dynamic-attributes="$field['attributes']" />
@endswitch
@endforeach

View File

@ -4,6 +4,9 @@ namespace Tests\Feature\Purchases;
use App\Exports\Purchases\Bills\Bills as Export;
use App\Jobs\Document\CreateDocument;
use App\Jobs\Document\UpdateDocument;
use App\Models\Banking\Transaction;
use App\Models\Common\Contact;
use App\Models\Document\Document;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
@ -235,6 +238,46 @@ class BillsTest extends FeatureTestCase
$this->assertFlashLevel('success');
}
public function testItShouldUpdateTransactionContactWhenBillVendorChanges()
{
// Create two different vendors
$vendorA = Contact::factory()->vendor()->enabled()->create();
$vendorB = Contact::factory()->vendor()->enabled()->create();
// Create a bill with vendor A (must be draft so authorize() allows contact change)
$request = $this->getRequest();
$request['status'] = 'draft';
$request['contact_id'] = $vendorA->id;
$request['contact_name'] = $vendorA->name;
$request['contact_email'] = $vendorA->email;
$bill = $this->dispatch(new CreateDocument($request));
// Create a transaction (payment) for this bill with vendor A
$transaction = Transaction::factory()->expense()->create([
'document_id' => $bill->id,
'contact_id' => $vendorA->id,
'type' => 'expense',
]);
// Verify transaction has vendor A's contact_id
$this->assertEquals($vendorA->id, $transaction->contact_id);
// Update the bill to use vendor B
$request['contact_id'] = $vendorB->id;
$request['contact_name'] = $vendorB->name;
$request['contact_email'] = $vendorB->email;
$this->dispatch(new UpdateDocument($bill, $request));
// Refresh the transaction from database
$transaction->refresh();
// Verify transaction's contact_id was updated to vendor B
$this->assertEquals($vendorB->id, $transaction->contact_id);
}
public function getRequest($recurring = false)
{
$factory = Document::factory();