Compare commits

..

7 Commits

Author SHA1 Message Date
WebShells
19184b50c6 Normalize line endings 2026-05-22 11:52:08 +03:00
WebShells
1a6cfffc27 Fix locale parsing 2026-05-20 23:37:54 +03:00
WebShells
5e0541c53e Restore secondary display sales flow 2026-05-19 21:33:54 +03:00
objecttothis
b0dddc22a3 Bugfixes to get Migration working on MySQL and MariaDB (#4551)
* Bugfixes to get Migration working on MySQL

Signed-off-by: objec <objecttothis@gmail.com>

* MariaDB compatibility fixes

- Drop foreign key constraints before making charset changes
- Fix dropAllForeignKeyConstraints helper function.
- Added `IF EXISTS` to DROP statements
- Do not try to readd FK constraints for tables which were dropped.
- MariaDB 11.8.x changes the default implicit collation to uca1400 which breaks the IndiaGST migration, et. al. Explicitly declare utf8_general_ci in affected migrations.

Signed-off-by: objec <objecttothis@gmail.com>

* Fix changes which break MySQL migrations

- MySQL does not support IF EXISTS in foreign key constraints. Since the PHP is now handling dropping those constraints, these lines are redundant. Remove them.

Signed-off-by: objec <objecttothis@gmail.com>

* Resolve code review recommendations

- Add try/catch around DB connect statement
- Heed result of execute_script function and throw an exception on failure.

Signed-off-by: objec <objecttothis@gmail.com>

* Refactor out duplicate code

Signed-off-by: objec <objecttothis@gmail.com>

* Initialize array variable causing potential issues

Signed-off-by: objec <objecttothis@gmail.com>

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-05-19 16:02:05 +04:00
jekkos
8d6b166673 feat: Add deployment workflow with approval gates (#4522)
* feat: Add deployment workflows with approval gates

Add GitHub Actions workflows for controlled deployments:

deploy.yml - Manual Deploy:
- Triggered via Actions UI (workflow_dispatch)
- Select environment (production/staging)
- Select Docker image tag
- Reusable via workflow_call for other workflows
- Creates GitHub deployment records with status tracking
- Sends Docker Hub compatible webhook payload
- Environment input validation for workflow_call

deploy-pr.yml - PR Deploy:
- Auto-triggers when PR is approved (same-repo only)
- Deploys to staging environment
- Image tag format: pr-{number}-{short-sha}
- Posts deployment status as PR comment
- Fork PR protection: only runs for same-repo PRs

Security:
- jq-based JSON payload construction (prevents script injection)
- HMAC-SHA256 signature verification for webhook
- Untrusted inputs via env: blocks (not inline interpolation)
- Environment validation before deployment
- Fork detection guard for PR deployments

Fixes CodeRabbit review comments:
- Invalid jq string filter syntax (missing quotes)
- Unvalidated environment input in workflow_call
- Fork PR deployments blocked by pull_request_review restrictions

* refactor: Limit deployment to staging only

- Remove environment input choice (was production/staging)
- Hardcode environment to 'staging' throughout
- Simplify workflow - no environment validation needed
- Update concurrency group to deploy-staging

* refactor: Extract deployment logic to reusable deploy-core.yml

Restructure workflows to eliminate code duplication:

deploy-core.yml (new):
- Reusable workflow with all deployment logic
- Creates GitHub deployment record
- Sends webhook payload to external service
- Handles status updates
- Accepts image_tag, sha, description, pr_number inputs
- Outputs deployment_id and status

deploy.yml (simplified):
- Manual trigger only
- Calls deploy-core with user-provided image_tag
- 18 lines (was 175)

deploy-pr.yml (simplified):
- PR approval trigger with fork guard
- Prepare job: checkout, generate PR image tag
- Deploy job: calls deploy-core
- Comment job: post status to PR
- 70 lines (was 204)

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-18 21:48:02 +02:00
jekkos
093ec7fb13 fix: validate attributeId > 0 in saveAttributeLink() (#4508)
- Add early validation to reject attributeId <= 0
- Ensure consistent handling of invalid attribute_id in INSERT/UPDATE paths
- Prevent foreign key constraint violations from invalid attribute references

Fixes #4460

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-18 14:13:20 +02:00
jekkos
9c89a2e2cb fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507)
* fix: capture CSV import failures in save_tax_data and save_inventory_quantities

- Change save_tax_data() return type from void to bool
- Change save_inventory_quantities() return type from void to bool
- Accumulate failure status with &= operator in save_inventory_quantities
- Update postImportCsvFile() to capture return values and set isFailedRow
- Properly propagate failures to failCodes array

Fixes #4475

* fix: Change isset to !empty for items_taxes_data check

- isset was always true since array was initialized
- Use !empty to properly check if there are tax items to save

Address CodeRabbit review feedback

* fix: Capture inventory insert result in save_inventory_quantities

- Combine inventory insert result with success tracking
- Use &= operator to accumulate failures from both operations
- Ensure failures from inventory inserts are propagated

Address CodeRabbit review feedback

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-17 22:23:43 +02:00
72 changed files with 1467 additions and 488 deletions

219
.github/workflows/deploy-core.yml vendored Normal file
View File

@@ -0,0 +1,219 @@
name: Deploy Core
on:
workflow_call:
inputs:
image_tag:
description: 'Docker image tag to deploy'
type: string
required: true
sha:
description: 'Git commit SHA to deploy'
type: string
required: true
description:
description: 'Deployment description'
type: string
required: true
pr_number:
description: 'Pull request number (optional)'
type: string
required: false
outputs:
deployment_id:
description: 'GitHub deployment ID'
value: ${{ jobs.deploy.outputs.deployment_id }}
status:
description: 'Deployment status (success/failure)'
value: ${{ jobs.deploy.outputs.status }}
concurrency:
group: deploy-staging
cancel-in-progress: false
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy to staging
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ vars.DEPLOY_URL || 'https://dev.opensourcepos.org' }}
deployment: false
outputs:
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
status: ${{ steps.webhook.outputs.status }}
steps:
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
REF_SHA: ${{ inputs.sha }}
DESCRIPTION: ${{ inputs.description }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="staging" \
-f description="${DESCRIPTION}" \
-F auto_merge=false \
-F required_contexts[] \
--jq '.id')
if [ -z "$DEPLOYMENT_ID" ]; then
echo "::error::Failed to create deployment"
exit 1
fi
echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT"
echo "Created deployment: $DEPLOYMENT_ID"
- name: Set deployment status to in_progress
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="in_progress" \
-f description="Deployment in progress..." \
-f log_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- name: Trigger deployment webhook
id: webhook
env:
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
DEPLOY_WEBHOOK_SECRET: ${{ secrets.DEPLOY_WEBHOOK_SECRET }}
DOCKER_REPO_NAME: ${{ secrets.DOCKER_REPO_NAME }}
IMAGE_TAG: ${{ inputs.image_tag }}
REF_SHA: ${{ inputs.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
if [ -z "$DEPLOY_WEBHOOK_URL" ]; then
echo "::error::DEPLOY_WEBHOOK_URL secret is not configured"
echo "Please add the DEPLOY_WEBHOOK_URL secret in your repository settings"
echo "status=failure" >> "$GITHUB_OUTPUT"
exit 1
fi
REPO_NAME="${DOCKER_REPO_NAME:-opensourcepos/opensourcepos}"
REPO_NAMESPACE="${REPO_NAME%%/*}"
REPO_SHORT_NAME="${REPO_NAME#*/}"
PUSHED_AT=$(date +%s)
if [ -n "$PR_NUMBER" ]; then
PAYLOAD=$(jq -n \
--arg callback_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--argjson pushed_at "$PUSHED_AT" \
--arg pusher "$GITHUB_ACTOR" \
--arg tag "$IMAGE_TAG" \
--arg repo_name "$REPO_NAME" \
--arg name "$REPO_SHORT_NAME" \
--arg namespace "$REPO_NAMESPACE" \
--arg repo_url "https://hub.docker.com/r/${REPO_NAME}/" \
--arg deployment_id "$DEPLOYMENT_ID" \
--arg repository "$GITHUB_REPOSITORY" \
--arg sha "$REF_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
--arg actor "$GITHUB_ACTOR" \
--argjson pr_number "$PR_NUMBER" \
'{
callback_url: $callback_url,
push_data: {pushed_at: $pushed_at, pusher: $pusher, tag: $tag},
repository: {repo_name: $repo_name, name: $name, namespace: $namespace, repo_url: $repo_url, status: "Active"},
github_deployment: {id: $deployment_id, environment: "staging", repository: $repository, sha: $sha, run_id: $run_id, actor: $actor, pull_request: $pr_number}
}')
else
PAYLOAD=$(jq -n \
--arg callback_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--argjson pushed_at "$PUSHED_AT" \
--arg pusher "$GITHUB_ACTOR" \
--arg tag "$IMAGE_TAG" \
--arg repo_name "$REPO_NAME" \
--arg name "$REPO_SHORT_NAME" \
--arg namespace "$REPO_NAMESPACE" \
--arg repo_url "https://hub.docker.com/r/${REPO_NAME}/" \
--arg deployment_id "$DEPLOYMENT_ID" \
--arg repository "$GITHUB_REPOSITORY" \
--arg sha "$REF_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
--arg actor "$GITHUB_ACTOR" \
'{
callback_url: $callback_url,
push_data: {pushed_at: $pushed_at, pusher: $pusher, tag: $tag},
repository: {repo_name: $repo_name, name: $name, namespace: $namespace, repo_url: $repo_url, status: "Active"},
github_deployment: {id: $deployment_id, environment: "staging", repository: $repository, sha: $sha, run_id: $run_id, actor: $actor}
}')
fi
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "Environment: staging"
HEADERS=(-H "Content-Type: application/json")
if [ -n "$DEPLOY_WEBHOOK_SECRET" ]; then
SIGNATURE=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$DEPLOY_WEBHOOK_SECRET" | sed 's/.*= //')
HEADERS+=(-H "X-Hub-Signature-256: sha256=$SIGNATURE")
echo "Using HMAC-SHA256 signature verification"
else
echo "::warning::DEPLOY_WEBHOOK_SECRET not set - webhook calls will not be signed"
echo "For security, configure DEPLOY_WEBHOOK_SECRET in your repository settings"
fi
HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 120 \
-o response.txt -w "%{http_code}" \
-X POST \
"${HEADERS[@]}" \
-d "$PAYLOAD" \
"$DEPLOY_WEBHOOK_URL") || HTTP_CODE="000"
echo "Response code: $HTTP_CODE"
if [ -s response.txt ]; then
cat response.txt
fi
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "status=success" >> "$GITHUB_OUTPUT"
else
echo "status=failure" >> "$GITHUB_OUTPUT"
fi
- name: Set deployment status
if: always()
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" \
'"Deployed image \($tag) to staging"')
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="success" \
-f description="$DESCRIPTION"
else
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="failure" \
-f description="Deployment failed"
exit 1
fi

79
.github/workflows/deploy-pr.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: PR Deploy
on:
pull_request_review:
types: [submitted]
concurrency:
group: staging-deploy
cancel-in-progress: false
permissions:
contents: read
deployments: write
pull-requests: write
jobs:
prepare:
name: Prepare deployment
runs-on: ubuntu-latest
if: >
github.event.review.state == 'approved' &&
github.event.pull_request.head.repo.full_name == github.repository
outputs:
image_tag: ${{ steps.image.outputs.tag }}
sha: ${{ github.event.pull_request.head.sha }}
pr_number: ${{ github.event.pull_request.number }}
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get image tag
id: image
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
IMAGE_TAG="pr-${PR_NUMBER}-${PR_SHA:0:7}"
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
deploy:
name: Deploy to staging
needs: prepare
uses: ./.github/workflows/deploy-core.yml
with:
image_tag: ${{ needs.prepare.outputs.image_tag }}
sha: ${{ needs.prepare.outputs.sha }}
description: Deploy PR #${{ needs.prepare.outputs.pr_number }} to staging
pr_number: ${{ needs.prepare.outputs.pr_number }}
secrets: inherit
comment:
name: Comment deployment status
needs: [prepare, deploy]
if: always()
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
REF_SHA: ${{ needs.prepare.outputs.sha }}
STATUS: ${{ needs.deploy.outputs.status }}
steps:
- name: Comment on PR
run: |
if [ "$STATUS" = "success" ]; then
BODY=$(jq -nr --arg tag "$IMAGE_TAG" --arg sha "$REF_SHA" --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'"✅ **Staging deployment completed**\n\n🔗 **URL**: https://dev.opensourcepos.org\n📦 **Image Tag**: `\($tag)`\n🔨 **Commit**: \($sha)\n\nView logs: \($url)"')
else
BODY=$(jq -nr --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'"❌ **Staging deployment failed**\n\nCheck the [workflow logs](\($url)) for details."')
fi
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
-X POST \
-f body="$BODY"

23
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Deploy
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to deploy (e.g., v3.4.0, latest)'
required: true
default: 'latest'
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy to staging
uses: ./.github/workflows/deploy-core.yml
with:
image_tag: ${{ inputs.image_tag }}
sha: ${{ github.sha }}
description: Deploy image ${{ inputs.image_tag }}
secrets: inherit

View File

@@ -5,6 +5,7 @@ namespace Config;
use App\Models\Appconfig;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Config\BaseConfig;
use Config\Database;
/**
* This class holds the configuration options stored from the database so that on launch those settings can be cached
@@ -13,7 +14,7 @@ use CodeIgniter\Config\BaseConfig;
*/
class OSPOS extends BaseConfig
{
public array $settings;
public array $settings = [];
public string $commit_sha1 = 'dev'; // TODO: Travis scripts need to be updated to replace this with the commit hash on build
private CacheInterface $cache;
@@ -33,26 +34,35 @@ class OSPOS extends BaseConfig
if ($cache) {
$this->settings = decode_array($cache);
} else {
try {
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
// Database table doesn't exist yet (migrations haven't run)
// or database connection failed. Return empty settings to
// allow migration page to display. Catches mysqli_sql_exception
// which is not a subclass of DatabaseException.
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
return;
}
try {
$db = Database::connect();
if (!$db->tableExists('app_config')) {
$this->settings = $this->getDefaultSettings();
return;
}
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
$this->settings = $this->getDefaultSettings();
}
}
private function getDefaultSettings(): array
{
return [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
/**
@@ -63,4 +73,4 @@ class OSPOS extends BaseConfig
$this->cache->delete('settings');
$this->set_settings();
}
}
}

View File

@@ -1,4 +1,4 @@
<?php
<?php
use CodeIgniter\Router\RouteCollection;
@@ -12,6 +12,40 @@ $routes->get('login', 'Login::index');
$routes->post('login', 'Login::index');
$routes->post('migrate', 'Login::migrate');
$routes->get('sales', 'Sales::getIndex');
$routes->get('sales/customerDisplay', 'Sales::getCustomerDisplay');
$routes->get('sales/itemSearch', 'Sales::getItemSearch');
$routes->post('sales/selectCustomer', 'Sales::postSelectCustomer');
$routes->post('sales/changeMode', 'Sales::postChangeMode');
$routes->post('sales/setComment', 'Sales::postSetComment');
$routes->post('sales/setInvoiceNumber', 'Sales::postSetInvoiceNumber');
$routes->post('sales/setPaymentType', 'Sales::postSetPaymentType');
$routes->post('sales/setPrintAfterSale', 'Sales::postSetPrintAfterSale');
$routes->post('sales/setPriceWorkOrders', 'Sales::postSetPriceWorkOrders');
$routes->post('sales/setEmailReceipt', 'Sales::postSetEmailReceipt');
$routes->post('sales/addPayment', 'Sales::postAddPayment');
$routes->post('sales/add', 'Sales::postAdd');
$routes->post('sales/editItem/(:segment)', 'Sales::postEditItem/$1');
$routes->post('sales/deleteItem/(:segment)', 'Sales::getDeleteItem/$1');
$routes->post('sales/deletePayment/(:segment)', 'Sales::getDeletePayment/$1');
$routes->post('sales/removeCustomer', 'Sales::getRemoveCustomer');
$routes->post('sales/complete', 'Sales::postComplete');
$routes->post('sales/cancel', 'Sales::postCancel');
$routes->post('sales/suspend', 'Sales::postSuspend');
$routes->post('sales/unsuspend', 'Sales::postUnsuspend');
$routes->post('sales/checkInvoiceNumber', 'Sales::postCheckInvoiceNumber');
$routes->post('sales/changeItemNumber', 'Sales::postChangeItemNumber');
$routes->post('sales/changeItemName', 'Sales::postChangeItemName');
$routes->post('sales/changeItemDescription', 'Sales::postChangeItemDescription');
$routes->get('sales/suspended', 'Sales::getSuspended');
$routes->get('sales/discardSuspendedSale', 'Sales::getDiscardSuspendedSale');
$routes->get('sales/sales_keyboard_help', 'Sales::getSalesKeyboardHelp');
$routes->get('sales/receipt/(:num)', 'Sales::getReceipt/$1');
$routes->get('sales/invoice/(:num)', 'Sales::getInvoice/$1');
$routes->get('sales/edit/(:num)', 'Sales::getEdit/$1');
$routes->post('sales/delete/(:num)', 'Sales::postDelete/$1');
$routes->post('sales/save/(:num)', 'Sales::postSave/$1');
$routes->add('no_access/index/(:segment)', 'No_access::index/$1');
$routes->add('no_access/index/(:segment)/(:segment)', 'No_access::index/$1/$2');

View File

@@ -246,7 +246,7 @@ class Attributes extends Secure_Controller
$data['definition_group'][''] = lang('Common.none_selected_text');
$data['definition_info'] = $info;
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH;
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES;
$data['definition_flags'] = $this->get_attributes($show_all);
$selected_flags = $info->definition_flags === '' ? $show_all : $info->definition_flags;
$data['selected_definition_flags'] = $this->get_attributes($selected_flags);

View File

@@ -82,7 +82,7 @@ class Config extends Secure_Controller
$npmDev = false;
$license = [];
$license[$i]['title'] = 'Open Source Point of Sale ' . config('App')->application_version;
$license[$i]['title'] = 'Open Source Point Of Sale ' . config('App')->application_version;
if (file_exists('license/LICENSE')) {
$license[$i]['text'] = file_get_contents('license/LICENSE', false, null, 0, 3000);
@@ -241,6 +241,28 @@ class Config extends Secure_Controller
$data['show_office_group'] = $this->module->get_show_office_group();
$data['currency_code'] = $this->config['currency_code'] ?? '';
$data['dbVersion'] = mysqli_get_server_info($this->db->getConnection());
$data['scale_export_formats'] = [
'txt' => 'TXT',
'csv' => 'CSV',
];
$data['scale_export_charsets'] = [
'windows-1256' => 'Windows-1256',
'utf-8' => 'UTF-8',
'windows-1252' => 'Windows-1252',
];
$data['scale_export_delimiters'] = [
';' => ';',
',' => ',',
"\t" => 'Tab',
];
$data['scale_export_fields_options'] = [
'legacy_code' => lang('Items.item_number'),
'item_number' => lang('Items.item_number'),
'repeat_item_number' => lang('Items.item_number'),
'name' => lang('Items.name'),
'unit_price' => lang('Items.unit_price'),
'legacy_tail' => lang('Items.item_number'),
];
// Load all the license statements, they are already XSS cleaned in the private function
$data['licenses'] = $this->_licenses();
@@ -394,6 +416,7 @@ class Config extends Secure_Controller
'suggestions_third_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_third_column'), 'other'),
'giftcard_number' => $this->request->getPost('giftcard_number'),
'derive_sale_quantity' => $this->request->getPost('derive_sale_quantity') != null,
'customer_display_enabled' => $this->request->getPost('customer_display_enabled') != null,
'multi_pack_enabled' => $this->request->getPost('multi_pack_enabled') != null,
'include_hsn' => $this->request->getPost('include_hsn') != null,
'category_dropdown' => $this->request->getPost('category_dropdown') != null
@@ -474,13 +497,36 @@ class Config extends Secure_Controller
*/
public function postSaveLocale(): ResponseInterface
{
$exploded = explode(":", $this->request->getPost('language'));
$language = trim((string) $this->request->getPost('language'));
$languageCode = 'en';
$languageName = 'english';
if ($language !== '' && str_contains($language, ':')) {
$exploded = array_map('trim', explode(':', $language, 2));
if (count($exploded) === 2) {
$languageCode = htmlspecialchars($exploded[0]);
$languageName = htmlspecialchars($exploded[1]);
}
}
$currency_symbol = $this->request->getPost('currency_symbol');
$secondaryCurrencyCode = strtoupper(trim((string) $this->request->getPost('secondary_currency_code')));
if (!preg_match('/^[A-Z]{3}$/', $secondaryCurrencyCode)) {
$secondaryCurrencyCode = '';
}
$batch_save_data = [
'currency_symbol' => htmlspecialchars($currency_symbol ?? ''),
'currency_code' => $this->request->getPost('currency_code'),
'language_code' => $exploded[0],
'language' => $exploded[1],
'secondary_currency_enabled' => $this->request->getPost('secondary_currency_enabled') != null,
'secondary_currency_symbol' => htmlspecialchars($this->request->getPost('secondary_currency_symbol') ?? ''),
'secondary_currency_code' => $secondaryCurrencyCode,
'secondary_currency_rate' => $this->request->getPost('secondary_currency_rate', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
'secondary_currency_decimals' => $this->request->getPost('secondary_currency_decimals', FILTER_SANITIZE_NUMBER_INT),
'language_code' => $languageCode,
'language' => $languageName,
'timezone' => $this->request->getPost('timezone'),
'dateformat' => $this->request->getPost('dateformat'),
'timeformat' => $this->request->getPost('timeformat'),
@@ -924,9 +970,7 @@ class Config extends Secure_Controller
public function postSaveReceipt(): ResponseInterface
{
$batch_save_data = [
'receipt_template' => Sale_lib::isValidReceiptTemplate($this->request->getPost('receipt_template'))
? $this->request->getPost('receipt_template')
: 'receipt_default',
'receipt_template' => $this->request->getPost('receipt_template'),
'receipt_font_size' => $this->request->getPost('receipt_font_size', FILTER_SANITIZE_NUMBER_INT),
'print_delay_autoreturn' => $this->request->getPost('print_delay_autoreturn', FILTER_SANITIZE_NUMBER_INT),
'email_receipt_check_behaviour' => $this->request->getPost('email_receipt_check_behaviour'),
@@ -936,6 +980,7 @@ class Config extends Secure_Controller
'receipt_show_tax_ind' => $this->request->getPost('receipt_show_tax_ind') != null,
'receipt_show_total_discount' => $this->request->getPost('receipt_show_total_discount') != null,
'receipt_show_description' => $this->request->getPost('receipt_show_description') != null,
'receipt_show_secondary_currency' => $this->request->getPost('receipt_show_secondary_currency') != null,
'receipt_show_serialnumber' => $this->request->getPost('receipt_show_serialnumber') != null,
'print_silently' => $this->request->getPost('print_silently') != null,
'print_header' => $this->request->getPost('print_header') != null,
@@ -964,7 +1009,7 @@ class Config extends Secure_Controller
$batchSaveData = [];
foreach ($currentShortcuts as $name => $shortcut) {
$postedValue = trim((string)$this->request->getPost('key_' . $name));
$postedValue = trim((string) $this->request->getPost('key_' . $name));
if (!in_array($postedValue, $allowedShortcuts, true)) {
$postedValue = $shortcut['value'];
@@ -1068,3 +1113,6 @@ class Config extends Secure_Controller
return in_array($column, $allowed, true) ? $column : $fallback;
}
}

View File

@@ -105,14 +105,13 @@ class Items extends Secure_Controller
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$sort = $this->sanitizeSortColumn(item_sort_columns(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'items.item_id');
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$this->item_lib->set_item_location($this->request->getGet('stock_location'));
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$filters = [
'start_date' => $this->request->getGet('start_date'),
'end_date' => $this->request->getGet('end_date'),
@@ -130,13 +129,6 @@ class Items extends Secure_Controller
// Check if any filter is set in the multiselect dropdown
$request_filters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
$filters = array_merge($filters, $request_filters);
// When search_custom is enabled, include attributes that are searchable but may not be visible in table
if (!empty($filters['search_custom'])) {
$searchable_definitions = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
$filters['definition_ids'] = array_keys($searchable_definitions);
}
$items = $this->item->search($search, $filters, $limit, $offset, $sort, $order);
$total_rows = $this->item->get_found_rows($search, $filters);
$data_rows = [];
@@ -1063,14 +1055,20 @@ class Items extends Secure_Controller
});
if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
$this->save_tax_data($row, $itemData);
$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId);
if (!$this->save_tax_data($row, $itemData)) {
$isFailedRow = true;
}
if (!$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId)) {
$isFailedRow = true;
}
$csvAttributeValues = $this->extractAttributeData($row);
$isFailedRow = !$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData);
if (!$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData)) {
$isFailedRow = true;
}
if ($isFailedRow) {
$failedRow = $key + 2;
$failCodes[] = $failedRow;
log_message('error', "CSV Item import failed on line $failedRow while saving attributes.");
log_message('error', "CSV Item import failed on line $failedRow while saving item.");
continue;
}
@@ -1260,13 +1258,15 @@ class Items extends Secure_Controller
* @param array $item_data
* @param array $allowed_locations
* @param int $employee_id
* @return bool Returns true on success, false on failure
* @throws ReflectionException
*/
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): void
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): bool
{
// Quantities & Inventory Section
$comment = lang('Items.inventory_CSV_import_quantity');
$is_update = (bool)$row['Id'];
$success = true;
foreach ($allowed_locations as $location_id => $location_name) {
$item_quantity_data = ['item_id' => $item_data['item_id'], 'location_id' => $location_id];
@@ -1280,20 +1280,22 @@ class Items extends Secure_Controller
if (!empty($row["location_$location_name"]) || $row["location_$location_name"] === '0') {
$item_quantity_data['quantity'] = $row["location_$location_name"];
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = $row["location_$location_name"];
$this->inventory->insert($csv_data, false);
$success &= (bool)$this->inventory->insert($csv_data, false);
} elseif ($is_update) {
return;
continue;
} else {
$item_quantity_data['quantity'] = 0;
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = 0;
$this->inventory->insert($csv_data, false);
$success &= (bool)$this->inventory->insert($csv_data, false);
}
}
return (bool)$success;
}
/**
@@ -1301,8 +1303,9 @@ class Items extends Secure_Controller
*
* @param array $row
* @param array $item_data
* @return bool Returns true on success, false on failure
*/
private function save_tax_data(array $row, array $item_data): void
private function save_tax_data(array $row, array $item_data): bool
{
$items_taxes_data = [];
@@ -1314,9 +1317,11 @@ class Items extends Secure_Controller
$items_taxes_data[] = ['name' => $row['Tax 2 Name'], 'percent' => $row['Tax 2 Percent']];
}
if (isset($items_taxes_data)) {
$this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
if (!empty($items_taxes_data)) {
return $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
}
return true;
}
/**

View File

@@ -49,6 +49,13 @@ class Login extends BaseController
return view('login', $data);
}
if (!$data['is_latest'] || $data['is_new_install']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
$rules = ['username' => 'required|login_check[data]'];
$messages = [
'username' => [
@@ -62,13 +69,6 @@ class Login extends BaseController
return view('login', $data);
}
if (!$data['is_latest']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
}
return redirect()->to('home');
@@ -79,18 +79,18 @@ class Login extends BaseController
try {
$migration = new MY_Migration(config('Migrations'));
$migration->migrate_to_ci4();
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return $this->response->setJSON([
'success' => true,
'message' => 'Migration completed successfully'
]);
} catch (\Exception $e) {
log_message('error', 'Migration failed: ' . $e->getMessage());
return $this->response->setJSON([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage()

View File

@@ -66,12 +66,168 @@ class Sales extends Secure_Controller
$this->employee = model(Employee::class);
}
/**
* Adds the shared secondary currency context to a view data array.
*
* @param array $data
* @return void
*/
private function _append_secondary_currency(array &$data): void
{
$secondaryCurrency = secondary_currency_context($this->config);
$data['secondaryCurrency'] = $secondaryCurrency;
if (!$secondaryCurrency['show']) {
return;
}
$displayFields = [
'total' => 'secondaryTotalDisplay',
'amount_due' => 'secondaryAmountDueDisplay',
'cash_amount_due' => 'secondaryCashAmountDueDisplay',
'non_cash_total' => 'secondaryNonCashTotalDisplay',
'non_cash_amount_due' => 'secondaryNonCashAmountDueDisplay'
];
foreach ($displayFields as $sourceField => $targetField) {
if (array_key_exists($sourceField, $data)) {
$data[$targetField] = to_secondary_currency((float) $data[$sourceField], $secondaryCurrency);
}
}
}
public function getIndex(): ResponseInterface|string
{
$this->session->set('allow_temp_items', 1);
return $this->_reload(); // TODO: Hungarian Notation
}
/**
* Load the customer display popup.
*
* @return ResponseInterface|string
* @noinspection PhpUnused
*/
public function getCustomerDisplay(): ResponseInterface|string
{
if (($this->config['customer_display_enabled'] ?? false) != 1) {
return $this->response->setStatusCode(404)->setBody('');
}
if ($this->session->get('sale_id') == '') {
$this->session->set('sale_id', NEW_ENTRY);
}
$secondaryCurrency = secondary_currency_context($this->config);
$secondaryCurrencyEnabled = (($this->config['secondary_currency_enabled'] ?? false) == 1);
$cashRounding = $this->sale_lib->reset_cash_rounding();
$showCustomerDisplay = $secondaryCurrencyEnabled && !empty($secondaryCurrency['rate']) && (float) $secondaryCurrency['rate'] > 0;
$companyLines = preg_split("/\r\n|\r|\n/", (string) ($this->config['company'] ?? '')) ?: [];
$companyName = array_shift($companyLines) ?? '';
$companyDetails = trim(implode("\n", $companyLines));
$secondaryCurrencySymbol = trim((string) ($this->config['secondary_currency_symbol'] ?? ''));
$secondaryCurrencyCode = trim((string) ($this->config['secondary_currency_code'] ?? ''));
$originalCurrencySymbol = trim((string) ($this->config['currency_symbol'] ?? ''));
$customerDisplayCurrencyLabel = $secondaryCurrencyCode !== '' ? $secondaryCurrencyCode : ($secondaryCurrencySymbol !== '' ? $secondaryCurrencySymbol : 'LBP');
$originalCurrencyLabel = $originalCurrencySymbol !== '' ? $originalCurrencySymbol : '$';
$cartHasCustomerDisplay = $showCustomerDisplay;
$cartColspan = $cartHasCustomerDisplay ? 6 : 5;
$cartItemWidth = $cartHasCustomerDisplay ? 32 : 44;
$cartPriceWidth = $cartHasCustomerDisplay ? 18 : 0;
$cartOriginalWidth = $cartHasCustomerDisplay ? 18 : 26;
$cartQuantityWidth = $cartHasCustomerDisplay ? 12 : 10;
$cartDiscountWidth = $cartHasCustomerDisplay ? 10 : 9;
$cartTotalWidth = $cartHasCustomerDisplay ? 10 : 11;
$data = [
'cash_rounding' => $cashRounding,
'cart' => $this->sale_lib->get_cart()
];
$customer_info = $this->_load_customer_data($this->sale_lib->get_customer(), $data, true);
$data += [
'customer_name' => $data['customer'] ?? lang('Sales.walk_in_customer'),
'customer_reward_points' => (int) ($data['customer_rewards']['points'] ?? 0),
'customer_reward_package' => $data['customer_rewards']['package_name'] ?? '',
'giftcard_remainder' => $this->sale_lib->get_giftcard_remainder(),
'rewards_remainder' => $this->sale_lib->get_rewards_remainder(),
'customerName' => $data['customer'] ?? lang('Sales.walk_in_customer'),
'customerRewardPoints' => (int) ($data['customer_rewards']['points'] ?? 0),
'giftcardRemainder' => $this->sale_lib->get_giftcard_remainder()
];
$tax_details = $this->tax_lib->get_taxes($data['cart']);
$data += [
'tax_exclusive_subtotal' => $this->sale_lib->get_subtotal(true, true),
'taxes' => $tax_details[0],
'discount' => $this->sale_lib->get_discount(),
'payments' => $this->sale_lib->get_payments()
];
$totals = $this->sale_lib->get_totals($tax_details[0]);
$data += [
'item_count' => $totals['item_count'],
'total_units' => $totals['total_units'],
'subtotal' => $totals['subtotal'],
'total' => $totals['total'],
'payments_total' => $totals['payment_total'],
'payments_cover_total' => $totals['payments_cover_total'],
'prediscount_subtotal' => $totals['prediscount_subtotal'],
'cash_total' => $totals['cash_total'],
'non_cash_total' => $totals['total'],
'cash_amount_due' => $totals['cash_amount_due'],
'non_cash_amount_due' => $totals['amount_due'],
'cash_mode' => $this->session->get('cash_mode'),
'selected_payment_type' => $this->sale_lib->get_payment_type(),
'comment' => $this->sale_lib->get_comment(),
'email_receipt' => $this->sale_lib->is_email_receipt(),
'config' => $this->config,
'mode' => $this->sale_lib->get_mode(),
'rate' => (float) ($secondaryCurrency['rate'] ?? $this->config['secondary_currency_rate'] ?? 0),
'secondaryCurrency' => $secondaryCurrency,
'secondaryCurrencyEnabled' => $secondaryCurrencyEnabled,
'showCustomerDisplay' => $showCustomerDisplay,
'companyName' => $companyName,
'companyDetails' => $companyDetails,
'secondaryCurrencySymbol' => $secondaryCurrencySymbol,
'secondaryCurrencyCode' => $secondaryCurrencyCode,
'originalCurrencySymbol' => $originalCurrencySymbol,
'customerDisplayCurrencyLabel' => $customerDisplayCurrencyLabel,
'originalCurrencyLabel' => $originalCurrencyLabel,
'cartHasCustomerDisplay' => $cartHasCustomerDisplay,
'cartColspan' => $cartColspan,
'cartItemWidth' => $cartItemWidth,
'cartPriceWidth' => $cartPriceWidth,
'cartOriginalWidth' => $cartOriginalWidth,
'cartQuantityWidth' => $cartQuantityWidth,
'cartDiscountWidth' => $cartDiscountWidth,
'cartTotalWidth' => $cartTotalWidth,
'items_module_allowed' => $this->employee->has_grant('items', $this->employee->get_logged_in_employee_info()->person_id),
'change_price' => $this->employee->has_grant('sales_change_price', $this->employee->get_logged_in_employee_info()->person_id)
];
$invoice_number = $this->sale_lib->get_invoice_number();
if ($invoice_number == null || $invoice_number == '') {
$invoice_number = $this->token_lib->render($this->config['sales_invoice_format'], [], false);
}
$data += [
'invoice_number' => $invoice_number,
'print_after_sale' => $this->sale_lib->is_print_after_sale(),
'price_work_orders' => $this->sale_lib->is_price_work_orders(),
'pos_mode' => $data['mode'] == 'sale' || $data['mode'] == 'return',
'quote_number' => $this->sale_lib->get_quote_number(),
'work_order_number' => $this->sale_lib->get_work_order_number(),
'amount_due' => $data['cash_mode'] && ($data['selected_payment_type'] === lang('Sales.cash') || $data['payments_total'] > 0) ? $totals['cash_amount_due'] : $totals['amount_due']
];
$data['amount_change'] = $data['amount_due'] * -1;
$data['payment_change_due'] = ((float) $data['amount_due'] < 0)
? abs((float) $data['amount_due'])
: max(((float) $data['payments_total']) - ((float) $data['amount_due']), 0);
$data['paymentChangeDue'] = $data['payment_change_due'];
return view('sales/customer_display', $data);
}
/**
* Load the sale edit modal. Used in app/Views/sales/register.php.
*
@@ -93,8 +249,6 @@ class Sales extends Secure_Controller
'only_check' => lang('Sales.check_filter'),
'only_creditcard' => lang('Sales.credit_filter'),
'only_debit' => lang('Sales.debit'),
'only_bank_transfer'=> lang('Sales.bank_transfer'),
'only_wallet' => lang('Sales.wallet'),
'only_invoices' => lang('Sales.invoice_filter'),
'selected_customer' => lang('Sales.selected_customer')
];
@@ -158,8 +312,6 @@ class Sales extends Secure_Controller
'selected_customer' => false,
'only_creditcard' => false,
'only_debit' => false,
'only_bank_transfer'=> false,
'only_wallet' => false,
'only_invoices' => $this->config['invoice_enable'] && $this->request->getGet('only_invoices', FILTER_SANITIZE_NUMBER_INT),
'is_valid_receipt' => $this->sale->is_valid_receipt($search)
];
@@ -818,6 +970,7 @@ class Sales extends Secure_Controller
// Resort and filter cart lines for printing
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$this->_append_secondary_currency($data);
if ($data['sale_id_num'] == NEW_ENTRY) {
$data['error_message'] = lang('Sales.transaction_failed');
@@ -857,6 +1010,7 @@ class Sales extends Secure_Controller
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$data['barcode'] = null;
$this->_append_secondary_currency($data);
$this->sale_lib->clear_all();
return view('sales/work_order', $data);
@@ -884,6 +1038,7 @@ class Sales extends Secure_Controller
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$data['barcode'] = null;
$this->_append_secondary_currency($data);
$this->sale_lib->clear_all();
return view('sales/quote', $data);
@@ -902,20 +1057,13 @@ class Sales extends Secure_Controller
$data['sale_id'] = 'POS ' . $data['sale_id_num'];
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$this->_append_secondary_currency($data);
if ($data['sale_id_num'] == NEW_ENTRY) {
$data['error_message'] = lang('Sales.transaction_failed');
return $this->_reload($data);
} else {
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
$this->sale_lib->clear_all();
return view('sales/receipt', $data);
}
@@ -1170,13 +1318,7 @@ class Sales extends Secure_Controller
$invoice_type = 'invoice';
}
$data['invoice_view'] = $invoice_type;
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
$this->_append_secondary_currency($data);
return $data;
}
@@ -1243,6 +1385,7 @@ class Sales extends Secure_Controller
}
$data['amount_change'] = $data['amount_due'] * -1;
$this->_append_secondary_currency($data);
$data['comment'] = $this->sale_lib->get_comment();
$data['email_receipt'] = $this->sale_lib->is_email_receipt();
@@ -1272,7 +1415,6 @@ class Sales extends Secure_Controller
$data['quote_number'] = $this->sale_lib->get_quote_number();
$data['work_order_number'] = $this->sale_lib->get_work_order_number();
$data['keyboardShortcuts'] = $this->sale_lib->getKeyShortcuts();
// TODO: the if/else set below should be converted to a switch
if ($this->sale_lib->get_mode() == 'sale_invoice') { // TODO: Duplicated code.
@@ -1661,9 +1803,7 @@ class Sales extends Secure_Controller
*/
public function getSalesKeyboardHelp(): string
{
return view('sales/help', [
'keyboardShortcuts' => $this->sale_lib->getKeyShortcuts()
]);
return view('sales/help');
}
/**
@@ -1785,3 +1925,5 @@ class Sales extends Secure_Controller
return null;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Database\Migrations;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\Migration;
class Migration_Upgrade_To_3_1_1 extends Migration
@@ -17,7 +18,37 @@ class Migration_Upgrade_To_3_1_1 extends Migration
public function up(): void
{
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql');
// MariaDB blocks CONVERT TO CHARACTER SET on tables with FK constraints.
// Drop all FKs across affected tables before running the SQL script, recreate after.
$fkColumns = [
['modules', 'module_id'],
['stock_locations', 'location_id'],
['permissions', 'permission_id'],
['people', 'person_id'],
['suppliers', 'supplier_id'],
['items', 'item_id'],
['item_kits', 'item_kit_id'],
['sales', 'sale_id'],
['receivings', 'receiving_id'],
['employees', 'employee_id'],
['customers', 'person_id'],
];
$constraints = [];
foreach ($fkColumns as [$table, $column]) {
foreach (dropAllForeignKeyConstraints($table, $column) as $c) {
$constraints[$c['constraintName']] = $c;
}
}
if (!execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql')) {
throw new DatabaseException('Migration script 3.0.2_to_3.1.1.sql failed. Check logs for details.');
}
$droppedTables = ['sales_suspended', 'sales_suspended_items', 'sales_suspended_items_taxes', 'sales_suspended_payments'];
$toRecreate = array_filter($constraints, fn($c) => !in_array($c['tableName'], $droppedTables, true));
recreateForeignKeyConstraints(array_values($toRecreate));
}
/**

View File

@@ -327,19 +327,6 @@ INSERT INTO `ospos_sales_items` (sale_id, item_id, description, serialnumber, li
INSERT INTO `ospos_sales_payments` (sale_id, payment_type, payment_amount) SELECT sale_id, payment_type, payment_amount FROM `ospos_sales_suspended_payments`;
INSERT INTO `ospos_sales_items_taxes` (sale_id, item_id, line, name, percent) SELECT sale_id, item_id, line, name, percent FROM `ospos_sales_suspended_items_taxes`;
ALTER TABLE `ospos_sales_suspended_payments` DROP FOREIGN KEY `ospos_sales_suspended_payments_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items_taxes` DROP FOREIGN KEY `ospos_sales_suspended_items_taxes_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items_taxes` DROP FOREIGN KEY `ospos_sales_suspended_items_taxes_ibfk_2`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_2`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_3`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_1`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_2`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_3`;
DROP TABLE `ospos_sales_suspended_payments`, `ospos_sales_suspended_items_taxes`, `ospos_sales_suspended_items`, `ospos_sales_suspended`;
--

View File

@@ -140,7 +140,7 @@ CREATE TABLE IF NOT EXISTS `ospos_expense_categories` (
`category_name` varchar(255) DEFAULT NULL,
`category_description` varchar(255) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Table structure for table `ospos_expenses`
@@ -154,7 +154,7 @@ CREATE TABLE IF NOT EXISTS `ospos_expenses` (
`description` varchar(255) NOT NULL,
`employee_id` int(10) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Indexes for table `ospos_expense_categories`

View File

@@ -75,7 +75,7 @@ CREATE TABLE `ospos_cash_up` (
`open_employee_id` int(10) NOT NULL,
`close_employee_id` int(10) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Indexes for table `ospos_cash_up`

View File

@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_codes` (
`state` varchar(255) NOT NULL DEFAULT '',
`deleted` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`tax_code_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
ALTER TABLE `ospos_customers`
ADD COLUMN `tax_id` varchar(32) NOT NULL DEFAULT '' AFTER `taxable`,
@@ -59,7 +59,7 @@ CREATE TABLE `ospos_sales_taxes` (
`rounding_code` tinyint(2) NOT NULL DEFAULT 0,
PRIMARY KEY (`sales_taxes_id`),
KEY `print_sequence` (`sale_id`,`print_sequence`,`tax_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` (
`jurisdiction_id` int(11) NOT NULL AUTO_INCREMENT,
@@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` (
`cascade_sequence` tinyint(2) NOT NULL DEFAULT 0,
`deleted` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`jurisdiction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci AUTO_INCREMENT=1;
ALTER TABLE `ospos_suppliers`
ADD COLUMN `tax_id` varchar(32) DEFAULT NULL AFTER `account_number`;
@@ -89,7 +89,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_rates` (
`tax_rate` decimal(15,4) NOT NULL DEFAULT 0.0000,
`tax_rounding_code` tinyint(2) NOT NULL DEFAULT 0,
PRIMARY KEY (`tax_rate_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Add support for sales tax report

View File

@@ -12,7 +12,7 @@ CREATE TABLE `ospos_sales_payments` (
`reference_code` varchar(40) NOT NULL DEFAULT '',
PRIMARY KEY (`payment_id`),
KEY `payment_sale` (`sale_id`, `payment_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
INSERT INTO ospos_sales_payments (sale_id, payment_type, payment_amount, payment_user)
SELECT payments.sale_id, payments.payment_type, payments.payment_amount, sales.employee_id

View File

@@ -272,9 +272,6 @@ function get_payment_options(): array
$payments[lang('Sales.upi')] = lang('Sales.upi');
}
$payments[lang('Sales.bank_transfer')] = lang('Sales.bank_transfer');
$payments[lang('Sales.wallet')] = lang('Sales.wallet');
return $payments;
}
@@ -368,6 +365,74 @@ function to_currency_no_money(?string $number): string
return to_decimals($number, 'currency_decimals');
}
/**
* Build the secondary currency rendering context from app config values.
*
* @param array $config
* @return array{show:bool,rate:float,symbol:string,code:string,decimals:int}
*/
function secondary_currency_context(array $config): array
{
$rate = (float) ($config['secondary_currency_rate'] ?? 0);
$symbol = trim((string) ($config['secondary_currency_symbol'] ?? ''));
$code = trim((string) ($config['secondary_currency_code'] ?? ''));
$decimals = (int) ($config['secondary_currency_decimals'] ?? ($config['currency_decimals'] ?? DEFAULT_PRECISION));
return [
'show' => (($config['secondary_currency_enabled'] ?? false) == 1) && $rate > 0,
'rate' => $rate,
'symbol' => $symbol,
'code' => $code,
'decimals' => $decimals,
];
}
/**
* Render a value in the secondary currency.
*
* @param float|int|string|null $number
* @param array{show:bool,rate:float,symbol:string,code:string,decimals:int} $secondaryCurrency
* @return string
*/
function to_secondary_currency(float|int|string|null $number, array $secondaryCurrency): string
{
if (!isset($number) || !$secondaryCurrency['show']) {
return '';
}
$config = config(OSPOS::class)->settings;
$amount = (float) $number * (float) $secondaryCurrency['rate'];
$fmt = new NumberFormatter($config['number_locale'], NumberFormatter::CURRENCY);
$fmt->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $secondaryCurrency['decimals']);
$fmt->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $secondaryCurrency['decimals']);
if (empty($config['thousands_separator'])) {
$fmt->setTextAttribute(NumberFormatter::GROUPING_SEPARATOR_SYMBOL, '');
}
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $secondaryCurrency['symbol'] !== '' ? $secondaryCurrency['symbol'] : ($secondaryCurrency['code'] !== '' ? $secondaryCurrency['code'] : ''));
return $fmt->format($amount);
}
/**
* Render the secondary and primary currency amounts together.
*
* @param float|int|string|null $number
* @param array{show:bool,rate:float,symbol:string,code:string,decimals:int} $secondaryCurrency
* @return string
*/
function to_secondary_currency_dual(float|int|string|null $number, array $secondaryCurrency): string
{
$secondary = to_secondary_currency($number, $secondaryCurrency);
if ($secondary === '') {
return to_currency((string) $number);
}
return $secondary . '<br>' . to_currency((string) $number);
}
/**
* @param string|null $number
* @return string

View File

@@ -172,6 +172,7 @@ function dropAllForeignKeyConstraints(string $table, string $column): array {
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND ((kcu.REFERENCED_TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column')
OR (kcu.TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
AND rc.CONSTRAINT_NAME IS NOT NULL
");
$deletedConstraints = [];

View File

@@ -402,25 +402,6 @@ function item_headers(): array
];
}
/**
* Get all sortable column keys for items table, including dynamic attribute columns.
*
* @return array Array of column headers in format expected by sanitizeSortColumn
*/
function item_sort_columns(): array
{
$attribute = model(Attribute::class);
$definitionIds = array_keys($attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS));
$headers = item_headers();
foreach ($definitionIds as $definitionId) {
$headers[] = [(string) $definitionId => ''];
}
return $headers;
}
/**
* Get the header for the items tabular view
*/
@@ -441,7 +422,7 @@ function get_items_manage_table_headers(): string
$headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false];
foreach ($definitionsWithTypes as $definition_id => $definitionInfo) {
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => true];
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
}
$headers[] = ['inventory' => '', 'escape' => false];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "استلام البضائع",
"show_in_sales" => "اظهار خلال البيع",
"show_in_sales_visibility" => "البيع",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تحديث الميزات",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "استلام البضائع",
"show_in_sales" => "اظهار خلال البيع",
"show_in_sales_visibility" => "البيع",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تحديث الميزات",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Alınanlar",
"show_in_sales" => "Satışda göstərin",
"show_in_sales_visibility" => "Satışlar",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Atributları yenilə",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Ulazi",
"show_in_sales" => "Prikaži u prodaji",
"show_in_sales_visibility" => "Prodaja",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Ažuriraj atribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "بەدەستگەیشتووکان",
"show_in_sales" => "لە فرۆشتندا نیشانی بدە",
"show_in_sales_visibility" => "فرۆشتن",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تایبەتمەندی نوێ بکەرەوە",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Modtagelser",
"show_in_sales" => "Vis i salg",
"show_in_sales_visibility" => "Salg",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Opdater egenskab",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Eingänge",
"show_in_sales" => "In Verkäufen anzeigen",
"show_in_sales_visibility" => "Verkauf",
"show_in_search" => "In Suche anzeigen",
"show_in_search_visibility" => "Suche",
"update" => "Attribut aktualisieren",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -268,10 +268,19 @@ return [
"receipt_show_company_name" => "Show Company Name",
"receipt_show_description" => "Show Description",
"receipt_show_serialnumber" => "Show Serial Number",
"receipt_show_secondary_currency" => "Show Secondary Currency",
"receipt_show_tax_ind" => "Show Tax Indicator",
"receipt_show_taxes" => "Show Taxes",
"receipt_show_total_discount" => "Show Total Discount",
"receipt_template" => "Receipt Template",
"secondary_currency" => "Secondary Currency",
"secondary_currency_decimals" => "Secondary Currency Decimals",
"secondary_currency_code" => "Secondary Currency Code",
"secondary_currency_enable" => "Enable Secondary Currency",
"secondary_currency_enable_tooltip" => "Show secondary currency fields and print/display values across the app.",
"secondary_currency_rate" => "Secondary Currency Rate",
"secondary_currency_settings" => "Secondary Currency Settings",
"secondary_currency_symbol" => "Secondary Currency Symbol",
"receiving_calculate_average_price" => "Calc avg. Price (Receiving)",
"recv_invoice_format" => "Receivings Invoice Format",
"register_mode_default" => "Default Register Mode",
@@ -288,6 +297,7 @@ return [
"security_issue" => "Security Vulnerability Warning",
"server_notice" => "Please use the below info for issue reporting.",
"service_charge" => "",
"customer_display" => "Customer Display",
"show_due_enable" => "",
"show_office_group" => "Show office icon",
"statistics" => "Send Statistics",
@@ -302,10 +312,6 @@ return [
"suggestions_layout" => "Search Suggestions Layout",
"suggestions_second_column" => "Column 2",
"suggestions_third_column" => "Column 3",
"shortcuts" => "Shortcuts",
"shortcuts_configuration" => "Sales Keyboard Shortcut Configuration",
"shortcuts_duplicate_bindings" => "Shortcut bindings must be unique.",
"shortcuts_save_error" => "Unable to save shortcut settings.",
"system_conf" => "Setup & Conf",
"system_info" => "System Info",
"table" => "Table",
@@ -334,3 +340,5 @@ return [
"work_order_enable" => "Work Order Support",
"work_order_format" => "Work Order Format",
];

View File

@@ -7,9 +7,9 @@ return [
"account_number" => "Account #",
"add_payment" => "Add Payment",
"amount_due" => "Amount Due",
"amount_due_lbp" => "Amount Due LBP",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorized Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
@@ -20,6 +20,8 @@ return [
"cash_deposit" => "Cash Deposit",
"cash_filter" => "Cash",
"change_due" => "Change Due",
"change" => "Change",
"currency_rate" => "Currency Rate",
"change_price" => "Change Selling Price",
"check" => "Check",
"check_balance" => "Check remainder",
@@ -41,6 +43,7 @@ return [
"customer_address" => "Address",
"customer_discount" => "Discount",
"customer_email" => "Email",
"customer_name" => "Customer Name",
"customer_location" => "Location",
"customer_mailchimp_status" => "MailChimp Status",
"customer_optional" => "(Required for Due Payments)",
@@ -74,12 +77,6 @@ return [
"employee" => "Employee",
"entry" => "Entry",
"error_editing_item" => "Error editing item",
"negative_price_invalid" => "Price cannot be negative.",
"negative_quantity_invalid" => "Quantity cannot be negative.",
"negative_discount_invalid" => "Discount cannot be negative.",
"discount_percent_exceeds_100" => "Percentage discount cannot exceed 100%.",
"discount_exceeds_item_total" => "Discount cannot exceed the item total.",
"negative_total_invalid" => "Sale total cannot be negative. Check item discounts and quantities.",
"find_or_scan_item" => "Find or Scan Item",
"find_or_scan_item_or_receipt" => "Find or Scan Item or Receipt",
"giftcard" => "Gift Card",
@@ -110,6 +107,7 @@ return [
"item_name" => "Item Name",
"item_number" => "Item #",
"item_out_of_stock" => "Item is out of stock.",
"items" => "Items",
"key_browser" => "Helpful Shortcuts",
"key_cancel" => "Cancels Current Quote/Invoice/Sale",
"key_customer_search" => "Customer Search",
@@ -151,7 +149,9 @@ return [
"payment_type" => "Type",
"payments" => "",
"payments_total" => "Payments Total",
"loyalty_reward_points" => "Loyalty Reward Points",
"price" => "Price",
"price_with_currency" => "Price (%s)",
"print_after_sale" => "Print after Sale",
"quantity" => "Quantity",
"quantity_less_than_reorder_level" => "Warning: Desired Quantity is below Reorder Level for that Item.",
@@ -167,10 +167,13 @@ return [
"receipt_number" => "Sale #",
"receipt_sent" => "Receipt sent to",
"receipt_unsent" => "Receipt failed to be sent to",
"rate" => "Rate",
"refund" => "Refund Type",
"register" => "Sales Register",
"remove_customer" => "Remove Customer",
"remove_discount" => "",
"customer_display" => "Customer Display",
"summary" => "Summary",
"return" => "Return",
"rewards" => "Reward Points",
"rewards_balance" => "Reward Points Balance",
@@ -182,6 +185,7 @@ return [
"sales_total" => "",
"select_customer" => "Select Customer",
"selected_customer" => "Selected Customer",
"walk_in_customer" => "Walk-in Customer",
"send_invoice" => "Send Invoice",
"send_quote" => "Send Quote",
"send_receipt" => "Send Receipt",
@@ -212,6 +216,7 @@ return [
"tax_percent" => "Tax %",
"taxed_ind" => "T",
"total" => "Total",
"total_lbp" => "Total LBP",
"total_tax_exclusive" => "Tax excluded",
"transaction_failed" => "Sales Transaction failed.",
"unable_to_add_item" => "Item add to Sale failed",
@@ -224,7 +229,6 @@ return [
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",
@@ -232,3 +236,5 @@ return [
"work_order_sent" => "Work Order sent to",
"work_order_unsent" => "Work Order failed to be sent to",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Recibos",
"show_in_sales" => "Mostrar en ventas",
"show_in_sales_visibility" => "Ventas",
"show_in_search" => "Mostrar en búsqueda",
"show_in_search_visibility" => "Búsqueda",
"update" => "Actualizar Atributo",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Recepciones",
"show_in_sales" => "Mostrar en Ventas",
"show_in_sales_visibility" => "Ventas",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Actualizar atributo",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "دریافت",
"show_in_sales" => "نمایش در فروش",
"show_in_sales_visibility" => "حراجی",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "به روز کردن ویژگی",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Réceptions",
"show_in_sales" => "Afficher dans les ventes",
"show_in_sales_visibility" => "Ventes",
"show_in_search" => "Afficher dans la recherche",
"show_in_search_visibility" => "Recherche",
"update" => "Mettre à jour l'attribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "קבלת סחורה",
"show_in_sales" => "הצג במכירות",
"show_in_sales_visibility" => "מכירות",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "עדכן מאפיין",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Áruátvételek",
"show_in_sales" => "Megjelenítés az értékesítésekben",
"show_in_sales_visibility" => "Értékesítések",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Tulajdonság frissítése",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Penerimaan",
"show_in_sales" => "Tampilkan dalam penjualan",
"show_in_sales_visibility" => "Penjualan",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Perbarui Atribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Ricezione",
"show_in_sales" => "Visualizza in vendite",
"show_in_sales_visibility" => "Vendite",
"show_in_search" => "Visualizza nella ricerca",
"show_in_search_visibility" => "Ricerca",
"update" => "Aggiorna attributo",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Orders",
"show_in_sales" => "Toon in verkoop",
"show_in_sales_visibility" => "Verkoop",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Wijzig Attribuut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Leveringen",
"show_in_sales" => "Weergeven in verkopen",
"show_in_sales_visibility" => "Verkopen",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Kenmerk bijwerken",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Dostawy",
"show_in_sales" => "Pokaż w sprzedażach",
"show_in_sales_visibility" => "Sprzedaże",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Zaktualizuj atrybut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Recebimentos",
"show_in_sales" => "Mostrar em vendas",
"show_in_sales_visibility" => "Vendas",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Atualizar atributo",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receptii",
"show_in_sales" => "Arata in vanzari",
"show_in_sales_visibility" => "Vanzari",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Actualizare Atribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Закупки",
"show_in_sales" => "Показать в продажах",
"show_in_sales_visibility" => "Продажи",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Обновить атрибут",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Inleveranser",
"show_in_sales" => "Visa i försäljning",
"show_in_sales_visibility" => "Försäljning",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Uppdatera attribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Manunuzi",
"show_in_sales" => "Onyesha kwenye Mauzo",
"show_in_sales_visibility" => "Mauzo",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Sasisha Sifa",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Manunuzi",
"show_in_sales" => "Onyesha kwenye Mauzo",
"show_in_sales_visibility" => "Mauzo",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Sasisha Sifa",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "สินค้าขาเข้า",
"show_in_sales" => "แสดงใน การขาย",
"show_in_sales_visibility" => "การขาย",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "ปรับปรุงแอตทริบิวต์",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Alacaklar",
"show_in_sales" => "Satışlarda göster",
"show_in_sales_visibility" => "Satışlar",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Nitelik Güncelle",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Надходження",
"show_in_sales" => "Показати в продажах",
"show_in_sales_visibility" => "Продажі",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Оновити атрибут",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Nhập hàng",
"show_in_sales" => "Hiển thị trong bán hàng",
"show_in_sales_visibility" => "Bán hàng",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Cập nhật thuộc tính",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "收据",
"show_in_sales" => "在销售中显示",
"show_in_sales_visibility" => "销售",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "更新属性",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "收貨",
"show_in_sales" => "在銷售中顯示",
"show_in_sales_visibility" => "銷售",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "更新屬性",
];

View File

@@ -38,10 +38,9 @@ class Attribute extends Model
'attribute_decimal'
];
public const SHOW_IN_ITEMS = 1;
public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php
public const SHOW_IN_SALES = 2;
public const SHOW_IN_RECEIVINGS = 4;
public const SHOW_IN_SEARCH = 8;
public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool
{
$attribute_id = $this->getAttributeIdByValue($attribute_value);
@@ -602,6 +601,10 @@ class Attribute extends Model
*/
public function saveAttributeLink(int $itemId, int $definitionId, int $attributeId): bool
{
if ($attributeId <= 0) {
return false;
}
$normalizedItemId = empty($itemId) ? null : $itemId;
$normalizedAttributeId = empty($attributeId) ? null : $attributeId;

View File

@@ -31,7 +31,6 @@ class Item extends Model
'allow_alt_description',
'is_serialized'
];
protected $table = 'items';
protected $primaryKey = 'item_id';
protected $useAutoIncrement = true;
@@ -59,6 +58,7 @@ class Item extends Model
'hsn_code'
];
/**
* Determines if a given item_id is an item
*/
@@ -132,186 +132,32 @@ class Item extends Model
return $this->search($search, $filters, 0, 0, 'items.name', 'asc', true);
}
/**
* Parse search string for attribute-specific queries
* Supports syntax like "color: blue size: large" or "color:blue AND size:large"
*
* @param string $search The raw search string
* @return array{terms: array, attributes: array} Parsed terms and attribute queries
*/
public function parseAttributeSearch(string $search): array
{
$result = [
'terms' => [],
'attributes' => []
];
if ($search === '') {
return $result;
}
$pattern = '/([[:alpha:]][[:alnum:] _-]*?)\s*:\s*([^\s,]+)(?:\s+(?:AND|OR)\s+)?/iu';
$remaining = preg_replace($pattern, '', $search);
if (preg_match_all($pattern, $search, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$attrName = strtolower(trim($match[1]));
$attrValue = trim($match[2]);
$result['attributes'][$attrName][] = $attrValue;
}
}
$remaining = trim(preg_replace('/\s+/', ' ', $remaining));
if ($remaining !== '') {
$result['terms'][] = $remaining;
}
return $result;
}
/**
* Search for items by attribute values
* Returns an array of item_ids matching the attribute search criteria
*
* @param string $search Search term
* @param array $definitionIds Attribute definition IDs to search within
* @param bool $matchDeleted Whether to match items where deleted flag equals this value
* @param string $logic 'AND' or 'OR' for multiple attribute matching
* @return array Array of matching item_ids
*/
public function searchByAttributes(string $search, array $definitionIds, bool $matchDeleted = false, string $logic = 'OR'): array
{
if ($definitionIds === [] || $search === '') {
return [];
}
$parsed = $this->parseAttributeSearch($search);
$matchingItemIds = [];
if (!empty($parsed['attributes'])) {
$attribute = model(Attribute::class);
$allDefinitions = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH, true);
$definitionNameToId = [];
foreach ($allDefinitions as $id => $defInfo) {
$name = is_array($defInfo) ? $defInfo['name'] : $defInfo;
$definitionNameToId[strtolower($name)] = (int) $id;
}
foreach ($parsed['attributes'] as $attrName => $values) {
if (!isset($definitionNameToId[$attrName])) {
continue;
}
$definitionId = $definitionNameToId[$attrName];
// Skip if this attribute is not in the caller-provided definitionIds filter
if (!in_array($definitionId, $definitionIds, true)) {
continue;
}
foreach ($values as $value) {
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->groupStart();
$builder->like('attribute_values.attribute_value', $value);
$builder->orWhere('attribute_values.attribute_decimal', $value);
$builder->orWhere('attribute_values.attribute_date', $value);
$builder->groupEnd();
$builder->where('attribute_links.definition_id', $definitionId);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $matchDeleted);
$foundIds = array_column($builder->get()->getResultArray(), 'item_id');
if ($logic === 'AND') {
if (empty($matchingItemIds)) {
$matchingItemIds = $foundIds;
} else {
$matchingItemIds = array_intersect($matchingItemIds, $foundIds);
}
} else {
$matchingItemIds = array_unique(array_merge($matchingItemIds, $foundIds));
}
}
}
}
if (!empty($parsed['terms'])) {
$term = implode(' ', $parsed['terms']);
$termIds = $this->searchByAttributeValue($term, $definitionIds, $matchDeleted);
if (empty($matchingItemIds)) {
return $termIds;
}
return $logic === 'AND'
? array_values(array_intersect($matchingItemIds, $termIds))
: array_values(array_unique(array_merge($matchingItemIds, $termIds)));
}
return $matchingItemIds;
}
/**
* Search for items by a single attribute value
*
* @param string $search Search term
* @param array $definitionIds Attribute definition IDs to search within
* @param bool $matchDeleted Whether to match items where deleted flag equals this value
* @return array Array of matching item_ids
*/
private function searchByAttributeValue(string $search, array $definitionIds, bool $matchDeleted = false): array
{
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->groupStart();
$builder->like('attribute_values.attribute_value', $search);
$builder->orWhere('attribute_values.attribute_decimal', $search);
$builder->orWhere('attribute_values.attribute_date', $search);
$builder->groupEnd();
$builder->whereIn('attribute_links.definition_id', $definitionIds);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $matchDeleted);
return array_column($builder->get()->getResultArray(), 'item_id');
}
/**
* Get attribute definition ID from column name for sorting
*
* @param string $sortColumn The sort column name
* @return int|null The definition ID or null if not an attribute column
*/
private function getAttributeSortDefinitionId(string $sortColumn): ?int
{
if (!ctype_digit($sortColumn)) {
return null;
}
return (int) $sortColumn;
}
/**
* Perform a search on items
*/
public function search(string $search, array $filters, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'items.name', ?string $order = 'asc', ?bool $count_only = false)
{
$rows = $rows ?? 0;
$limit_from = $limit_from ?? 0;
$sort = $sort ?? 'items.name';
$order = $order ?? 'asc';
$count_only = $count_only ?? false;
// Set default values
if ($rows == null) {
$rows = 0;
}
if ($limit_from == null) {
$limit_from = 0;
}
if ($sort == null) {
$sort = 'items.name';
}
if ($order == null) {
$order = 'asc';
}
if ($count_only == null) {
$count_only = false;
}
$config = config(OSPOS::class)->settings;
$builder = $this->db->table('items AS items');
$builder = $this->db->table('items AS items'); // TODO: I'm not sure if it's needed to write items AS items... I think you can just get away with items
// get_found_rows case
if ($count_only) {
$builder->select('COUNT(DISTINCT items.item_id) AS count');
} else {
@@ -366,33 +212,13 @@ class Item extends Model
: 'trans_date BETWEEN ' . $this->db->escape(rawurldecode($filters['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($filters['end_date']));
$builder->where($where);
$attributesEnabled = count($filters['definition_ids']) > 0;
$matchingItemIds = [];
$attributes_enabled = count($filters['definition_ids']) > 0;
if ($search !== '' && $attributesEnabled && $filters['search_custom']) {
$matchingItemIds = $this->searchByAttributes($search, $filters['definition_ids'], $filters['is_deleted']);
}
if ($search !== '') {
if ($attributesEnabled && $filters['search_custom']) {
if (empty($matchingItemIds)) {
$builder->groupStart();
$builder->like('name', $search);
$builder->orLike('item_number', $search);
$builder->orLike('items.item_id', $search);
$builder->orLike('company_name', $search);
$builder->orLike('items.category', $search);
$builder->groupEnd();
} else {
$builder->groupStart();
$builder->whereIn('items.item_id', $matchingItemIds);
$builder->orLike('name', $search);
$builder->orLike('item_number', $search);
$builder->orLike('items.item_id', $search);
$builder->orLike('company_name', $search);
$builder->orLike('items.category', $search);
$builder->groupEnd();
}
if (!empty($search)) {
if ($attributes_enabled && $filters['search_custom']) {
$builder->havingLike('attribute_values', $search);
$builder->orHavingLike('attribute_dtvalues', $search);
$builder->orHavingLike('attribute_dvalues', $search);
} else {
$builder->groupStart();
$builder->like('name', $search);
@@ -404,43 +230,16 @@ class Item extends Model
}
}
if ($attributesEnabled && !$count_only) {
if ($attributes_enabled) {
$format = $this->db->escape(dateformat_mysql());
$this->db->simpleQuery('SET SESSION group_concat_max_len=49152');
$builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_value) ORDER BY definition_id SEPARATOR \'|\') AS attribute_values');
$builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', definition_id, DATE_FORMAT(attribute_date, $format)) SEPARATOR '|') AS attribute_dtvalues");
$builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_decimal) SEPARATOR \'|\') AS attribute_dvalues');
$sanitizedIds = array_map('intval', $filters['definition_ids']);
$builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $sanitizedIds) . ')', 'left');
$builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $filters['definition_ids']) . ')', 'left');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left');
}
// Handle attribute column sorting
$sortDefinitionId = $this->getAttributeSortDefinitionId($sort);
if ($sortDefinitionId !== null && $attributesEnabled && !$count_only) {
$sortAlias = "sort_attr_{$sortDefinitionId}";
$builder->join("attribute_links AS {$sortAlias}", "{$sortAlias}.item_id = items.item_id AND {$sortAlias}.definition_id = {$sortDefinitionId} AND {$sortAlias}.sale_id IS NULL AND {$sortAlias}.receiving_id IS NULL", 'left');
$builder->join("attribute_values AS {$sortAlias}_val", "{$sortAlias}_val.attribute_id = {$sortAlias}.attribute_id", 'left');
// Determine the correct column to sort by based on attribute type
$attribute = model(Attribute::class);
$definitionInfo = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS, true);
$sortColumn = "{$sortAlias}_val.attribute_value"; // default to text
if (isset($definitionInfo[$sortDefinitionId])) {
$defType = is_array($definitionInfo[$sortDefinitionId]) ? ($definitionInfo[$sortDefinitionId]['type'] ?? TEXT) : TEXT;
if ($defType === DECIMAL) {
$sortColumn = "{$sortAlias}_val.attribute_decimal";
} elseif ($defType === DATE) {
$sortColumn = "{$sortAlias}_val.attribute_date";
}
}
$builder->orderBy($sortColumn, $order);
} else {
$builder->orderBy($sort, $order);
}
$builder->where('items.deleted', $filters['is_deleted']);
if ($filters['empty_upc']) {
@@ -462,12 +261,17 @@ class Item extends Model
$builder->whereIn('items.item_type', $non_temp);
}
// get_found_rows case
if ($count_only) {
return $builder->get()->getRow()->count;
}
// Avoid duplicated entries with same name because of inventory reporting multiple changes on the same item in the same date range
$builder->groupBy('items.item_id');
// Order by name of item by default
$builder->orderBy($sort, $order);
if ($rows > 0) {
$builder->limit($rows, $limit_from);
}

View File

@@ -29,7 +29,7 @@
) ?>
</div>
<div class="col-sm-7">
<a href="<?= 'https://bootswatch.com/3/' . ('bootstrap' == ($config['theme']) ? 'default' : esc($config['theme'])) ?>" target="_blank" rel=noopener>
<a href="<?= 'https://bootswatch.com/3/' . ('bootstrap' == ($config['theme']) ? 'default' : esc($config['theme'])) ?>" target="_blank" rel="noopener">
<span><?= lang('Config.theme_preview') . ' ' . ucfirst(esc($config['theme'])) . ' ' ?></span>
<span class="glyphicon glyphicon-new-window"></span>
</a>
@@ -130,14 +130,17 @@
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.receiving_calculate_average_price'), 'receiving_calculate_average_price', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
<?= form_checkbox([
'name' => 'receiving_calculate_average_price',
'id' => 'receiving_calculate_average_price',
'value' => 'receiving_calculate_average_price',
'checked' => $config['receiving_calculate_average_price'] == 1
]) ?>
<?= form_label(lang('Config.receiving_cost_price_method'), 'receiving_cost_price_method', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-3">
<?= form_dropdown(
'receiving_cost_price_method',
[
'average' => lang('Config.receiving_cost_price_method_average'),
'new' => lang('Config.receiving_cost_price_method_new'),
],
(($config['receiving_cost_price_method'] ?? (($config['receiving_calculate_average_price'] ?? 1) ? 'average' : 'new'))),
['id' => 'receiving_cost_price_method', 'class' => 'form-control']
) ?>
</div>
</div>
@@ -278,7 +281,7 @@
'checked' => $config['gcaptcha_enable'] == 1
]) ?>
<label class="control-label">
<a href="https://www.google.com/recaptcha/admin" target="_blank">
<a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noopener noreferrer">
<span class="glyphicon glyphicon-info-sign" data-toggle="tooltip" data-placement="right" title="<?= lang('Config.gcaptcha_tooltip') ?>"></span>
</a>
</label>
@@ -405,6 +408,18 @@
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.customer_display'), 'customer_display_enabled', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
<?= form_checkbox([
'name' => 'customer_display_enabled',
'id' => 'customer_display_enabled',
'value' => 'customer_display_enabled',
'checked' => ($config['customer_display_enabled'] ?? 1) == 1
]) ?>
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.show_office_group'), 'show_office_group', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
@@ -441,6 +456,7 @@
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.category_dropdown'), 'category_dropdown', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
@@ -541,3 +557,6 @@
}));
});
</script>

View File

@@ -0,0 +1,249 @@
<?php
/**
* @var array $config
* @var string $companyName
* @var string $companyDetails
*/
helper('url');
?>
<!doctype html>
<html lang="<?= esc(service('request')->getLocale()) ?>">
<head>
<meta charset="utf-8">
<title><?= lang('Sales.customer_display') ?></title>
<link rel="shortcut icon" type="image/x-icon" href="<?= base_url('images/favicon.ico') ?>">
<link rel="stylesheet" href="<?= base_url('resources/bootswatch/' . (empty($config['theme']) ? 'flatly' : esc($config['theme'])) . '/bootstrap.min.css') ?>">
<link rel="stylesheet" href="<?= base_url('resources/opensourcepos-8e34d6a398.min.css') ?>">
<style>
html, body {
margin: 0;
padding: 0;
background: #f8f8f8;
color: #333;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
body {
width: 100%;
overflow: hidden;
}
.customer-display-header {
background: #1f3143;
color: #fff;
text-align: center;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 6px 12px;
border-bottom: 1px solid #102131;
}
.customer-display-shell {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 12px 18px 18px;
box-sizing: border-box;
}
.customer-display-company {
text-align: center;
margin-bottom: 18px;
}
.customer-display-company img {
display: block;
margin: 0 auto 6px;
max-height: 84px;
max-width: 240px;
}
.customer-display-company .company-name {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
margin-top: 12px;
}
.customer-display-company .company-details {
font-size: 13px;
line-height: 1.35;
white-space: pre-line;
}
.customer-display-company .company-phone {
font-size: 13px;
line-height: 1.35;
margin-top: 4px;
}
.customer-display-main-row {
display: flex;
gap: 14px;
align-items: flex-start;
margin-top: 6px;
}
.customer-display-cart-column {
flex: 1 1 auto;
min-width: 0;
}
.customer-display-summary-column {
flex: 0 0 320px;
width: 320px;
}
.customer-display-summary-panel,
.customer-display-info-panel,
.customer-display-items-panel {
margin-bottom: 0;
}
.customer-display-summary-panel .panel-heading,
.customer-display-info-panel .panel-heading,
.customer-display-items-panel .panel-heading {
font-weight: 600;
}
.customer-display-summary-panel .table,
.customer-display-info-table {
margin-bottom: 0;
font-size: 13px;
}
.customer-display-summary-panel .table > tbody > tr > th,
.customer-display-info-table > tbody > tr > th {
background: #f8fbfd;
width: 56%;
font-weight: 700;
}
.customer-display-summary-panel .table > tbody > tr > td,
.customer-display-info-table > tbody > tr > td {
width: 44%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.customer-display-summary-panel .rate-row th,
.customer-display-summary-panel .rate-row td {
color: #c00000;
}
.customer-display-summary-panel .summary-section-row th {
background: #eaf2f8;
color: #1f3b5b;
font-weight: 700;
}
.customer-display-summary-panel .summary-subtable {
width: 100%;
}
.customer-display-summary-panel .summary-subtable > tbody > tr > th {
background: #fdfefe;
font-weight: 600;
}
.customer-display-summary-panel .summary-subtable > tbody > tr > td {
font-weight: 600;
}
.register-wrap {
width: 100%;
}
#register {
width: 100%;
margin: 0;
table-layout: fixed;
background: #fff;
}
#register th,
#register td {
text-align: center;
vertical-align: middle;
padding: 6px 5px;
word-wrap: break-word;
}
#register thead th {
font-size: 12px;
font-weight: 600;
color: #333;
}
#register tbody td {
font-size: 15px;
}
#register tbody td.item-name-cell {
font-size: 16px;
text-align: left;
}
#register tbody td.price-cell {
font-size: 15px;
}
#register tbody td.serial-cell {
font-size: 12px;
color: #2F4F4F;
}
.customer-display-summary-panel .table > tbody > tr > th,
.customer-display-info-table > tbody > tr > th {
border-top: 1px solid #e5e5e5;
}
.customer-display-summary-panel .table > tbody > tr > td,
.customer-display-info-table > tbody > tr > td {
border-top: 1px solid #e5e5e5;
}
.customer-display-summary-panel .panel-body,
.customer-display-info-panel .panel-body,
.customer-display-items-panel .panel-body {
padding: 12px 15px;
}
.customer-display-summary-column .panel-body {
padding-top: 8px;
}
.customer-display-summary-column .customer-name-value,
.customer-display-summary-column .giftcard-value,
.customer-display-summary-column .reward-value {
text-align: right;
}
.customer-display-footer {
margin-top: 14px;
text-align: center;
font-size: 12px;
color: #777;
}
</style>
</head>
<body>
<div class="customer-display-header">Open Source Point of Sale</div>
<div class="customer-display-shell">
<div class="customer-display-company">
<?php if (!empty($config['company_logo'])) { ?>
<img src="<?= base_url('uploads/' . esc($config['company_logo'], 'url')) ?>" alt="company_logo">
<?php } ?>
<div class="company-name"><?= esc($companyName) ?></div>
<div class="company-phone">Phone: <?= esc((string)($config['phone'] ?? '')) ?></div>
<?php if ($companyDetails !== '') { ?>
<div class="company-details"><?= nl2br(esc($companyDetails)) ?></div>
<?php } ?>
</div>
<div class="customer-display-main-row">

View File

@@ -0,0 +1,224 @@
<?php
/**
* @var array $cart
* @var array $config
* @var float $rate
* @var float $total
* @var float $subtotal
* @var float $prediscount_subtotal
* @var array $taxes
* @var array $payments
* @var float $amount_change
*/
$priceWithCurrencyLabel = lang('Sales.price_with_currency');
?>
<?= view('partial/customer_display_header') ?>
<div class="customer-display-cart-column">
<div class="register-wrap">
<div class="panel panel-default customer-display-items-panel">
<div class="panel-heading"><?= lang('Sales.items') ?></div>
<div class="panel-body table-responsive">
<table class="table table-striped table-condensed" id="register">
<thead>
<tr>
<th style="width: <?= (int) $cartItemWidth ?>%;"><?= lang('Sales.item_name') ?></th>
<?php if ($cartHasCustomerDisplay) { ?>
<th style="width: <?= (int) $cartPriceWidth ?>%;"><?= sprintf($priceWithCurrencyLabel, esc($customerDisplayCurrencyLabel)) ?></th>
<?php } ?>
<th style="width: <?= (int) $cartOriginalWidth ?>%;"><?= sprintf($priceWithCurrencyLabel, esc($originalCurrencyLabel)) ?></th>
<th style="width: <?= (int) $cartQuantityWidth ?>%;"><?= lang('Sales.quantity') ?></th>
<th style="width: <?= (int) $cartDiscountWidth ?>%;"><?= lang('Sales.discount') ?></th>
<th style="width: <?= (int) $cartTotalWidth ?>%;"><?= lang('Sales.total') ?></th>
</tr>
</thead>
<tbody id="cart_contents">
<?php if (count($cart) == 0) { ?>
<tr>
<td colspan="<?= (int) $cartColspan ?>">
<div class="alert alert-dismissible alert-info"><?= lang('Sales.no_items_in_cart') ?></div>
</td>
</tr>
<?php } else { ?>
<?php foreach (array_reverse($cart, true) as $line => $item) { ?>
<tr>
<td class="item-name-cell">
<?= esc($item['name']) ?><br>
<?= !empty($item['attribute_values']) ? esc($item['attribute_values']) : '' ?>
</td>
<?php if ($cartHasCustomerDisplay) { ?>
<td class="price-cell">
<?= to_secondary_currency((float)$item['price'], $secondaryCurrency) ?>
</td>
<?php } ?>
<td class="price-cell">
<?= to_currency($item['price']) ?>
</td>
<td class="price-cell">
<?= to_quantity_decimals($item['quantity']) ?>
</td>
<td class="price-cell">
<?= to_decimals($item['discount'], 0) ?>
</td>
<td class="price-cell">
<?= $item['item_type'] == ITEM_AMOUNT_ENTRY ? to_currency_no_money($item['discounted_total']) : to_currency($item['discounted_total']) ?>
</td>
</tr>
<tr>
<td colspan="<?= $cartHasCustomerDisplay ? 3 : 2 ?>"></td>
<td class="serial-cell">
<?= $item['is_serialized'] == 1 ? lang('Sales.serial') : '' ?>
</td>
<td colspan="2" class="serial-cell">
<?php if ($item['is_serialized'] == 1) {
echo esc($item['serialnumber']);
} ?>
</td>
</tr>
<?php } ?>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="customer-display-summary-column">
<div class="panel panel-primary customer-display-summary-panel">
<div class="panel-heading"><?= lang('Sales.summary') ?></div>
<div class="panel-body">
<table class="table table-condensed summary-subtable">
<tbody>
<tr>
<th><?= lang('Sales.total') ?></th>
<td><?= to_currency($total) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.total') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)$total, $secondaryCurrency) ?></td>
</tr>
<tr class="rate-row">
<th><?= lang('Sales.rate') ?></th>
<td><?= number_format((float) $rate, 2) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
<table class="table table-condensed summary-subtable" style="margin-top: 10px;">
<tbody>
<tr class="summary-section-row">
<th colspan="2"><?= lang('Sales.customer') ?></th>
</tr>
<tr>
<th><?= lang('Sales.customer_name') ?></th>
<td class="customer-name-value"><?= esc($customerName ?? lang('Sales.walk_in_customer')) ?></td>
</tr>
<tr>
<th><?= lang('Sales.giftcard_balance') ?></th>
<td class="giftcard-value"><?= to_currency((float) ($giftcardRemainder ?? 0)) ?></td>
</tr>
<tr>
<th><?= lang('Sales.loyalty_reward_points') ?></th>
<td class="reward-value"><?= esc((string)($customerRewardPoints ?? 0)) ?></td>
</tr>
</tbody>
</table>
<table class="table table-condensed summary-subtable" style="margin-top: 10px;">
<tbody>
<tr class="summary-section-row">
<th colspan="2"><?= lang('Sales.change') ?></th>
</tr>
<tr>
<th><?= lang('Sales.payments_total') ?></th>
<td><?= to_currency($payments_total) ?></td>
</tr>
<tr>
<th><?= lang('Sales.amount_due') ?></th>
<td><?= to_currency($amount_due) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.amount_due') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)$amount_due, $secondaryCurrency) ?></td>
</tr>
<?php endif; ?>
<tr>
<th><?= lang('Sales.change_due') ?></th>
<td><?= to_currency($paymentChangeDue ?? 0) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.change_due') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)($paymentChangeDue ?? 0), $secondaryCurrency) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="customer-display-footer"></div>
</div>
<script>
const customerDisplayId = new URLSearchParams(window.location.search).get('displayId') || '';
const customerDisplayStorageSuffix = customerDisplayId !== '' ? '_' + customerDisplayId : '';
const customerDisplayStorageKeys = {
open: 'customerDisplayOpen' + customerDisplayStorageSuffix,
dirtyAt: 'customerDisplayDirtyAt' + customerDisplayStorageSuffix
};
localStorage.setItem(customerDisplayStorageKeys.open, '1');
let lastDirtyAt = localStorage.getItem(customerDisplayStorageKeys.dirtyAt) || '';
let refreshTimer = null;
const scheduleRefresh = function(dirtyAt) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = setTimeout(function() {
if (localStorage.getItem(customerDisplayStorageKeys.open) !== '1') {
return;
}
if (localStorage.getItem(customerDisplayStorageKeys.dirtyAt) === dirtyAt) {
window.location.reload();
}
}, 700);
};
const checkForRefresh = function() {
const dirtyAt = localStorage.getItem(customerDisplayStorageKeys.dirtyAt) || '';
if (dirtyAt !== '' && dirtyAt !== lastDirtyAt) {
lastDirtyAt = dirtyAt;
scheduleRefresh(dirtyAt);
}
};
window.addEventListener('storage', function(event) {
if (event.key === customerDisplayStorageKeys.dirtyAt) {
checkForRefresh();
}
});
setInterval(checkForRefresh, 500);
window.addEventListener('beforeunload', function() {
localStorage.removeItem(customerDisplayStorageKeys.open);
});
</script>
</body>
</html>

View File

@@ -61,6 +61,20 @@ if (isset($success)) {
helper('url');
?>
<?php if ($secondaryCurrency['show']): ?>
<?php $secondaryCurrencyLabel = $secondaryCurrency['symbol'] ?: $secondaryCurrency['code']; ?>
<table align="center" style="font-size: 22px; font-weight: 600; background-color: rgb(221, 221, 221); width: 25%; margin: 0 auto 0.5em; border: dashed 1px;">
<tr>
<td style="text-align: center; padding-right: 5%;"><?= lang(ucfirst($controller_name) . '.total') ?>:</td>
<td style="text-align: center;"><?= to_currency($total) ?></td>
</tr>
<tr>
<td style="text-align: center; padding-right: 5%;"><?= lang(ucfirst($controller_name) . '.total') ?> <?= esc($secondaryCurrencyLabel) ?>:</td>
<td style="text-align: center;"><?= $secondaryTotalDisplay ?? to_secondary_currency((float) $total, $secondaryCurrency) ?></td>
</tr>
</table>
<?php endif; ?>
<div id="register_wrapper">
<!-- Top register controls -->
@@ -90,6 +104,16 @@ helper('url');
</li>
<?php } ?>
<?php if (($config['customer_display_enabled'] ?? true) == 1) { ?>
<li class="pull-right">
<?= anchor(
"$controller_name/customerDisplay",
'<span class="glyphicon glyphicon-blackboard">&nbsp;</span>' . lang(ucfirst($controller_name) . '.customer_display'),
['class' => 'btn btn-success btn-sm', 'id' => 'show_customer_display', 'title' => lang(ucfirst($controller_name) . '.customer_display'), 'onclick' => 'return openCustomerDisplay(this.href);']
) ?>
</li>
<?php } ?>
<li class="pull-right">
<button class="btn btn-default btn-sm modal-dlg" id="show_suspended_sales_button" data-href="<?= esc("$controller_name/suspended") ?>"
title="<?= lang(ucfirst($controller_name) . '.suspended_sales') ?>">
@@ -191,7 +215,7 @@ helper('url');
if ($items_module_allowed && $change_price) {
echo form_input(['name' => 'price', 'class' => 'form-control input-sm', 'value' => to_currency_no_money($item['price']), 'tabindex' => ++$tabindex, 'onClick' => 'this.select();']);
} else {
echo to_currency($item['price']);
echo $secondaryCurrency['show'] ? to_secondary_currency_dual((float) $item['price'], $secondaryCurrency) : to_currency($item['price']);
echo form_hidden('price', to_currency_no_money($item['price']));
}
?>
@@ -362,9 +386,6 @@ helper('url');
<button class="btn btn-info btn-sm modal-dlg" data-btn-submit="<?= lang('Common.submit') ?>" data-href="<?= "customers/view" ?>" title="<?= lang(ucfirst($controller_name) . ".new_customer") ?>">
<span class="glyphicon glyphicon-user">&nbsp;</span><?= lang(ucfirst($controller_name) . ".new_customer") ?>
</button>
<button class="btn btn-default btn-sm modal-dlg" id="show_keyboard_help" data-href="<?= esc("$controller_name/salesKeyboardHelp") ?>" title="<?= lang(ucfirst($controller_name) . '.key_title') ?>">
<span class="glyphicon glyphicon-share-alt">&nbsp;</span><?= lang(ucfirst($controller_name) . '.key_help') ?>
</button>
</div>
<?php } ?>
<?= form_close() ?>
@@ -380,7 +401,7 @@ helper('url');
</tr>
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
<tr>
<th style="width: 55%;"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?></th>
<th style="width: 55%;"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?></th>
<th style="width: 45%; text-align: right;"><?= to_currency_tax($tax['sale_tax_amount']) ?></th>
</tr>
<?php } ?>
@@ -388,6 +409,12 @@ helper('url');
<th style="width: 55%; font-size: 150%"><?= lang(ucfirst($controller_name) . '.total') ?></th>
<th style="width: 45%; font-size: 150%; text-align: right;"><span id="sale_total"><?= to_currency($total) ?></span></th>
</tr>
<?php if ($secondaryCurrency['show']) { ?>
<tr>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.total') ?> <?= esc($secondaryCurrencyLabel) ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_total_secondary_currency"><?= $secondaryTotalDisplay ?? to_secondary_currency((float) $total, $secondaryCurrency) ?></span></th>
</tr>
<?php } ?>
</table>
<?php if (count($cart) > 0) { // Only show this part if there are Items already in the register ?>
@@ -396,16 +423,21 @@ helper('url');
<th style="width: 55%;"><?= lang(ucfirst($controller_name) . '.payments_total') ?></th>
<th style="width: 45%; text-align: right;"><?= to_currency($payments_total) ?></th>
</tr>
<tr>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.amount_due') ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_amount_due"><?= to_currency($amount_due) ?></span></th>
</tr>
<?php if ($secondaryCurrency['show']) { ?>
<tr>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.amount_due') ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_amount_due"><?= to_currency($amount_due) ?></span></th>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.amount_due') ?> <?= esc($secondaryCurrencyLabel) ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_amount_due_secondary_currency"><?= $secondaryAmountDueDisplay ?? to_secondary_currency((float) $amount_due, $secondaryCurrency) ?></span></th>
</tr>
</table>
<?php } ?>
</table>
<div id="payment_details">
<?php if ($payments_cover_total) { // Show Complete sale button instead of Add Payment if there is no amount due left ?>
<?= form_open("$controller_name/addPayment", ['id' => 'add_payment_form', 'class' => 'form-horizontal']) ?>
<input type="hidden" name="complete_after_payment" value="0">
<table class="sales_table_100">
<tr>
<td><?= lang(ucfirst($controller_name) . '.payment') ?></td>
@@ -582,8 +614,76 @@ helper('url');
cancel: keyboardShortcuts?.cancel?.code ?? null
};
window.customerDisplayWindow = window.customerDisplayWindow || null;
window.customerDisplayDisplayId = window.customerDisplayDisplayId || sessionStorage.getItem('customerDisplayId') || localStorage.getItem('customerDisplayId') || '';
window.customerDisplayStorageSuffix = function() {
return window.customerDisplayDisplayId ? '_' + window.customerDisplayDisplayId : '';
};
window.customerDisplayStorageKeys = function() {
const suffix = window.customerDisplayStorageSuffix();
return {
open: 'customerDisplayOpen' + suffix,
dirtyAt: 'customerDisplayDirtyAt' + suffix
};
};
window.openCustomerDisplay = function(url) {
if (window.customerDisplayDisplayId === '') {
window.customerDisplayDisplayId = String(Date.now()) + Math.random().toString(36).slice(2);
}
const keys = window.customerDisplayStorageKeys();
const displayUrl = new URL(url, window.location.href);
displayUrl.searchParams.set('displayId', window.customerDisplayDisplayId);
sessionStorage.setItem('customerDisplayId', window.customerDisplayDisplayId);
localStorage.setItem('customerDisplayId', window.customerDisplayDisplayId);
localStorage.setItem(keys.open, '1');
localStorage.setItem(keys.dirtyAt, String(Date.now()));
window.customerDisplayWindow = window.open(displayUrl.toString(), 'customer_display_' + window.customerDisplayDisplayId, 'width=1280,height=720,resizable=yes,scrollbars=yes');
if (window.customerDisplayWindow && !window.customerDisplayWindow.closed) {
window.customerDisplayWindow.focus();
}
return false;
};
window.refreshCustomerDisplay = function() {
const keys = window.customerDisplayStorageKeys();
if (localStorage.getItem(keys.open) !== '1') {
return;
}
localStorage.setItem(keys.dirtyAt, String(Date.now()));
if (window.customerDisplayWindow && !window.customerDisplayWindow.closed) {
window.customerDisplayWindow.location.reload();
window.customerDisplayWindow.focus();
}
};
window.notifyCustomerDisplay = function() {
window.refreshCustomerDisplay();
};
const secondaryAmounts = <?= json_encode([
'total' => $secondaryTotalDisplay ?? null,
'amountDue' => $secondaryAmountDueDisplay ?? null,
'cashAmountDue' => $secondaryCashAmountDueDisplay ?? null,
'nonCashTotal' => $secondaryNonCashTotalDisplay ?? null,
'nonCashAmountDue' => $secondaryNonCashAmountDueDisplay ?? null
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;
$(document).ready(function() {
setTimeout(function() {
window.notifyCustomerDisplay();
}, 300);
const redirect = function() {
window.notifyCustomerDisplay();
window.location.href = "<?= site_url('sales'); ?>";
};
@@ -611,7 +711,10 @@ helper('url');
'item_id': item_id,
'item_number': item_number,
},
dataType: 'json'
dataType: 'json',
success: function() {
window.notifyCustomerDisplay();
}
});
});
@@ -625,7 +728,10 @@ helper('url');
'item_id': item_id,
'item_name': item_name,
},
dataType: 'json'
dataType: 'json',
success: function() {
window.notifyCustomerDisplay();
}
});
});
@@ -639,7 +745,10 @@ helper('url');
'item_id': item_id,
'item_description': item_description,
},
dataType: 'json'
dataType: 'json',
success: function() {
window.notifyCustomerDisplay();
}
});
});
@@ -688,6 +797,7 @@ helper('url');
delay: 10,
select: function(a, ui) {
$(this).val(ui.item.value);
window.notifyCustomerDisplay();
$('#select_customer_form').submit();
return false;
}
@@ -706,6 +816,7 @@ helper('url');
delay: 10,
select: function(a, ui) {
$(this).val(ui.item.value);
window.notifyCustomerDisplay();
$('#add_payment_form').submit();
return false;
}
@@ -745,28 +856,33 @@ helper('url');
});
$('#finish_sale_button').click(function() {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= "$controller_name/complete" ?>");
$('#buttons_form').submit();
});
$('#finish_invoice_quote_button').click(function() {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= "$controller_name/complete" ?>");
$('#buttons_form').submit();
});
$('#suspend_sale_button').click(function() {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= site_url("$controller_name/suspend") ?>");
$('#buttons_form').submit();
});
$('#cancel_sale_button').click(function() {
if (confirm("<?= lang(ucfirst($controller_name) . '.confirm_cancel_sale') ?>")) {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= site_url("$controller_name/cancel") ?>");
$('#buttons_form').submit();
}
});
$('#add_payment_button').click(function() {
window.notifyCustomerDisplay();
$('#add_payment_form').find('input[name="complete_after_payment"]').val('0');
$('#add_payment_form').submit();
});
@@ -803,11 +919,13 @@ helper('url');
if (response.success) {
if (resource.match(/customers$/)) {
$('#customer').val(response.id);
window.notifyCustomerDisplay();
$('#select_customer_form').submit();
} else {
var $stock_location = $("select[name='stock_location']").val();
$('#item_location').val($stock_location);
$('#item').val(response.id);
window.notifyCustomerDisplay();
if (stay_open) {
$('#add_item_form').ajaxSubmit();
} else {
@@ -830,10 +948,17 @@ helper('url');
function check_payment_type() {
var cash_mode = <?= json_encode($cash_mode) ?>;
const updateSecondaryRows = function(totalDisplay, amountDueDisplay) {
if (totalDisplay !== null && amountDueDisplay !== null) {
$("#sale_total_secondary_currency").html(totalDisplay);
$("#sale_amount_due_secondary_currency").html(amountDueDisplay);
}
};
if ($("#payment_types").val() == "<?= lang(ucfirst($controller_name) . '.giftcard') ?>") {
$("#sale_total").html("<?= to_currency($total) ?>");
$("#sale_amount_due").html("<?= to_currency($amount_due) ?>");
updateSecondaryRows(secondaryAmounts.total, secondaryAmounts.amountDue);
$("#amount_tendered_label").html("<?= lang(ucfirst($controller_name) . '.giftcard_number') ?>");
$("#amount_tendered:enabled").val('').focus();
$(".giftcard-input").attr('disabled', false);
@@ -842,6 +967,7 @@ helper('url');
} else if (($("#payment_types").val() == "<?= lang(ucfirst($controller_name) . '.cash') ?>" && cash_mode == '1')) {
$("#sale_total").html("<?= to_currency($non_cash_total) ?>");
$("#sale_amount_due").html("<?= to_currency($cash_amount_due) ?>");
updateSecondaryRows(secondaryAmounts.nonCashTotal, secondaryAmounts.cashAmountDue);
$("#amount_tendered_label").html("<?= lang(ucfirst($controller_name) . '.amount_tendered') ?>");
$("#amount_tendered:enabled").val("<?= to_currency_no_money($cash_amount_due) ?>");
$(".giftcard-input").attr('disabled', true);
@@ -849,6 +975,7 @@ helper('url');
} else {
$("#sale_total").html("<?= to_currency($non_cash_total) ?>");
$("#sale_amount_due").html("<?= to_currency($amount_due) ?>");
updateSecondaryRows(secondaryAmounts.nonCashTotal, secondaryAmounts.nonCashAmountDue);
$("#amount_tendered_label").html("<?= lang(ucfirst($controller_name) . '.amount_tendered') ?>");
$("#amount_tendered:enabled").val("<?= to_currency_no_money($amount_due) ?>");
$(".giftcard-input").attr('disabled', true);
@@ -861,6 +988,7 @@ helper('url');
if ($(event.target).closest('.modal').length || $('.modal.in').length) {
return;
}
if (event.altKey) {
switch (event.keyCode) {
case shortcutCodes.items:
@@ -909,3 +1037,6 @@ helper('url');
</script>
<?= view('partial/footer') ?>