Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d5b151234 | |||
| ede7fd947a | |||
| 485341875a | |||
| 2ad7dc1170 | |||
| 9e2f858343 | |||
| c70f6bdc6e | |||
| 6936a822c8 | |||
| 228824182a | |||
| 1d964c79de | |||
| 02fd6e4863 | |||
| 46769e8b27 | |||
| 88772c3ea0 | |||
| 8d644a2c85 | |||
| e81a1832ad | |||
| dbfd72c2f9 | |||
| 5b11e2d859 | |||
| 6ae90d757b | |||
| 849a4579e9 | |||
| 8d83cf84ab | |||
| e1fa1ded64 | |||
| 2eae9819fe | |||
| cf5314eaf6 | |||
| b689570052 | |||
| 332b0d1007 | |||
| 140be9922d | |||
| 361d0c0d3a | |||
| 0f8ad86b4f | |||
| 48ae2805d3 | |||
| 22c4cf6fd2 | |||
| 6cf3a607a4 | |||
| 611a5c8e4b | |||
| d2cb159451 | |||
| 3133b3c584 | |||
| 38644b32ae | |||
| 0ae9993d82 | |||
| 7b86cf5218 | |||
| 9676261d65 | |||
| a354d78fa9 | |||
| 9899af7b37 | |||
| 5f5afe97ad | |||
| 9036fcb1f5 | |||
| 608aca79b8 | |||
| 576da3bd78 | |||
| a6e0ce1432 | |||
| 103f970110 | |||
| 1d44123410 | |||
| 29dd16db0c | |||
| d906a915d6 | |||
| 23d1cbb64c | |||
| d3da699e55 | |||
| 889c037660 | |||
| 127098b87c | |||
| 2b0206e7f2 | |||
| 383bbb7269 | |||
| 246def8ca9 | |||
| bfa343b69f | |||
| 0a31aa1393 | |||
| 6f722795c5 | |||
| 2bfb9b3aa1 | |||
| 049baf2cb4 | |||
| 0ac3a8375e | |||
| 36c7e3fc6d | |||
| 717baaea79 | |||
| 3cbfc40934 | |||
| 4603d9ced2 | |||
| c12bb59372 | |||
| 1ef2800f12 | |||
| b6f3b14e7b | |||
| 10dc8a33b4 | |||
| 09ea144bb2 | |||
| 8d7ee3b9fc | |||
| 81a72a4c59 | |||
| f8f00c70c8 | |||
| 8509497326 | |||
| 2be16553df | |||
| a30c1398a1 | |||
| 068e739408 | |||
| 4aac9472af | |||
| d34dbd2a47 | |||
| 716cc8b8d3 | |||
| 4db8334a3c | |||
| 0d0a771dd1 | |||
| 539d0eb220 | |||
| a5517b516a | |||
| 3c8b9b587e | |||
| d0b0fb9d3d | |||
| 0699fbbdd6 | |||
| 636231b62a | |||
| 84be5e44ca | |||
| df4e2cf994 | |||
| 7c341ca624 | |||
| 745c294012 | |||
| 3408d5ab20 | |||
| ba87598d3a | |||
| 7f3b24e47f | |||
| 9de2ab2a9f | |||
| 5f27dccd0f | |||
| feb1b29a32 | |||
| 504c3bc9f9 | |||
| f3ac521d7c | |||
| a56aca4342 | |||
| 439a695727 | |||
| f83ebe66b9 | |||
| 62492a5644 | |||
| 84337e8a50 | |||
| 55f8027550 | |||
| e06ccc9cc0 |
@@ -0,0 +1,189 @@
|
|||||||
|
name: Deploy to Shared Hosting
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target:
|
||||||
|
description: 'Deployment target'
|
||||||
|
required: true
|
||||||
|
default: 'production'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Shared hosting deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_APP_PATH: ${{ secrets.DEPLOY_APP_PATH }}
|
||||||
|
DEPLOY_PUBLIC_PATH: ${{ secrets.DEPLOY_PUBLIC_PATH }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_PHP_BIN: ${{ secrets.DEPLOY_PHP_BIN }}
|
||||||
|
DEPLOY_INDEX_REQUIRE: ${{ secrets.DEPLOY_INDEX_REQUIRE }}
|
||||||
|
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||||
|
GITEA_PACKAGE_OWNER: ${{ secrets.GITEA_PACKAGE_OWNER }}
|
||||||
|
GITEA_PACKAGE_USER: ${{ secrets.GITEAPACKAGEUSER }}
|
||||||
|
GITEA_PACKAGE_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
|
GIT_SSL_NO_VERIFY: 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
env:
|
||||||
|
GIT_SSL_NO_VERIFY: 'true'
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.2'
|
||||||
|
extensions: dom, gd, intl, mbstring, mysqli, zip
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Validate Composer config
|
||||||
|
run: composer validate --no-check-publish --strict
|
||||||
|
|
||||||
|
- name: Install production dependencies
|
||||||
|
run: composer install --no-dev --no-interaction --prefer-dist --no-progress --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Create deployment artifact
|
||||||
|
run: bash scripts/build-deploy-artifact.sh
|
||||||
|
|
||||||
|
- name: Publish deployment package
|
||||||
|
if: ${{ env.GITEA_PACKAGE_USER != '' && env.GITEA_PACKAGE_TOKEN != '' }}
|
||||||
|
run: |
|
||||||
|
BASE_URL="${GITEA_BASE_URL:-https://192.168.3.80}"
|
||||||
|
PACKAGE_OWNER="${GITEA_PACKAGE_OWNER:-melabidi}"
|
||||||
|
PACKAGE_NAME="alrahma_sunday_school"
|
||||||
|
REF_NAME="${GITHUB_REF_NAME:-manual}"
|
||||||
|
SHORT_SHA="$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
VERSION="$(printf '%s-%s-%s' "${REF_NAME}" "${GITHUB_RUN_NUMBER}" "${SHORT_SHA}" | sed 's/[^A-Za-z0-9._+-]/-/g')"
|
||||||
|
FILE_NAME="${PACKAGE_NAME}-${VERSION}.tar.gz"
|
||||||
|
PACKAGE_URL="${BASE_URL}/api/packages/${PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${VERSION}/${FILE_NAME}"
|
||||||
|
|
||||||
|
tar -czf "build/${FILE_NAME}" -C build/deploy .
|
||||||
|
echo "Publishing package ${PACKAGE_NAME} ${VERSION}"
|
||||||
|
|
||||||
|
curl --fail-with-body --insecure \
|
||||||
|
--user "${GITEA_PACKAGE_USER}:${GITEA_PACKAGE_TOKEN}" \
|
||||||
|
--upload-file "build/${FILE_NAME}" \
|
||||||
|
"${PACKAGE_URL}"
|
||||||
|
|
||||||
|
echo "Package uploaded: ${PACKAGE_URL}"
|
||||||
|
|
||||||
|
- name: Resolve shared-hosting paths
|
||||||
|
run: |
|
||||||
|
APP_PATH="${DEPLOY_APP_PATH:-${DEPLOY_PATH}}"
|
||||||
|
PUBLIC_PATH="${DEPLOY_PUBLIC_PATH}"
|
||||||
|
|
||||||
|
if [ -z "${APP_PATH}" ]; then
|
||||||
|
echo "Set DEPLOY_APP_PATH (application directory next to public_html)."
|
||||||
|
echo "Example: /home/u280815660/domains/home.alrahmaisgl.org/alrahma"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PUBLIC_PATH}" ]; then
|
||||||
|
PUBLIC_PATH="$(dirname "${APP_PATH}")/public_html"
|
||||||
|
echo "DEPLOY_PUBLIC_PATH is not set; using ${PUBLIC_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "APP_PATH=${APP_PATH}" >> "$GITHUB_ENV"
|
||||||
|
echo "PUBLIC_PATH=${PUBLIC_PATH}" >> "$GITHUB_ENV"
|
||||||
|
echo "SSH_PORT=${DEPLOY_PORT:-65002}" >> "$GITHUB_ENV"
|
||||||
|
echo "PHP_BIN=${DEPLOY_PHP_BIN:-/opt/alt/php85/usr/bin/php}" >> "$GITHUB_ENV"
|
||||||
|
echo "INDEX_REQUIRE=${DEPLOY_INDEX_REQUIRE:-../alrahma/app/Config/Paths.php}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
run: |
|
||||||
|
test -n "${DEPLOY_HOST}"
|
||||||
|
test -n "${DEPLOY_USER}"
|
||||||
|
test -n "${DEPLOY_SSH_KEY}"
|
||||||
|
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
printf '%s\n' "${DEPLOY_SSH_KEY}" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "Host shared-hosting"
|
||||||
|
echo " HostName ${DEPLOY_HOST}"
|
||||||
|
echo " User ${DEPLOY_USER}"
|
||||||
|
echo " Port ${SSH_PORT}"
|
||||||
|
echo " IdentityFile ~/.ssh/deploy_key"
|
||||||
|
echo " IdentitiesOnly yes"
|
||||||
|
echo " StrictHostKeyChecking accept-new"
|
||||||
|
echo " ServerAliveInterval 30"
|
||||||
|
} > ~/.ssh/config
|
||||||
|
chmod 600 ~/.ssh/config
|
||||||
|
|
||||||
|
ssh-keyscan -p "${SSH_PORT}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts || true
|
||||||
|
|
||||||
|
- name: Prepare remote directories
|
||||||
|
run: |
|
||||||
|
ssh shared-hosting "mkdir -p \
|
||||||
|
'${APP_PATH}' \
|
||||||
|
'${APP_PATH}/writable/cache' \
|
||||||
|
'${APP_PATH}/writable/logs' \
|
||||||
|
'${APP_PATH}/writable/session' \
|
||||||
|
'${APP_PATH}/writable/uploads' \
|
||||||
|
'${APP_PATH}/writable/debugbar' \
|
||||||
|
'${APP_PATH}/writable/reports' \
|
||||||
|
'${PUBLIC_PATH}'"
|
||||||
|
|
||||||
|
- name: Deploy application files
|
||||||
|
run: |
|
||||||
|
rsync -az --delete \
|
||||||
|
-e ssh \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude 'writable/cache/***' \
|
||||||
|
--exclude 'writable/logs/***' \
|
||||||
|
--exclude 'writable/session/***' \
|
||||||
|
--exclude 'writable/uploads/***' \
|
||||||
|
--exclude 'writable/debugbar/***' \
|
||||||
|
--exclude 'writable/reports/***' \
|
||||||
|
build/deploy/ "shared-hosting:${APP_PATH}/"
|
||||||
|
|
||||||
|
- name: Deploy public_html document root
|
||||||
|
run: |
|
||||||
|
rsync -az --delete \
|
||||||
|
-e ssh \
|
||||||
|
--exclude 'uploads/***' \
|
||||||
|
--exclude 'cgi-bin/***' \
|
||||||
|
--exclude '.well-known/***' \
|
||||||
|
--exclude 'error_log' \
|
||||||
|
--exclude 'default.php' \
|
||||||
|
build/deploy/public/ "shared-hosting:${PUBLIC_PATH}/"
|
||||||
|
|
||||||
|
- name: Finalize shared-hosting release
|
||||||
|
run: |
|
||||||
|
ssh shared-hosting "APP_PATH=$(printf '%q' "${APP_PATH}") PUBLIC_PATH=$(printf '%q' "${PUBLIC_PATH}") INDEX_REQUIRE=$(printf '%q' "${INDEX_REQUIRE}") PHP_BIN=$(printf '%q' "${PHP_BIN}") bash -s" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
test -f "${PUBLIC_PATH}/index.php"
|
||||||
|
test -d "${APP_PATH}/app"
|
||||||
|
test -d "${APP_PATH}/vendor"
|
||||||
|
|
||||||
|
grep -q "require FCPATH . '" "${PUBLIC_PATH}/index.php"
|
||||||
|
sed -i "s|require FCPATH . '../[^']*app/Config/Paths.php';|require FCPATH . '${INDEX_REQUIRE}';|" "${PUBLIC_PATH}/index.php"
|
||||||
|
grep -F "require FCPATH . '${INDEX_REQUIRE}';" "${PUBLIC_PATH}/index.php"
|
||||||
|
|
||||||
|
chmod 644 "${PUBLIC_PATH}/index.php" "${PUBLIC_PATH}/.htaccess" || true
|
||||||
|
find "${PUBLIC_PATH}" -type d -exec chmod 755 {} +
|
||||||
|
find "${APP_PATH}/writable" -type d -exec chmod 775 {} + || true
|
||||||
|
find "${APP_PATH}/writable" -type f -exec chmod 664 {} + || true
|
||||||
|
|
||||||
|
if [ -x "${PHP_BIN}" ]; then
|
||||||
|
(cd "${APP_PATH}" && "${PHP_BIN}" spark cache:clear) || true
|
||||||
|
else
|
||||||
|
(cd "${APP_PATH}" && php spark cache:clear) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployed app to ${APP_PATH}"
|
||||||
|
echo "Deployed document root to ${PUBLIC_PATH}"
|
||||||
|
echo "index.php requires ${INDEX_REQUIRE}"
|
||||||
|
REMOTE
|
||||||
+115
-2
@@ -31,24 +31,32 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GIT_SSL_NO_VERIFY: 'true'
|
GIT_SSL_NO_VERIFY: 'true'
|
||||||
CI_ENVIRONMENT: testing
|
CI_ENVIRONMENT: testing
|
||||||
database.tests.hostname: 127.0.0.1
|
TEST_DB_HOST: mysql
|
||||||
|
TEST_DB_PORT: 3306
|
||||||
|
TEST_DB_NAME: alrahma_test
|
||||||
|
TEST_DB_USER: alrahma
|
||||||
|
TEST_DB_PASSWORD: alrahma
|
||||||
|
database.tests.hostname: mysql
|
||||||
database.tests.database: alrahma_test
|
database.tests.database: alrahma_test
|
||||||
database.tests.username: alrahma
|
database.tests.username: alrahma
|
||||||
database.tests.password: alrahma
|
database.tests.password: alrahma
|
||||||
database.tests.DBDriver: MySQLi
|
database.tests.DBDriver: MySQLi
|
||||||
database.tests.DBPrefix: ''
|
database.tests.DBPrefix: ''
|
||||||
database.tests.port: 3306
|
database.tests.port: 3306
|
||||||
|
JWT_SECRET: test-secret-for-ci
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
env:
|
||||||
|
GIT_SSL_NO_VERIFY: 'true'
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.2'
|
php-version: '8.2'
|
||||||
extensions: dom, gd, intl, mbstring, mysqli, zip
|
extensions: dom, gd, intl, mbstring, mysqli, zip
|
||||||
coverage: none
|
coverage: xdebug
|
||||||
|
|
||||||
- name: Validate Composer config
|
- name: Validate Composer config
|
||||||
run: composer validate --no-check-publish --strict
|
run: composer validate --no-check-publish --strict
|
||||||
@@ -56,5 +64,110 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: composer install --no-interaction --prefer-dist --no-progress
|
run: composer install --no-interaction --prefer-dist --no-progress
|
||||||
|
|
||||||
|
- name: Prepare writable directories
|
||||||
|
run: |
|
||||||
|
mkdir -p \
|
||||||
|
writable/cache/testing \
|
||||||
|
writable/logs \
|
||||||
|
writable/session \
|
||||||
|
writable/uploads \
|
||||||
|
writable/debugbar
|
||||||
|
chown -R "$(id -u):$(id -g)" writable || true
|
||||||
|
chmod -R u+rwX,g+rwX writable
|
||||||
|
|
||||||
|
- name: Verify writable cache
|
||||||
|
run: |
|
||||||
|
test -d writable/cache/testing
|
||||||
|
test -w writable/cache/testing
|
||||||
|
touch writable/cache/testing/.ci-write-test
|
||||||
|
rm writable/cache/testing/.ci-write-test
|
||||||
|
|
||||||
|
- name: Verify database configuration
|
||||||
|
run: |
|
||||||
|
php -d variables_order=EGPCS -r '
|
||||||
|
define("FCPATH", __DIR__ . "/public/");
|
||||||
|
define("ENVIRONMENT", getenv("CI_ENVIRONMENT") ?: "production");
|
||||||
|
require "app/Config/Paths.php";
|
||||||
|
$paths = new Config\Paths();
|
||||||
|
require $paths->systemDirectory . "/Boot.php";
|
||||||
|
CodeIgniter\Boot::bootConsole($paths);
|
||||||
|
|
||||||
|
$database = config("Database");
|
||||||
|
$dbConfig = $database->{$database->defaultGroup};
|
||||||
|
|
||||||
|
echo "Environment: " . ENVIRONMENT . PHP_EOL;
|
||||||
|
echo "DB group: " . $database->defaultGroup . PHP_EOL;
|
||||||
|
echo "DB host: " . $dbConfig["hostname"] . PHP_EOL;
|
||||||
|
echo "DB port: " . $dbConfig["port"] . PHP_EOL;
|
||||||
|
echo "DB name: " . $dbConfig["database"] . PHP_EOL;
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Wait for MariaDB
|
||||||
|
run: |
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
php -r '
|
||||||
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
$db = @new mysqli(
|
||||||
|
getenv("TEST_DB_HOST"),
|
||||||
|
getenv("TEST_DB_USER"),
|
||||||
|
getenv("TEST_DB_PASSWORD"),
|
||||||
|
getenv("TEST_DB_NAME"),
|
||||||
|
(int) getenv("TEST_DB_PORT")
|
||||||
|
);
|
||||||
|
|
||||||
|
exit($db->connect_errno === 0 ? 0 : 1);
|
||||||
|
' && break
|
||||||
|
|
||||||
|
echo "Waiting for MariaDB, attempt ${attempt}/30"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
php -r '
|
||||||
|
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||||
|
|
||||||
|
new mysqli(
|
||||||
|
getenv("TEST_DB_HOST"),
|
||||||
|
getenv("TEST_DB_USER"),
|
||||||
|
getenv("TEST_DB_PASSWORD"),
|
||||||
|
getenv("TEST_DB_NAME"),
|
||||||
|
(int) getenv("TEST_DB_PORT")
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "MariaDB connection successful\n";
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Import baseline schema
|
||||||
|
run: php tests/_support/import_baseline.php Database_Dump/u280815660_school_12072025
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
run: |
|
||||||
|
php -d variables_order=EGPCS spark migrate --all --no-interaction
|
||||||
|
php -r '
|
||||||
|
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||||
|
|
||||||
|
$db = new mysqli(
|
||||||
|
getenv("TEST_DB_HOST"),
|
||||||
|
getenv("TEST_DB_USER"),
|
||||||
|
getenv("TEST_DB_PASSWORD"),
|
||||||
|
getenv("TEST_DB_NAME"),
|
||||||
|
(int) getenv("TEST_DB_PORT")
|
||||||
|
);
|
||||||
|
|
||||||
|
$tables = [];
|
||||||
|
$result = $db->query("SHOW TABLES");
|
||||||
|
while ($row = $result->fetch_array(MYSQLI_NUM)) {
|
||||||
|
$tables[$row[0]] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (["users", "students", "attendance_data"] as $table) {
|
||||||
|
if (!isset($tables[$table])) {
|
||||||
|
fwrite(STDERR, "Migration schema missing required table: {$table}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Migration schema verified\n";
|
||||||
|
'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: composer test
|
run: composer test
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
/env
|
||||||
|
/env.production
|
||||||
|
/env.backup
|
||||||
|
/env_for_test*
|
||||||
|
/environment.txt
|
||||||
|
/env\ (copy\ 1)
|
||||||
|
|
||||||
/vendor/
|
/vendor/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
@@ -47,7 +53,9 @@ Thumbs.db
|
|||||||
|
|
||||||
/coverage/
|
/coverage/
|
||||||
/build/
|
/build/
|
||||||
|
/build.tar.gz
|
||||||
/builds
|
/builds
|
||||||
|
/_chunks/
|
||||||
/phpunit.xml.cache
|
/phpunit.xml.cache
|
||||||
/.phpunit.result.cache
|
/.phpunit.result.cache
|
||||||
/writable/reports/*
|
/writable/reports/*
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,11 @@
|
|||||||
# Current Software Architecture of `alrahma_sunday_school`
|
# Current Software Architecture of `alrahma_sunday_school`
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="/melabidi/alrahma_sunday_school/actions">
|
||||||
|
<img src="https://img.shields.io/badge/Deploy-Hosting%20Server-2ea44f?style=for-the-badge" alt="Deploy to Hosting Server"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="/image/system_architecture.png" alt="software-architecture" width="1000"/>
|
<img src="/image/system_architecture.png" alt="software-architecture" width="1000"/>
|
||||||
</p>
|
</p>
|
||||||
@@ -55,7 +62,7 @@ Problems with it can be raised on our forum, or as issues in the main repository
|
|||||||
|
|
||||||
## Server Requirements
|
## Server Requirements
|
||||||
|
|
||||||
PHP version 8.1 or higher is required, with the following extensions installed:
|
PHP version 8.5 or higher is required, with the following extensions installed:
|
||||||
|
|
||||||
- [intl](http://php.net/manual/en/intl.requirements.php)
|
- [intl](http://php.net/manual/en/intl.requirements.php)
|
||||||
- [mbstring](http://php.net/manual/en/mbstring.installation.php)
|
- [mbstring](http://php.net/manual/en/mbstring.installation.php)
|
||||||
@@ -64,7 +71,7 @@ PHP version 8.1 or higher is required, with the following extensions installed:
|
|||||||
> - The end of life date for PHP 7.4 was November 28, 2022.
|
> - The end of life date for PHP 7.4 was November 28, 2022.
|
||||||
> - The end of life date for PHP 8.0 was November 26, 2023.
|
> - The end of life date for PHP 8.0 was November 26, 2023.
|
||||||
> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately.
|
> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately.
|
||||||
> - The end of life date for PHP 8.1 will be December 31, 2025.
|
> - The end of life date for PHP 8.1 was December 31, 2025.
|
||||||
|
|
||||||
Additionally, make sure that the following extensions are enabled in your PHP:
|
Additionally, make sure that the following extensions are enabled in your PHP:
|
||||||
|
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
Al Rahma Sunday School — API Docs Pack
|
|
||||||
=================================================
|
|
||||||
Generated: 2025-11-07T21:01:43.815073
|
|
||||||
|
|
||||||
WHAT'S INCLUDED
|
|
||||||
---------------
|
|
||||||
- app/Controllers/Api/*.php (API controllers)
|
|
||||||
- app/Controllers/{DocsController, ApiDocsController}
|
|
||||||
- app/Filters/ApiDocsAuthFilter.php
|
|
||||||
- app/Views/docs/{index.php, swagger_ui.php, swagger_ui_public.php}
|
|
||||||
- public/docs/openapi/*.yaml (separate specs + master.yaml)
|
|
||||||
- ROUTES_SNIPPET.php (add to app/Config/Routes.php)
|
|
||||||
- FILTERS_SNIPPET.php (add alias to app/Config/Filters.php)
|
|
||||||
|
|
||||||
INSTALL
|
|
||||||
-------
|
|
||||||
1) Unzip into your project root.
|
|
||||||
2) Merge routes:
|
|
||||||
- Open app/Config/Routes.php and paste the contents of ROUTES_SNIPPET.php
|
|
||||||
3) Add filter alias:
|
|
||||||
- Open app/Config/Filters.php and add the 'apiDocsAuth' alias from FILTERS_SNIPPET.php
|
|
||||||
4) Ensure your Models exist and match the controller expectations.
|
|
||||||
5) Set JWT secret in .env:
|
|
||||||
JWT_SECRET=your_super_secret_key
|
|
||||||
6) Visit:
|
|
||||||
- /docs (landing)
|
|
||||||
- /docs/api (secured, admin only)
|
|
||||||
- /docs/api/public (public read-only)
|
|
||||||
7) API base path:
|
|
||||||
- /api/v1/... (see YAMLs for full paths)
|
|
||||||
|
|
||||||
Enjoy!
|
|
||||||
Binary file not shown.
@@ -98,10 +98,9 @@ class ConfigUpdate extends BaseCommand
|
|||||||
return true; // no-op is success
|
return true; // no-op is success
|
||||||
}
|
}
|
||||||
|
|
||||||
CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray');
|
CLI::write('Semester is derived from fall_semester_start and spring_semester_start; no config row is updated.', 'yellow');
|
||||||
if ($dry) return true;
|
|
||||||
|
|
||||||
return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring');
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool
|
protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool
|
||||||
@@ -118,10 +117,9 @@ class ConfigUpdate extends BaseCommand
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray');
|
CLI::write('Semester is derived from fall_semester_start and spring_semester_start; no config row is updated.', 'yellow');
|
||||||
if ($dry) return true;
|
|
||||||
|
|
||||||
return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall');
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function run(array $params)
|
public function run(array $params)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DeleteInactiveUsers extends BaseCommand
|
|||||||
{
|
{
|
||||||
protected $group = 'Maintenance';
|
protected $group = 'Maintenance';
|
||||||
protected $name = 'users:delete-inactive-users';
|
protected $name = 'users:delete-inactive-users';
|
||||||
protected $description = 'Delete users that are inactive and created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
|
protected $description = 'Delete unverified inactive registrations created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
|
||||||
|
|
||||||
public function run(array $params)
|
public function run(array $params)
|
||||||
{
|
{
|
||||||
@@ -24,11 +24,12 @@ class DeleteInactiveUsers extends BaseCommand
|
|||||||
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
|
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
// 1 Fetch inactive users older than 15 min
|
// 1 Fetch unfinished registrations older than 15 min
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
$users = $db->table('users')
|
$users = $db->table('users')
|
||||||
->select('id, firstname, lastname, email, created_at')
|
->select('id, firstname, lastname, email, created_at')
|
||||||
->where('status', 'Inactive')
|
->where('status', 'Inactive')
|
||||||
|
->where('is_verified', 0)
|
||||||
->where('created_at <', $cutoffTime)
|
->where('created_at <', $cutoffTime)
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class EnrollmentCloseoutReport extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:closeout-report';
|
||||||
|
protected $description = 'Generate end-of-registration reconciliation and closeout exception lists.';
|
||||||
|
protected $usage = 'php spark registration:closeout-report [--school-year=2026-2027] [--json] [--export=/tmp/registration-closeout.csv]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
|
||||||
|
'--json' => 'Print machine-readable JSON.',
|
||||||
|
'--export' => 'Write closeout exception rows to a CSV file.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->currentSchoolYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->buildReport($schoolYear);
|
||||||
|
$exportPath = $this->optionValue('export');
|
||||||
|
if ($exportPath !== '') {
|
||||||
|
$this->writeCsv($exportPath, $report['exceptions']);
|
||||||
|
$report['export_path'] = $exportPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->printReport($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildReport(string $schoolYear): array
|
||||||
|
{
|
||||||
|
$previousYear = $this->previousSchoolYearName($schoolYear);
|
||||||
|
$expected = $this->expectedReturningStudents($previousYear);
|
||||||
|
$enrolled = $this->enrolledStudents($schoolYear);
|
||||||
|
$unsubmitted = $this->unsubmittedReturningStudents($schoolYear, $previousYear);
|
||||||
|
$pendingEnrollments = $this->pendingEnrollments($schoolYear);
|
||||||
|
$unresolvedFlags = $this->unresolvedFlags($schoolYear);
|
||||||
|
$failedEmails = $this->failedEmails($schoolYear);
|
||||||
|
|
||||||
|
$exceptions = array_merge(
|
||||||
|
$this->exceptionRows('unsubmitted_returning_student', $unsubmitted),
|
||||||
|
$this->exceptionRows('pending_enrollment', $pendingEnrollments),
|
||||||
|
$this->exceptionRows('unresolved_flag', $unresolvedFlags),
|
||||||
|
$this->exceptionRows('failed_email', $failedEmails)
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'expected_returning_students' => count($expected),
|
||||||
|
'students_with_target_year_enrollment' => count($enrolled),
|
||||||
|
'unsubmitted_returning_students' => count($unsubmitted),
|
||||||
|
'pending_enrollments' => count($pendingEnrollments),
|
||||||
|
'unresolved_flags' => count($unresolvedFlags),
|
||||||
|
'failed_emails' => count($failedEmails),
|
||||||
|
'closeout_exception_total' => count($exceptions),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'source_school_year' => $previousYear,
|
||||||
|
'generated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'ready_to_close' => $summary['closeout_exception_total'] === 0,
|
||||||
|
'summary' => $summary,
|
||||||
|
'exceptions' => $exceptions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expectedReturningStudents(?string $previousYear): array
|
||||||
|
{
|
||||||
|
if ($previousYear === null || ! $this->db->tableExists('student_class')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('student_class sc')
|
||||||
|
->select('DISTINCT sc.student_id', false)
|
||||||
|
->where('sc.school_year', $previousYear)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enrolledStudents(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollments')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('enrollments')
|
||||||
|
->select('DISTINCT student_id', false)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unsubmittedReturningStudents(string $schoolYear, ?string $previousYear): array
|
||||||
|
{
|
||||||
|
if ($previousYear === null || ! $this->db->tableExists('student_class') || ! $this->db->tableExists('enrollments')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$join = 'e.student_id = sc.student_id AND e.school_year = ' . $this->db->escape($schoolYear);
|
||||||
|
|
||||||
|
return $this->db->table('student_class sc')
|
||||||
|
->select('sc.student_id, s.firstname, s.lastname, s.school_id')
|
||||||
|
->select('e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at')
|
||||||
|
->join('students s', 's.id = sc.student_id', 'left')
|
||||||
|
->join('enrollments e', $join, 'left', false)
|
||||||
|
->where('sc.school_year', $previousYear)
|
||||||
|
->groupStart()
|
||||||
|
->where('e.id IS NULL')
|
||||||
|
->orWhere('e.registration_submitted_at IS NULL', null, false)
|
||||||
|
->groupEnd()
|
||||||
|
->groupBy('sc.student_id, s.firstname, s.lastname, s.school_id, e.id, e.enrollment_status, e.admission_status, e.registration_submitted_at')
|
||||||
|
->orderBy('s.lastname', 'ASC')
|
||||||
|
->orderBy('s.firstname', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pendingEnrollments(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollments')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('enrollments e')
|
||||||
|
->select('e.student_id, e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at, e.registration_confirmed_at')
|
||||||
|
->select('s.firstname, s.lastname, s.school_id')
|
||||||
|
->join('students s', 's.id = e.student_id', 'left')
|
||||||
|
->where('e.school_year', $schoolYear)
|
||||||
|
->groupStart()
|
||||||
|
->where('e.enrollment_status', 'admission under review')
|
||||||
|
->orWhere('e.admission_status', 'pending')
|
||||||
|
->orWhere('e.registration_confirmed_at IS NULL', null, false)
|
||||||
|
->groupEnd()
|
||||||
|
->orderBy('s.lastname', 'ASC')
|
||||||
|
->orderBy('s.firstname', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unresolvedFlags(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_flags')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('enrollment_flags ef')
|
||||||
|
->select('ef.id AS flag_id, ef.student_id, ef.flag_type, ef.priority, ef.created_at')
|
||||||
|
->select('s.firstname, s.lastname, s.school_id')
|
||||||
|
->join('students s', 's.id = ef.student_id', 'left')
|
||||||
|
->where('ef.school_year', $schoolYear)
|
||||||
|
->where('ef.status', 'open')
|
||||||
|
->orderBy('ef.priority', 'DESC')
|
||||||
|
->orderBy('ef.created_at', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failedEmails(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_email_records')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('enrollment_email_records')
|
||||||
|
->select('id AS email_record_id, parent_user_id, recipient_addresses_json, delivery_status, failure_reason, retry_count, updated_at')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('delivery_status', 'failed')
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exceptionRows(string $type, array $rows): array
|
||||||
|
{
|
||||||
|
$exceptions = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||||
|
$exceptions[] = [
|
||||||
|
'type' => $type,
|
||||||
|
'student_id' => $row['student_id'] ?? '',
|
||||||
|
'student_name' => $studentName,
|
||||||
|
'school_id' => $row['school_id'] ?? '',
|
||||||
|
'reference_id' => $row['enrollment_id'] ?? $row['flag_id'] ?? $row['email_record_id'] ?? '',
|
||||||
|
'status' => $row['enrollment_status'] ?? $row['flag_type'] ?? $row['delivery_status'] ?? '',
|
||||||
|
'detail' => $this->exceptionDetail($type, $row),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $exceptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exceptionDetail(string $type, array $row): string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'unsubmitted_returning_student' => empty($row['enrollment_id'])
|
||||||
|
? 'No target-year enrollment exists.'
|
||||||
|
: 'Target-year enrollment exists but registration has not been submitted.',
|
||||||
|
'pending_enrollment' => 'Enrollment status: ' . (string) ($row['enrollment_status'] ?? '') . '; admission status: ' . (string) ($row['admission_status'] ?? ''),
|
||||||
|
'unresolved_flag' => 'Open flag priority: ' . (string) ($row['priority'] ?? ''),
|
||||||
|
'failed_email' => 'Retry count: ' . (int) ($row['retry_count'] ?? 0) . '; reason: ' . (string) ($row['failure_reason'] ?? ''),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeCsv(string $path, array $rows): void
|
||||||
|
{
|
||||||
|
$directory = dirname($path);
|
||||||
|
if ($directory !== '' && $directory !== '.' && ! is_dir($directory)) {
|
||||||
|
throw new \RuntimeException('Export directory does not exist: ' . $directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($path, 'wb');
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException('Unable to write export file: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($handle, ['type', 'student_id', 'student_name', 'school_id', 'reference_id', 'status', 'detail']);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$row['type'] ?? '',
|
||||||
|
$row['student_id'] ?? '',
|
||||||
|
$row['student_name'] ?? '',
|
||||||
|
$row['school_id'] ?? '',
|
||||||
|
$row['reference_id'] ?? '',
|
||||||
|
$row['status'] ?? '',
|
||||||
|
$row['detail'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionValue(string $name): string
|
||||||
|
{
|
||||||
|
$value = CLI::getOption($name);
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$argv = $_SERVER['argv'] ?? [];
|
||||||
|
$long = '--' . $name;
|
||||||
|
foreach ($argv as $index => $arg) {
|
||||||
|
if (str_starts_with((string) $arg, $long . '=')) {
|
||||||
|
return trim(substr((string) $arg, strlen($long) + 1));
|
||||||
|
}
|
||||||
|
if ($arg === $long && isset($argv[$index + 1]) && ! str_starts_with((string) $argv[$index + 1], '--')) {
|
||||||
|
return trim((string) $argv[$index + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYear(): string
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('school_years')) {
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('name')
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['name'])) {
|
||||||
|
return (string) $row['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('configuration')) {
|
||||||
|
$row = $this->db->table('configuration')
|
||||||
|
->select('config_value')
|
||||||
|
->where('config_key', 'school_year')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['config_value'])) {
|
||||||
|
return (string) $row['config_value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||||||
|
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printReport(array $report): void
|
||||||
|
{
|
||||||
|
CLI::write('Registration Closeout Report: ' . (string) ($report['school_year'] ?? ''), ($report['ready_to_close'] ?? false) ? 'green' : 'yellow');
|
||||||
|
CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? ''));
|
||||||
|
CLI::write('Ready to close: ' . (($report['ready_to_close'] ?? false) ? 'yes' : 'no'));
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
foreach (($report['summary'] ?? []) as $key => $value) {
|
||||||
|
CLI::write($key . ': ' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($report['export_path'])) {
|
||||||
|
CLI::newLine();
|
||||||
|
CLI::write('Export written: ' . (string) $report['export_path'], 'green');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Support\Enrollment\DeliberationDecision;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class EnrollmentDataAuditCommand extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:enrollment-data-audit';
|
||||||
|
protected $description = 'Audit withdrawn terminology normalization and duplicate enrollment rows per student/year.';
|
||||||
|
protected $usage = 'php spark registration:enrollment-data-audit [--school-year=2026-2027] [--json]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'Optional school year filter for duplicate enrollment detection.',
|
||||||
|
'--json' => 'Print machine-readable JSON.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'generated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'school_year_filter' => $schoolYear !== '' ? $schoolYear : null,
|
||||||
|
'withdrawn_normalization' => $this->auditWithdrawnValues(),
|
||||||
|
'duplicate_enrollments' => $this->auditDuplicateEnrollments($schoolYear),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('Enrollment Data Audit', 'green');
|
||||||
|
CLI::newLine();
|
||||||
|
CLI::write('Withdrawn / misspelling findings: ' . count($report['withdrawn_normalization']), 'yellow');
|
||||||
|
foreach ($report['withdrawn_normalization'] as $row) {
|
||||||
|
CLI::write(sprintf(
|
||||||
|
' [%s] %s.%s = %s',
|
||||||
|
$row['table'],
|
||||||
|
$row['column'],
|
||||||
|
$row['id'] ?? $row['student_id'] ?? '?',
|
||||||
|
$row['value']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CLI::newLine();
|
||||||
|
CLI::write('Duplicate enrollment groups: ' . count($report['duplicate_enrollments']), 'yellow');
|
||||||
|
foreach ($report['duplicate_enrollments'] as $group) {
|
||||||
|
CLI::write(sprintf(
|
||||||
|
' student=%d year=%s rows=%d ids=[%s]',
|
||||||
|
$group['student_id'],
|
||||||
|
$group['school_year'],
|
||||||
|
$group['row_count'],
|
||||||
|
implode(',', $group['enrollment_ids'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function auditWithdrawnValues(): array
|
||||||
|
{
|
||||||
|
$findings = [];
|
||||||
|
$patterns = ['widthrawan', 'widthrwan', 'withdraw'];
|
||||||
|
|
||||||
|
if ($this->db->tableExists('enrollments')) {
|
||||||
|
$rows = $this->db->table('enrollments')
|
||||||
|
->select('id, student_id, school_year, enrollment_status')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$status = strtolower(trim((string) ($row['enrollment_status'] ?? '')));
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if ($status === $pattern || str_contains($status, $pattern)) {
|
||||||
|
$findings[] = [
|
||||||
|
'table' => 'enrollments',
|
||||||
|
'column' => 'enrollment_status',
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||||
|
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||||
|
'value' => (string) ($row['enrollment_status'] ?? ''),
|
||||||
|
'recommended' => 'withdrawn',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('student_decisions')) {
|
||||||
|
$rows = $this->db->table('student_decisions')
|
||||||
|
->select('id, student_id, school_year, decision, deliberation_decision_standard')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
foreach (['decision', 'deliberation_decision_standard'] as $column) {
|
||||||
|
$raw = (string) ($row[$column] ?? '');
|
||||||
|
$normalized = DeliberationDecision::normalize($raw);
|
||||||
|
if ($raw !== '' && $normalized === DeliberationDecision::WITHDRAWN && strtoupper($raw) !== DeliberationDecision::WITHDRAWN) {
|
||||||
|
$findings[] = [
|
||||||
|
'table' => 'student_decisions',
|
||||||
|
'column' => $column,
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||||
|
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||||
|
'value' => $raw,
|
||||||
|
'recommended' => DeliberationDecision::WITHDRAWN,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function auditDuplicateEnrollments(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollments')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('enrollments')
|
||||||
|
->select('student_id, school_year, COUNT(*) AS row_count, GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) AS enrollment_ids', false)
|
||||||
|
->groupBy('student_id, school_year')
|
||||||
|
->having('row_count >', 1);
|
||||||
|
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$builder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
foreach ($builder->get()->getResultArray() as $row) {
|
||||||
|
$ids = array_values(array_filter(array_map('intval', explode(',', (string) ($row['enrollment_ids'] ?? '')))));
|
||||||
|
$groups[] = [
|
||||||
|
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||||
|
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||||
|
'row_count' => (int) ($row['row_count'] ?? 0),
|
||||||
|
'enrollment_ids' => $ids,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class EnrollmentPostLaunchMonitor extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:monitor';
|
||||||
|
protected $description = 'Monitor post-launch enrollment progress, blocks, flags, and email delivery.';
|
||||||
|
protected $usage = 'php spark registration:monitor [--school-year=2026-2027] [--days=7] [--json]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
|
||||||
|
'--days' => 'Recent activity window in days. Defaults to 7.',
|
||||||
|
'--json' => 'Print machine-readable JSON.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->currentSchoolYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = max(1, (int) (CLI::getOption('days') ?? 7));
|
||||||
|
$report = $this->buildReport($schoolYear, $days);
|
||||||
|
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->printReport($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildReport(string $schoolYear, int $days): array
|
||||||
|
{
|
||||||
|
$previousYear = $this->previousSchoolYearName($schoolYear);
|
||||||
|
$expected = $this->expectedReturningStudentCount($previousYear);
|
||||||
|
$enrollments = $this->enrollmentSummary($schoolYear);
|
||||||
|
$flags = $this->flagSummary($schoolYear, $days);
|
||||||
|
$emails = $this->emailSummary($schoolYear);
|
||||||
|
$audits = $this->auditSummary($schoolYear, $days);
|
||||||
|
|
||||||
|
$submitted = (int) ($enrollments['submitted'] ?? 0);
|
||||||
|
$progressPercent = $expected > 0 ? round(($submitted / $expected) * 100, 1) : null;
|
||||||
|
$needsAttention = (int) ($flags['open_total'] ?? 0)
|
||||||
|
+ (int) ($flags['stale_total'] ?? 0)
|
||||||
|
+ (int) ($emails['failed'] ?? 0)
|
||||||
|
+ (int) ($enrollments['blocked_total'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'source_school_year' => $previousYear,
|
||||||
|
'generated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'activity_window_days' => $days,
|
||||||
|
'expected_returning_students' => $expected,
|
||||||
|
'submitted_registrations' => $submitted,
|
||||||
|
'progress_percent' => $progressPercent,
|
||||||
|
'needs_attention_count' => $needsAttention,
|
||||||
|
'enrollments' => $enrollments,
|
||||||
|
'flags' => $flags,
|
||||||
|
'emails' => $emails,
|
||||||
|
'audits' => $audits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expectedReturningStudentCount(?string $previousYear): int
|
||||||
|
{
|
||||||
|
if ($previousYear === null || ! $this->db->tableExists('student_class')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($this->db->table('student_class')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||||
|
->where('school_year', $previousYear)
|
||||||
|
->get()
|
||||||
|
->getRowArray()['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enrollmentSummary(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollments')) {
|
||||||
|
return [
|
||||||
|
'total' => 0,
|
||||||
|
'submitted' => 0,
|
||||||
|
'confirmed' => 0,
|
||||||
|
'blocked_total' => 0,
|
||||||
|
'by_status' => [],
|
||||||
|
'by_placement_status' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $this->countRows('enrollments', ['school_year' => $schoolYear]);
|
||||||
|
$submitted = $this->db->fieldExists('registration_submitted_at', 'enrollments')
|
||||||
|
? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_submitted_at IS NOT NULL')
|
||||||
|
: $total;
|
||||||
|
$confirmed = $this->db->fieldExists('registration_confirmed_at', 'enrollments')
|
||||||
|
? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_confirmed_at IS NOT NULL')
|
||||||
|
: $this->countRows('enrollments', ['school_year' => $schoolYear, 'enrollment_status' => 'enrolled']);
|
||||||
|
|
||||||
|
$blocked = 0;
|
||||||
|
if ($this->db->fieldExists('parent_enrollment_allowed', 'enrollments')) {
|
||||||
|
$blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'parent_enrollment_allowed' => 0]);
|
||||||
|
}
|
||||||
|
if ($this->db->fieldExists('exception_required', 'enrollments')) {
|
||||||
|
$blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'exception_required' => 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'submitted' => $submitted,
|
||||||
|
'confirmed' => $confirmed,
|
||||||
|
'blocked_total' => $blocked,
|
||||||
|
'by_status' => $this->groupCount('enrollments', 'enrollment_status', ['school_year' => $schoolYear]),
|
||||||
|
'by_placement_status' => $this->db->fieldExists('placement_status', 'enrollments')
|
||||||
|
? $this->groupCount('enrollments', 'placement_status', ['school_year' => $schoolYear])
|
||||||
|
: [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flagSummary(string $schoolYear, int $days): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_flags')) {
|
||||||
|
return [
|
||||||
|
'open_total' => 0,
|
||||||
|
'stale_total' => 0,
|
||||||
|
'by_type' => [],
|
||||||
|
'by_priority' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$staleBefore = date('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'open_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']),
|
||||||
|
'stale_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open'], 'created_at < ' . $this->db->escape($staleBefore)),
|
||||||
|
'by_type' => $this->groupCount('enrollment_flags', 'flag_type', ['school_year' => $schoolYear, 'status' => 'open']),
|
||||||
|
'by_priority' => $this->groupCount('enrollment_flags', 'priority', ['school_year' => $schoolYear, 'status' => 'open']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emailSummary(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_email_records')) {
|
||||||
|
return [
|
||||||
|
'total' => 0,
|
||||||
|
'sent' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'pending' => 0,
|
||||||
|
'by_status' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]),
|
||||||
|
'sent' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'sent']),
|
||||||
|
'failed' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'failed']),
|
||||||
|
'pending' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'pending']),
|
||||||
|
'by_status' => $this->groupCount('enrollment_email_records', 'delivery_status', ['school_year' => $schoolYear]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditSummary(string $schoolYear, int $days): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||||
|
return [
|
||||||
|
'recent_total' => 0,
|
||||||
|
'by_action' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = date('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recent_total' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)),
|
||||||
|
'by_action' => $this->groupCount('enrollment_transition_audits', 'action', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRows(string $table, array $where, ?string $rawWhere = null): int
|
||||||
|
{
|
||||||
|
$builder = $this->db->table($table);
|
||||||
|
foreach ($where as $field => $value) {
|
||||||
|
$builder->where($field, $value);
|
||||||
|
}
|
||||||
|
if ($rawWhere !== null) {
|
||||||
|
$builder->where($rawWhere, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->countAllResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function groupCount(string $table, string $field, array $where, ?string $rawWhere = null): array
|
||||||
|
{
|
||||||
|
if (! $this->db->fieldExists($field, $table)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table($table)
|
||||||
|
->select($field . ' AS value, COUNT(*) AS total', false)
|
||||||
|
->groupBy($field)
|
||||||
|
->orderBy('total', 'DESC', false);
|
||||||
|
|
||||||
|
foreach ($where as $whereField => $value) {
|
||||||
|
$builder->where($whereField, $value);
|
||||||
|
}
|
||||||
|
if ($rawWhere !== null) {
|
||||||
|
$builder->where($rawWhere, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($builder->get()->getResultArray() as $row) {
|
||||||
|
$key = trim((string) ($row['value'] ?? '')) ?: 'blank';
|
||||||
|
$result[$key] = (int) ($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYear(): string
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('school_years')) {
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('name')
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['name'])) {
|
||||||
|
return (string) $row['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('configuration')) {
|
||||||
|
$row = $this->db->table('configuration')
|
||||||
|
->select('config_value')
|
||||||
|
->where('config_key', 'school_year')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['config_value'])) {
|
||||||
|
return (string) $row['config_value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||||||
|
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printReport(array $report): void
|
||||||
|
{
|
||||||
|
CLI::write('Registration Post-Launch Monitor: ' . (string) ($report['school_year'] ?? ''), 'cyan');
|
||||||
|
CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? ''));
|
||||||
|
CLI::write('Expected returning students: ' . (int) ($report['expected_returning_students'] ?? 0));
|
||||||
|
CLI::write('Submitted registrations: ' . (int) ($report['submitted_registrations'] ?? 0));
|
||||||
|
CLI::write('Progress: ' . (($report['progress_percent'] ?? null) === null ? 'n/a' : (string) $report['progress_percent'] . '%'));
|
||||||
|
CLI::write('Needs attention: ' . (int) ($report['needs_attention_count'] ?? 0), ((int) ($report['needs_attention_count'] ?? 0)) > 0 ? 'yellow' : 'green');
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
$this->printSection('Enrollments', $report['enrollments'] ?? []);
|
||||||
|
$this->printSection('Flags', $report['flags'] ?? []);
|
||||||
|
$this->printSection('Emails', $report['emails'] ?? []);
|
||||||
|
$this->printSection('Recent Audits', $report['audits'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printSection(string $title, array $data): void
|
||||||
|
{
|
||||||
|
CLI::write($title, 'white');
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
CLI::write(' ' . $key . ':');
|
||||||
|
foreach ($value as $nestedKey => $nestedValue) {
|
||||||
|
CLI::write(' ' . $nestedKey . ': ' . $nestedValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CLI::write(' ' . $key . ': ' . $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CLI::newLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class EnrollmentReleaseAudit extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:release-audit';
|
||||||
|
protected $description = 'Audit school-year transition and registration readiness before launch.';
|
||||||
|
protected $usage = 'php spark registration:release-audit [--school-year=2026-2027] [--json]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
|
||||||
|
'--json' => 'Print machine-readable JSON.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->currentSchoolYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->buildReport($schoolYear);
|
||||||
|
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->printReport($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildReport(string $schoolYear): array
|
||||||
|
{
|
||||||
|
$previousYear = $this->previousSchoolYearName($schoolYear);
|
||||||
|
$checks = [
|
||||||
|
'school_year_configuration' => $this->schoolYearConfigurationCheck($schoolYear),
|
||||||
|
'launch_approval' => $this->launchApprovalCheck($schoolYear),
|
||||||
|
'deliberation_decisions' => $this->deliberationDecisionCheck($schoolYear, $previousYear),
|
||||||
|
'open_enrollment_flags' => $this->openEnrollmentFlagsCheck($schoolYear),
|
||||||
|
'failed_registration_emails' => $this->failedEmailCheck($schoolYear),
|
||||||
|
'duplicate_or_invalid_contacts' => $this->contactQualityCheck(),
|
||||||
|
'email_preview_sample' => $this->emailPreviewCheck($schoolYear),
|
||||||
|
];
|
||||||
|
|
||||||
|
$blocking = 0;
|
||||||
|
$warnings = 0;
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
if (($check['severity'] ?? '') === 'blocking') {
|
||||||
|
$blocking++;
|
||||||
|
} elseif (($check['severity'] ?? '') === 'warning') {
|
||||||
|
$warnings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'source_school_year' => $previousYear,
|
||||||
|
'generated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'ready' => $blocking === 0,
|
||||||
|
'blocking_count' => $blocking,
|
||||||
|
'warning_count' => $warnings,
|
||||||
|
'checks' => $checks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function schoolYearConfigurationCheck(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('school_years')) {
|
||||||
|
return $this->check('blocking', 'school_years table is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
|
||||||
|
if ($row === null) {
|
||||||
|
return $this->check('blocking', 'Target school year record was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
foreach ([
|
||||||
|
'registration_starts_on' => 'registration opening date',
|
||||||
|
'registration_ends_on' => 'registration deadline',
|
||||||
|
] as $field => $label) {
|
||||||
|
if (empty($row[$field])) {
|
||||||
|
$missing[] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('email_templates')) {
|
||||||
|
$missing[] = 'email templates table';
|
||||||
|
} elseif ($this->activeTemplateCount('registration_opening') <= 0) {
|
||||||
|
$missing[] = 'active registration email template';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missing === []
|
||||||
|
? $this->check('pass', 'Required school-year launch configuration is present.')
|
||||||
|
: $this->check('blocking', 'Missing: ' . implode(', ', $missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function launchApprovalCheck(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('school_years') || ! $this->db->fieldExists('registration_launch_approved_at', 'school_years')) {
|
||||||
|
return $this->check('warning', 'Launch approval fields have not been migrated yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('registration_launch_approved_at')
|
||||||
|
->where('name', $schoolYear)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return ! empty($row['registration_launch_approved_at'] ?? null)
|
||||||
|
? $this->check('pass', 'Registration launch has been approved.')
|
||||||
|
: $this->check('warning', 'Registration launch has not been approved yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deliberationDecisionCheck(string $schoolYear, ?string $previousYear): array
|
||||||
|
{
|
||||||
|
if ($previousYear === null) {
|
||||||
|
return $this->check('warning', 'Previous school year could not be inferred.');
|
||||||
|
}
|
||||||
|
if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('student_decisions')) {
|
||||||
|
return $this->check('warning', 'Student placement or decision tables are missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('student_class sc')
|
||||||
|
->select('COUNT(DISTINCT sc.student_id) AS total', false)
|
||||||
|
->join('student_decisions sd', 'sd.student_id = sc.student_id AND sd.school_year = ' . $this->db->escape($previousYear), 'left', false)
|
||||||
|
->where('sc.school_year', $previousYear)
|
||||||
|
->groupStart()
|
||||||
|
->where('sd.id IS NULL')
|
||||||
|
->orWhere('sd.decision IS NULL')
|
||||||
|
->orWhere('TRIM(sd.decision) =', '')
|
||||||
|
->groupEnd()
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$missing = (int) ($rows['total'] ?? 0);
|
||||||
|
return $missing === 0
|
||||||
|
? $this->check('pass', 'All placed source-year students have a recorded deliberation decision.')
|
||||||
|
: $this->check('blocking', $missing . ' source-year student(s) are missing deliberation decisions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function openEnrollmentFlagsCheck(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_flags')) {
|
||||||
|
return $this->check('warning', 'Enrollment flags table has not been migrated yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $this->db->table('enrollment_flags')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('status', 'open')
|
||||||
|
->countAllResults();
|
||||||
|
|
||||||
|
return $count === 0
|
||||||
|
? $this->check('pass', 'No open enrollment follow-up flags remain.')
|
||||||
|
: $this->check('warning', $count . ' open enrollment follow-up flag(s) remain.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failedEmailCheck(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_email_records')) {
|
||||||
|
return $this->check('warning', 'Enrollment email record table has not been migrated yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = $this->db->table('enrollment_email_records')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('delivery_status', 'failed')
|
||||||
|
->countAllResults();
|
||||||
|
|
||||||
|
return $failed === 0
|
||||||
|
? $this->check('pass', 'No failed registration email records found.')
|
||||||
|
: $this->check('warning', $failed . ' failed registration email record(s) need retry or review.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function contactQualityCheck(): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('users')) {
|
||||||
|
return $this->check('warning', 'Users table is missing.');
|
||||||
|
}
|
||||||
|
if (! $this->db->fieldExists('email', 'users')) {
|
||||||
|
return $this->check('warning', 'Users table does not include an email field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->query(
|
||||||
|
"SELECT LOWER(TRIM(email)) AS normalized_email, COUNT(*) AS total
|
||||||
|
FROM users
|
||||||
|
WHERE email IS NOT NULL AND email <> ''
|
||||||
|
GROUP BY LOWER(TRIM(email))
|
||||||
|
HAVING COUNT(*) > 1"
|
||||||
|
)->getResultArray();
|
||||||
|
|
||||||
|
$invalid = 0;
|
||||||
|
foreach ($this->db->table('users')->select('email')->where('email IS NOT NULL')->where('email !=', '')->get()->getResultArray() as $row) {
|
||||||
|
if (! filter_var((string) ($row['email'] ?? ''), FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$invalid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicates = count($rows);
|
||||||
|
if ($duplicates === 0 && $invalid === 0) {
|
||||||
|
return $this->check('pass', 'No duplicate or invalid parent/user email addresses were detected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->check('warning', $duplicates . ' duplicate email value(s) and ' . $invalid . ' invalid email address(es) were detected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emailPreviewCheck(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('students')) {
|
||||||
|
return $this->check('warning', 'Students table is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->db->table('students')
|
||||||
|
->select('parent_id')
|
||||||
|
->where('parent_id IS NOT NULL', null, false)
|
||||||
|
->orderBy('parent_id', 'ASC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$parentId = is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : 0;
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return $this->check('warning', 'No parent with students was found for email preview.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->check('blocking', 'Email preview generation failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message !== null && trim((string) ($message['body'] ?? '')) !== ''
|
||||||
|
? $this->check('pass', 'A consolidated registration email preview can be generated.')
|
||||||
|
: $this->check('blocking', 'No consolidated registration email preview could be generated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYear(): string
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('school_years')) {
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('name')
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['name'])) {
|
||||||
|
return (string) $row['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('configuration')) {
|
||||||
|
$row = $this->db->table('configuration')
|
||||||
|
->select('config_value')
|
||||||
|
->where('config_key', 'school_year')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['config_value'])) {
|
||||||
|
return (string) $row['config_value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeTemplateCount(string $key): int
|
||||||
|
{
|
||||||
|
$fields = $this->db->getFieldNames('email_templates');
|
||||||
|
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
||||||
|
|
||||||
|
return $this->db->table('email_templates')
|
||||||
|
->where($keyField, $key)
|
||||||
|
->where('is_active', 1)
|
||||||
|
->countAllResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||||||
|
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function check(string $severity, string $message): array
|
||||||
|
{
|
||||||
|
return ['severity' => $severity, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printReport(array $report): void
|
||||||
|
{
|
||||||
|
CLI::write('Registration Release Audit: ' . (string) ($report['school_year'] ?? ''), ($report['ready'] ?? false) ? 'green' : 'red');
|
||||||
|
CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? ''));
|
||||||
|
CLI::write('Ready: ' . (($report['ready'] ?? false) ? 'yes' : 'no'));
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
foreach (($report['checks'] ?? []) as $name => $check) {
|
||||||
|
$severity = (string) ($check['severity'] ?? 'warning');
|
||||||
|
$color = match ($severity) {
|
||||||
|
'pass' => 'green',
|
||||||
|
'blocking' => 'red',
|
||||||
|
default => 'yellow',
|
||||||
|
};
|
||||||
|
CLI::write(sprintf('[%s] %s: %s', strtoupper($severity), $name, (string) ($check['message'] ?? '')), $color);
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::newLine();
|
||||||
|
CLI::write('Blocking: ' . (int) ($report['blocking_count'] ?? 0), ((int) ($report['blocking_count'] ?? 0)) > 0 ? 'red' : 'green');
|
||||||
|
CLI::write('Warnings: ' . (int) ($report['warning_count'] ?? 0), ((int) ($report['warning_count'] ?? 0)) > 0 ? 'yellow' : 'green');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts one legacy distribution movement into immutable, priced issue
|
||||||
|
* evidence. The existing stock movement is linked, never replayed, so this
|
||||||
|
* command cannot decrement inventory twice.
|
||||||
|
*/
|
||||||
|
class ReconcileLegacyBookIssue extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Inventory';
|
||||||
|
protected $name = 'inventory:reconcile-legacy-book-issue';
|
||||||
|
protected $description = 'List or explicitly reconcile legacy student book distributions without guessing a price.';
|
||||||
|
protected $usage = 'php spark inventory:reconcile-legacy-book-issue [--school-year=2025-2026] [--movement-id=123 --unit-price=25.00 --reason="approved evidence" --actor-id=7 --commit]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'Filter the read-only orphan list.',
|
||||||
|
'--movement-id' => 'One legacy distribution movement to reconcile.',
|
||||||
|
'--unit-price' => 'Admin-approved historical unit charge price.',
|
||||||
|
'--reason' => 'Required audit explanation for the evidence source.',
|
||||||
|
'--actor-id' => 'Required administrator user ID.',
|
||||||
|
'--commit' => 'Actually write; without this flag the command is read-only.',
|
||||||
|
'--json' => 'Print listing or result as JSON.',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
foreach (['inventory_movements', 'inventory_item_years', 'student_book_issues', 'withdrawal_financial_calculations', 'refunds'] as $table) {
|
||||||
|
if (! $db->tableExists($table)) {
|
||||||
|
CLI::error('Required table is missing: ' . $table . '. Run migrations first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$movementId = (int) (CLI::getOption('movement-id') ?? 0);
|
||||||
|
if ($movementId <= 0) {
|
||||||
|
$builder = $db->table('inventory_movements im')
|
||||||
|
->select('im.id, im.item_id, im.student_id, im.class_section_id, im.qty_change, im.school_year, im.created_at, i.name AS book_name')
|
||||||
|
->join('inventory_items i', 'i.id = im.item_id', 'inner')
|
||||||
|
->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left')
|
||||||
|
->where('im.movement_type', 'distribution')
|
||||||
|
->where('im.student_id IS NOT NULL', null, false)
|
||||||
|
->where('im.qty_change <', 0)
|
||||||
|
->where('im.status', 'posted')
|
||||||
|
->where('sbi.id', null)
|
||||||
|
->where('i.type', 'book')
|
||||||
|
->orderBy('im.id', 'ASC');
|
||||||
|
$year = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||||
|
if ($year !== '') {
|
||||||
|
$builder->where('im.school_year', $year);
|
||||||
|
}
|
||||||
|
$rows = $builder->get()->getResultArray();
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode(['count' => count($rows), 'legacy_distributions' => $rows], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CLI::write('Legacy book distributions requiring priced issue evidence: ' . count($rows), 'yellow');
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
CLI::write(sprintf('#%d %s student=%d qty=%d year=%s', (int) $row['id'], (string) $row['book_name'], (int) $row['student_id'], abs((int) $row['qty_change']), (string) $row['school_year']));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$priceCents = $this->priceToCents((string) (CLI::getOption('unit-price') ?? ''));
|
||||||
|
$reason = trim((string) (CLI::getOption('reason') ?? ''));
|
||||||
|
$actorId = (int) (CLI::getOption('actor-id') ?? 0);
|
||||||
|
if ($reason === '' || $actorId <= 0) {
|
||||||
|
throw new InvalidArgumentException('--reason and a positive --actor-id are required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->transBegin();
|
||||||
|
$movement = $db->query('SELECT * FROM inventory_movements WHERE id = ? FOR UPDATE', [$movementId])->getRowArray();
|
||||||
|
if ($movement === null || ($movement['movement_type'] ?? '') !== 'distribution' || ($movement['status'] ?? '') !== 'posted' || (int) ($movement['qty_change'] ?? 0) >= 0 || (int) ($movement['student_id'] ?? 0) <= 0) {
|
||||||
|
throw new InvalidArgumentException('The movement is not a negative student distribution.');
|
||||||
|
}
|
||||||
|
if ($db->table('student_book_issues')->where('distribution_movement_id', $movementId)->countAllResults() > 0) {
|
||||||
|
throw new InvalidArgumentException('This movement is already linked to student issue evidence.');
|
||||||
|
}
|
||||||
|
$book = $db->table('inventory_items')->select('id, name, type')->where('id', (int) $movement['item_id'])->get(1)->getRowArray();
|
||||||
|
if ($book === null || ($book['type'] ?? '') !== 'book') {
|
||||||
|
throw new InvalidArgumentException('The movement item is not a book.');
|
||||||
|
}
|
||||||
|
$schoolYear = trim((string) ($movement['school_year'] ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
throw new InvalidArgumentException('The legacy movement has no school year. Correct that evidence first.');
|
||||||
|
}
|
||||||
|
$itemYear = $db->table('inventory_item_years')
|
||||||
|
->where('inventory_item_id', (int) $movement['item_id'])
|
||||||
|
->where('school_year', $schoolYear)->get(1)->getRowArray();
|
||||||
|
if ($itemYear === null) {
|
||||||
|
throw new InvalidArgumentException('The book has no inventory-year record for ' . $schoolYear . '.');
|
||||||
|
}
|
||||||
|
$enrollments = $db->table('enrollments')->select('id, parent_id, class_section_id')
|
||||||
|
->where('student_id', (int) $movement['student_id'])->where('school_year', $schoolYear)->get()->getResultArray();
|
||||||
|
if (count($enrollments) !== 1 || (int) ($enrollments[0]['parent_id'] ?? 0) <= 0) {
|
||||||
|
throw new InvalidArgumentException('Exactly one parent-linked enrollment must exist for the student and year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$quantity = abs((int) $movement['qty_change']);
|
||||||
|
if ($quantity <= 0 || $priceCents > intdiv(2147483647, $quantity)) {
|
||||||
|
throw new InvalidArgumentException('The quantity and approved price exceed the supported charge range.');
|
||||||
|
}
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$issuedAt = trim((string) ($movement['created_at'] ?? '')) ?: $now;
|
||||||
|
$issue = [
|
||||||
|
'student_id' => (int) $movement['student_id'],
|
||||||
|
'enrollment_id' => (int) $enrollments[0]['id'],
|
||||||
|
'parent_id' => (int) $enrollments[0]['parent_id'],
|
||||||
|
'inventory_item_id' => (int) $movement['item_id'],
|
||||||
|
'inventory_item_year_id' => (int) $itemYear['id'],
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'class_section_id' => (int) ($movement['class_section_id'] ?? $enrollments[0]['class_section_id'] ?? 0) ?: null,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_charge_price_cents' => $priceCents,
|
||||||
|
'total_charge_cents' => $priceCents * $quantity,
|
||||||
|
'distribution_movement_id' => $movementId,
|
||||||
|
'idempotency_key' => 'legacy-distribution:' . $movementId,
|
||||||
|
'status' => 'issued',
|
||||||
|
'issued_at' => $issuedAt,
|
||||||
|
'issued_by' => $actorId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
if (CLI::getOption('commit') === null) {
|
||||||
|
$db->transRollback();
|
||||||
|
$result = ['mode' => 'dry-run', 'movement_id' => $movementId, 'book' => $book['name'], 'issue' => $issue, 'reason' => $reason];
|
||||||
|
CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
CLI::write('DRY RUN — add --commit to save.', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $db->table('student_book_issues')->insert($issue)) {
|
||||||
|
throw new RuntimeException('Unable to insert issue evidence.');
|
||||||
|
}
|
||||||
|
$issueId = (int) $db->insertID();
|
||||||
|
$db->table('inventory_movements')->where('id', $movementId)->update([
|
||||||
|
'item_year_id' => (int) $itemYear['id'],
|
||||||
|
'idempotency_key' => 'legacy-distribution-movement:' . $movementId,
|
||||||
|
'status' => 'posted',
|
||||||
|
'source_type' => 'student_book_issue',
|
||||||
|
'source_id' => $issueId,
|
||||||
|
'note' => trim((string) ($movement['note'] ?? '') . "\nLegacy evidence approved by user #{$actorId}: {$reason}"),
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$affectedCalculations = $db->table('withdrawal_financial_calculations')
|
||||||
|
->select('id')
|
||||||
|
->where('student_id', (int) $movement['student_id'])
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('status', 'posted')
|
||||||
|
->where('withdrawal_request_date >=', substr($issuedAt, 0, 10))
|
||||||
|
->get()->getResultArray();
|
||||||
|
$affectedIds = array_values(array_filter(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $affectedCalculations)));
|
||||||
|
if ($affectedIds !== []) {
|
||||||
|
$db->table('withdrawal_financial_calculations')->whereIn('id', $affectedIds)->update([
|
||||||
|
'status' => 'requires_review',
|
||||||
|
'active_posted_key' => null,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$db->table('refunds')->whereIn('withdrawal_calculation_id', $affectedIds)->update([
|
||||||
|
'reconciliation_status' => 'requires_review',
|
||||||
|
'reconciliation_reason' => 'Legacy book issue evidence was added after the withdrawal calculation was posted.',
|
||||||
|
'reconciliation_required_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (! $db->transCommit()) {
|
||||||
|
throw new RuntimeException('Unable to commit the reconciliation.');
|
||||||
|
}
|
||||||
|
CLI::write('Reconciled movement #' . $movementId . ' as issue #' . $issueId . ' without changing stock.', 'green');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
|
CLI::error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function priceToCents(string $price): int
|
||||||
|
{
|
||||||
|
$price = trim($price);
|
||||||
|
if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $price)) {
|
||||||
|
throw new InvalidArgumentException('--unit-price is required with no more than two decimal places.');
|
||||||
|
}
|
||||||
|
[$whole, $fraction] = array_pad(explode('.', $price, 2), 2, '');
|
||||||
|
$cents = ((int) $whole * 100) + (int) str_pad($fraction, 2, '0');
|
||||||
|
if ($cents <= 0 || $cents > 2147483647) {
|
||||||
|
throw new InvalidArgumentException('Unit price must be greater than zero and within range.');
|
||||||
|
}
|
||||||
|
return $cents;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class RefreshEnrollmentAdminTables extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:refresh-admin-tables';
|
||||||
|
protected $description = 'Refresh enrollment admin dashboard data from the transition service.';
|
||||||
|
protected $usage = 'php spark registration:refresh-admin-tables [--school-year=2026-2027] [--limit=50] [--dry-run]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
|
||||||
|
'--limit' => 'Maximum number of source-year students to process.',
|
||||||
|
'--dry-run' => 'Preview target/source years and source student count without writing.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$options = $this->parseOptions($params);
|
||||||
|
$schoolYear = trim((string) ($options['school-year'] ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->currentSchoolYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceYear = $this->previousSchoolYearName($schoolYear);
|
||||||
|
if ($schoolYear === '' || $sourceYear === null) {
|
||||||
|
CLI::error('Unable to determine target/source school year.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = (int) ($options['limit'] ?? 0);
|
||||||
|
$dryRun = ! empty($options['dry-run']);
|
||||||
|
$students = $this->sourceStudents($sourceYear, $limit);
|
||||||
|
|
||||||
|
CLI::write('Enrollment admin refresh', 'cyan');
|
||||||
|
CLI::write('Target year: ' . $schoolYear);
|
||||||
|
CLI::write('Source year: ' . $sourceYear);
|
||||||
|
CLI::write('Source students: ' . count($students));
|
||||||
|
|
||||||
|
$before = $this->dashboardCounts($schoolYear);
|
||||||
|
$this->printCounts('Before', $before);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
CLI::write('Dry-run complete. Re-run without --dry-run to write updates.', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$eligible = 0;
|
||||||
|
$blocked = 0;
|
||||||
|
$errors = 0;
|
||||||
|
$transitionService = service('enrollmentTransition');
|
||||||
|
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$studentId = (int) ($student['student_id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$evaluation = $transitionService->applyInitialTransition(
|
||||||
|
$studentId,
|
||||||
|
$sourceYear,
|
||||||
|
$schoolYear,
|
||||||
|
is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null,
|
||||||
|
null,
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
if (! empty($evaluation['academic_eligible']) && ($evaluation['blockers'] ?? []) === []) {
|
||||||
|
$eligible++;
|
||||||
|
} else {
|
||||||
|
$blocked++;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$errors++;
|
||||||
|
CLI::error('Student #' . $studentId . ' failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $this->dashboardCounts($schoolYear);
|
||||||
|
$this->printCounts('After', $after);
|
||||||
|
CLI::write('Processed: ' . $processed, 'white');
|
||||||
|
CLI::write('Eligible/applied: ' . $eligible, 'green');
|
||||||
|
CLI::write('Blocked/flagged: ' . $blocked, $blocked > 0 ? 'yellow' : 'white');
|
||||||
|
CLI::write('Errors: ' . $errors, $errors > 0 ? 'red' : 'white');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseOptions(array $params): array
|
||||||
|
{
|
||||||
|
$options = [
|
||||||
|
'school-year' => '',
|
||||||
|
'limit' => 0,
|
||||||
|
'dry-run' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
$rawParams = array_merge($params, array_slice($_SERVER['argv'] ?? [], 2));
|
||||||
|
|
||||||
|
foreach ($rawParams as $param) {
|
||||||
|
$value = trim((string) $param);
|
||||||
|
if ($value === '--dry-run') {
|
||||||
|
$options['dry-run'] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with($value, '--') || ! str_contains($value, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $raw] = explode('=', substr($value, 2), 2);
|
||||||
|
if (array_key_exists($key, $options)) {
|
||||||
|
$options[$key] = $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceStudents(string $sourceYear, int $limit): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('student_class')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('student_class sc')
|
||||||
|
->select('sc.student_id, s.parent_id')
|
||||||
|
->join('students s', 's.id = sc.student_id', 'left')
|
||||||
|
->where('sc.school_year', $sourceYear)
|
||||||
|
->groupBy('sc.student_id, s.parent_id')
|
||||||
|
->orderBy('sc.student_id', 'ASC');
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$builder->limit($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dashboardCounts(string $schoolYear): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enrollments' => $this->countRows('enrollments', ['school_year' => $schoolYear]),
|
||||||
|
'open_flags' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']),
|
||||||
|
'audits' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear]),
|
||||||
|
'exceptions' => $this->countRows('enrollment_exceptions', ['school_year' => $schoolYear]),
|
||||||
|
'email_records' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRows(string $table, array $where): int
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists($table)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table($table);
|
||||||
|
foreach ($where as $field => $value) {
|
||||||
|
$builder->where($field, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->countAllResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printCounts(string $label, array $counts): void
|
||||||
|
{
|
||||||
|
CLI::write($label . ' counts:', 'white');
|
||||||
|
foreach ($counts as $key => $value) {
|
||||||
|
CLI::write(' ' . $key . ': ' . $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYear(): string
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('school_years')) {
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('name')
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['name'])) {
|
||||||
|
return (string) $row['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('configuration')) {
|
||||||
|
$row = $this->db->table('configuration')
|
||||||
|
->select('config_value')
|
||||||
|
->where('config_key', 'school_year')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! empty($row['config_value'])) {
|
||||||
|
return (string) $row['config_value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||||||
|
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class SendExamDraftDeadlineReminders extends BaseCommand
|
|||||||
|
|
||||||
$configModel = new ConfigurationModel();
|
$configModel = new ConfigurationModel();
|
||||||
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
|
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
|
||||||
$semester = (string) ($configModel->getConfig('semester') ?? '');
|
$semester = (string) (getSemester() ?? '');
|
||||||
$deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? ''));
|
$deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||||
if ($deadlineValue === '') {
|
if ($deadlineValue === '') {
|
||||||
CLI::write('exam_draft_deadline is not configured.', 'yellow');
|
CLI::write('exam_draft_deadline is not configured.', 'yellow');
|
||||||
@@ -54,13 +54,10 @@ class SendExamDraftDeadlineReminders extends BaseCommand
|
|||||||
|
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$teacherClassRows = $db->table('teacher_class')
|
$teacherClassRows = $db->table('teacher_class')
|
||||||
->select('teacher_id, class_section_id, school_year, semester')
|
->select('teacher_id, class_section_id, school_year')
|
||||||
->when($schoolYear !== '', static function ($builder) use ($schoolYear) {
|
->when($schoolYear !== '', static function ($builder) use ($schoolYear) {
|
||||||
return $builder->where('school_year', $schoolYear);
|
return $builder->where('school_year', $schoolYear);
|
||||||
})
|
})
|
||||||
->when($semester !== '', static function ($builder) use ($semester) {
|
|
||||||
return $builder->where('semester', $semester);
|
|
||||||
})
|
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class SendRegistrationOpeningEmail extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:send-opening-email';
|
||||||
|
protected $description = 'Send the parent registration opening email on the configured registration start date.';
|
||||||
|
protected $usage = 'php spark registration:send-opening-email [--force] [--date=YYYY-MM-DD] [--email=parent@example.com] [--dry-run] [--tz=America/New_York]';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$tzName = (string) (CLI::getOption('tz') ?? config('School')->attendance['timezone'] ?? 'America/New_York');
|
||||||
|
$timezone = new \DateTimeZone($tzName);
|
||||||
|
$dateOption = CLI::getOption('date');
|
||||||
|
$date = $dateOption
|
||||||
|
? new \DateTimeImmutable((string) $dateOption, $timezone)
|
||||||
|
: new \DateTimeImmutable('today', $timezone);
|
||||||
|
|
||||||
|
$force = CLI::getOption('force') !== null;
|
||||||
|
$dryRun = CLI::getOption('dry-run') !== null;
|
||||||
|
$email = CLI::getOption('email');
|
||||||
|
$email = is_string($email) && trim($email) !== '' ? trim($email) : null;
|
||||||
|
|
||||||
|
if ($email !== null && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
CLI::error('Invalid --email value.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = service('enrollmentRegistrationEmail');
|
||||||
|
$summary = $service->sendForDate($date, $force, $email, $dryRun);
|
||||||
|
|
||||||
|
foreach ($summary['messages'] as $message) {
|
||||||
|
CLI::write($message, 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentLabel = $dryRun ? 'Would send' : 'Sent';
|
||||||
|
|
||||||
|
CLI::write(sprintf(
|
||||||
|
'Registration opening email complete. Years: %d. Recipients: %d. %s: %d. Failed: %d. Skipped: %d%s.',
|
||||||
|
$summary['school_years'],
|
||||||
|
$summary['recipients'],
|
||||||
|
$sentLabel,
|
||||||
|
$summary['sent'],
|
||||||
|
$summary['failed'],
|
||||||
|
$summary['skipped'],
|
||||||
|
$dryRun ? ' (dry run)' : ''
|
||||||
|
), $summary['failed'] > 0 ? 'red' : 'green');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Services\EnrollmentStatusService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class SyncStudentActivity extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Enrollment';
|
||||||
|
protected $name = 'students:sync-activity';
|
||||||
|
protected $description = 'Synchronize students.is_active from current-year enrollment_status.';
|
||||||
|
protected $usage = 'php spark students:sync-activity --school-year=YYYY-YYYY [--dry-run]';
|
||||||
|
protected $options = [
|
||||||
|
'--school-year' => 'School year to use as the controlling enrollment year.',
|
||||||
|
'--dry-run' => 'Report changes without updating students.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
private EnrollmentStatusService $statusService;
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$this->statusService = new EnrollmentStatusService($this->db);
|
||||||
|
|
||||||
|
$schoolYear = $this->optionValue('school-year');
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->statusService->currentSchoolYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
CLI::error('Missing --school-year and no configured current school year was found.');
|
||||||
|
return EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dryRun = CLI::getOption('dry-run') !== null;
|
||||||
|
$duplicates = $this->duplicates($schoolYear);
|
||||||
|
$unknownStatuses = [];
|
||||||
|
$withoutEnrollment = [];
|
||||||
|
$mismatches = [];
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
$students = $this->db->table('students')
|
||||||
|
->select('id, school_id, firstname, lastname, is_active')
|
||||||
|
->orderBy('id', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$studentId = (int) $student['id'];
|
||||||
|
$enrollment = $this->statusService->controllingEnrollment($studentId, $schoolYear);
|
||||||
|
if ($enrollment === null) {
|
||||||
|
$expected = 0;
|
||||||
|
$withoutEnrollment[] = $this->studentLabel($student);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$status = $this->statusService->normalizeStatus((string) ($enrollment['enrollment_status'] ?? ''));
|
||||||
|
$expected = $this->statusService->activeFlagForStatus($status);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$unknownStatuses[] = [
|
||||||
|
'student' => $this->studentLabel($student),
|
||||||
|
'enrollment_id' => (int) ($enrollment['id'] ?? 0),
|
||||||
|
'status' => (string) ($enrollment['enrollment_status'] ?? ''),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = (int) ($student['is_active'] ?? 1);
|
||||||
|
if ($actual !== $expected) {
|
||||||
|
$mismatches[] = [
|
||||||
|
'student' => $this->studentLabel($student),
|
||||||
|
'current' => $actual,
|
||||||
|
'expected' => $expected,
|
||||||
|
'status' => (string) ($enrollment['enrollment_status'] ?? 'no enrollment'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->db->table('students')->where('id', $studentId)->update(['is_active' => $expected]);
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('School year: ' . $schoolYear);
|
||||||
|
CLI::write('Dry run: ' . ($dryRun ? 'yes' : 'no'));
|
||||||
|
CLI::write('Duplicate enrollment groups: ' . count($duplicates));
|
||||||
|
foreach ($duplicates as $row) {
|
||||||
|
CLI::write(sprintf(
|
||||||
|
' student_id=%d school_year=%s semester=%s count=%d',
|
||||||
|
(int) $row['student_id'],
|
||||||
|
(string) $row['school_year'],
|
||||||
|
(string) ($row['semester'] ?? ''),
|
||||||
|
(int) $row['row_count']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('Unknown statuses: ' . count($unknownStatuses));
|
||||||
|
foreach ($unknownStatuses as $row) {
|
||||||
|
CLI::write(sprintf(' %s enrollment_id=%d status=%s', $row['student'], $row['enrollment_id'], $row['status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('Students without current enrollment: ' . count($withoutEnrollment));
|
||||||
|
foreach (array_slice($withoutEnrollment, 0, 50) as $label) {
|
||||||
|
CLI::write(' ' . $label);
|
||||||
|
}
|
||||||
|
if (count($withoutEnrollment) > 50) {
|
||||||
|
CLI::write(' ... ' . (count($withoutEnrollment) - 50) . ' more');
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('Mismatches: ' . count($mismatches));
|
||||||
|
foreach ($mismatches as $row) {
|
||||||
|
CLI::write(sprintf(
|
||||||
|
' %s current=%d expected=%d status=%s',
|
||||||
|
$row['student'],
|
||||||
|
$row['current'],
|
||||||
|
$row['expected'],
|
||||||
|
$row['status']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
CLI::write('Updated students: ' . $updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function duplicates(string $schoolYear): array
|
||||||
|
{
|
||||||
|
return $this->db->table('enrollments')
|
||||||
|
->select('student_id, school_year, semester, COUNT(*) AS row_count')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->groupBy('student_id, school_year, semester')
|
||||||
|
->having('COUNT(*) >', 1)
|
||||||
|
->orderBy('student_id', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function studentLabel(array $student): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||||
|
return '#' . (int) ($student['id'] ?? 0) . ($name !== '' ? ' ' . $name : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionValue(string $name): string
|
||||||
|
{
|
||||||
|
$value = CLI::getOption($name);
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$argv = $_SERVER['argv'] ?? [];
|
||||||
|
$prefix = '--' . $name . '=';
|
||||||
|
foreach ($argv as $index => $arg) {
|
||||||
|
if (is_string($arg) && str_starts_with($arg, $prefix)) {
|
||||||
|
return trim(substr($arg, strlen($prefix)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($arg === '--' . $name && isset($argv[$index + 1])) {
|
||||||
|
return trim((string) $argv[$index + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
/** Read-only audit for withdrawal refund posting prerequisites. */
|
||||||
|
class WithdrawalInventoryAudit extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Financial';
|
||||||
|
protected $name = 'financial:withdrawal-inventory-audit';
|
||||||
|
protected $description = 'Audit withdrawal policy, book inventory evidence, invoices, and open refunds without changing data.';
|
||||||
|
protected $usage = 'php spark financial:withdrawal-inventory-audit --school-year=2025-2026 [--json]';
|
||||||
|
protected $options = ['--school-year' => 'Required school year.', '--json' => 'Print JSON.'];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$year = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||||
|
if ($year === '') {
|
||||||
|
CLI::error('--school-year is required; the audit will not guess an active year.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$checks = [];
|
||||||
|
$policy = $db->table('school_years')->where('name', $year)->get(1)->getRowArray();
|
||||||
|
$configWeeksRow = $db->table('configuration')
|
||||||
|
->select('config_value')
|
||||||
|
->where('config_key', 'total_instructional_weeks')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
$configWeeks = filter_var($configWeeksRow['config_value'] ?? null, FILTER_VALIDATE_INT);
|
||||||
|
$checks['policy'] = $policy !== null
|
||||||
|
&& $configWeeks !== false
|
||||||
|
&& $configWeeks > 0
|
||||||
|
&& (int) ($policy['annual_fee_includes_books'] ?? 0) === 1
|
||||||
|
? $this->check('pass', 'School-year refund inputs are present.')
|
||||||
|
: $this->check('blocking', 'Configuration total_instructional_weeks or book-inclusive policy is missing.');
|
||||||
|
|
||||||
|
$missingPrices = $db->table('inventory_item_years iy')
|
||||||
|
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
|
||||||
|
->where('iy.school_year', $year)->where('i.type', 'book')
|
||||||
|
->groupStart()->where('iy.charge_price_cents <=', 0)->orWhere('iy.price_confirmed !=', 1)->groupEnd()
|
||||||
|
->countAllResults();
|
||||||
|
$checks['book_prices'] = $missingPrices === 0 ? $this->check('pass', 'All book-year prices are confirmed.') : $this->check('blocking', $missingPrices . ' book-year price(s) are missing or unconfirmed.');
|
||||||
|
|
||||||
|
$catalogRows = $db->table('inventory_items')->select('id, name, isbn, edition, sku, author')->where('type', 'book')->get()->getResultArray();
|
||||||
|
$identityCounts = [];
|
||||||
|
$skuCounts = [];
|
||||||
|
foreach ($catalogRows as $catalogRow) {
|
||||||
|
$isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', (string) ($catalogRow['isbn'] ?? '')));
|
||||||
|
$edition = strtolower(trim((string) ($catalogRow['edition'] ?? '')));
|
||||||
|
$sku = strtolower(trim((string) ($catalogRow['sku'] ?? '')));
|
||||||
|
if ($isbn !== '') {
|
||||||
|
$identityCounts[$isbn . '|' . $edition] = ($identityCounts[$isbn . '|' . $edition] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
if ($sku !== '') {
|
||||||
|
$skuCounts[$sku] = ($skuCounts[$sku] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$duplicateCount = count(array_filter($identityCounts, static fn (int $count): bool => $count > 1))
|
||||||
|
+ count(array_filter($skuCounts, static fn (int $count): bool => $count > 1));
|
||||||
|
$checks['duplicate_catalog'] = $duplicateCount === 0 ? $this->check('pass', 'No duplicate normalized ISBN/edition or SKU keys found.') : $this->check('blocking', $duplicateCount . ' duplicate normalized ISBN/edition or SKU group(s) require reconciliation.');
|
||||||
|
|
||||||
|
$missingAuthorCount = count(array_filter($catalogRows, static fn (array $row): bool => trim((string) ($row['author'] ?? '')) === ''));
|
||||||
|
$checks['catalog_author'] = $missingAuthorCount === 0
|
||||||
|
? $this->check('pass', 'All book catalog rows have an author.')
|
||||||
|
: $this->check('warning', $missingAuthorCount . ' book catalog row(s) have no author.');
|
||||||
|
$unassignedBooks = $db->table('inventory_items i')
|
||||||
|
->join('inventory_book_class_assignments a', 'a.inventory_item_id = i.id AND a.school_year = ' . $db->escape($year), 'left')
|
||||||
|
->where('i.type', 'book')->where('i.is_active', 1)->where('a.id', null)->countAllResults();
|
||||||
|
$checks['book_class_assignments'] = $unassignedBooks === 0
|
||||||
|
? $this->check('pass', 'All active books have explicit year/class assignments.')
|
||||||
|
: $this->check('warning', $unassignedBooks . ' active book(s) rely on category fallback instead of explicit class assignments.');
|
||||||
|
|
||||||
|
$malformedYearTags = $db->table('inventory_movements')->where('school_year IS NOT NULL', null, false)
|
||||||
|
->where("school_year NOT REGEXP '^[0-9]{4}-[0-9]{4}$'", null, false)->countAllResults();
|
||||||
|
$checks['movement_year_tags'] = $malformedYearTags === 0
|
||||||
|
? $this->check('pass', 'Inventory movement year tags are well formed.')
|
||||||
|
: $this->check('warning', $malformedYearTags . ' inventory movement(s) have malformed school-year tags.');
|
||||||
|
|
||||||
|
$legacyDistributions = $db->table('inventory_movements im')
|
||||||
|
->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left')
|
||||||
|
->where('im.school_year', $year)->where('im.movement_type', 'distribution')->where('sbi.id', null)
|
||||||
|
->countAllResults();
|
||||||
|
$checks['legacy_distributions'] = $legacyDistributions === 0 ? $this->check('pass', 'Every distribution has a price-snapshotted issue.') : $this->check('blocking', $legacyDistributions . ' distribution movement(s) lack student issue evidence and cannot be priced automatically.');
|
||||||
|
|
||||||
|
$positiveStudentMovements = $db->table('inventory_movements')
|
||||||
|
->where('school_year', $year)->where('student_id IS NOT NULL', null, false)->where('qty_change >', 0)
|
||||||
|
->groupStart()->where('source_type !=', 'student_book_issue_reversal')->orWhere('source_type', null)->groupEnd()->countAllResults();
|
||||||
|
$checks['possible_returns'] = $positiveStudentMovements === 0 ? $this->check('pass', 'No unclassified positive student book movements found.') : $this->check('blocking', $positiveStudentMovements . ' positive student movement(s) look like legacy returns/corrections and require manual classification.');
|
||||||
|
|
||||||
|
$duplicateOpenings = $db->table('inventory_movements')
|
||||||
|
->select('item_id')->where('school_year', $year)->where('movement_type', 'initial')->groupBy('item_id')->having('COUNT(*) > 1', null, false)->countAllResults();
|
||||||
|
$checks['duplicate_openings'] = $duplicateOpenings === 0 ? $this->check('pass', 'No duplicate legacy opening movements found.') : $this->check('blocking', $duplicateOpenings . ' book/item(s) have duplicate opening movements.');
|
||||||
|
|
||||||
|
$quantityMismatches = $db->query(
|
||||||
|
"SELECT COUNT(*) AS total FROM (
|
||||||
|
SELECT i.id
|
||||||
|
FROM inventory_items i
|
||||||
|
JOIN inventory_item_years iy ON iy.inventory_item_id = i.id AND iy.school_year = ?
|
||||||
|
LEFT JOIN inventory_movements im ON im.item_year_id = iy.id AND im.status IN ('posted','reversed')
|
||||||
|
WHERE i.type = 'book'
|
||||||
|
GROUP BY i.id, i.quantity, iy.opening_quantity
|
||||||
|
HAVING i.quantity != iy.opening_quantity + COALESCE(SUM(im.qty_change), 0)
|
||||||
|
) mismatches",
|
||||||
|
[$year]
|
||||||
|
)->getRowArray();
|
||||||
|
$quantityMismatchCount = (int) ($quantityMismatches['total'] ?? 0);
|
||||||
|
$checks['quantity_projection'] = $quantityMismatchCount === 0
|
||||||
|
? $this->check('pass', 'Book catalog quantities match the year movement ledger.')
|
||||||
|
: $this->check('blocking', $quantityMismatchCount . ' book quantity projection(s) disagree with the year movement ledger.');
|
||||||
|
|
||||||
|
$legacyRefunds = $db->table('refunds')->where('school_year', $year)
|
||||||
|
->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid'])
|
||||||
|
->groupStart()->where('withdrawal_calculation_id', null)->orWhere('withdrawal_calculation_id', 0)->groupEnd()
|
||||||
|
->groupStart()->where('source_type', 'tuition_withdrawal')->orLike('reason', 'Withdrawal')->groupEnd()->countAllResults();
|
||||||
|
$checks['legacy_refunds'] = $legacyRefunds === 0 ? $this->check('pass', 'Open withdrawal refunds have calculation links.') : $this->check('blocking', $legacyRefunds . ' open withdrawal refund(s) have no versioned calculation.');
|
||||||
|
|
||||||
|
$comparisonRows = $db->query(
|
||||||
|
"SELECT r.id AS refund_id, r.invoice_id, r.refund_amount,
|
||||||
|
r.requested_amount_cents AS stored_requested_cents,
|
||||||
|
wfc.id AS calculation_id, wfc.new_refund_request_cents AS calculated_requested_cents,
|
||||||
|
wfc.status AS calculation_status
|
||||||
|
FROM refunds r
|
||||||
|
LEFT JOIN withdrawal_financial_calculations wfc ON wfc.id = r.withdrawal_calculation_id
|
||||||
|
WHERE r.school_year = ?
|
||||||
|
AND (r.source_type = 'tuition_withdrawal' OR r.reason LIKE '%Withdrawal%')
|
||||||
|
AND LOWER(COALESCE(r.status,'')) IN ('pending','requested','approved','partial','partially_paid')
|
||||||
|
ORDER BY r.id",
|
||||||
|
[$year]
|
||||||
|
)->getResultArray();
|
||||||
|
$comparisonDifferences = 0;
|
||||||
|
foreach ($comparisonRows as &$comparison) {
|
||||||
|
$storedCents = (int) ($comparison['stored_requested_cents'] ?? 0);
|
||||||
|
if ($storedCents === 0) {
|
||||||
|
$storedCents = (int) round(((float) ($comparison['refund_amount'] ?? 0)) * 100, 0, PHP_ROUND_HALF_UP);
|
||||||
|
}
|
||||||
|
$comparison['stored_requested_cents'] = $storedCents;
|
||||||
|
$comparison['difference_cents'] = $comparison['calculation_id'] === null
|
||||||
|
? null
|
||||||
|
: $storedCents - (int) ($comparison['calculated_requested_cents'] ?? 0);
|
||||||
|
if ($comparison['difference_cents'] !== null && $comparison['difference_cents'] !== 0) {
|
||||||
|
$comparisonDifferences++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($comparison);
|
||||||
|
$checks['refund_comparison'] = $comparisonDifferences === 0
|
||||||
|
? $this->check('pass', 'Linked open refund requests match their versioned calculations.')
|
||||||
|
: $this->check('blocking', $comparisonDifferences . ' linked open refund request(s) differ from their calculation snapshot.');
|
||||||
|
|
||||||
|
$duplicateInvoices = $db->query(
|
||||||
|
"SELECT COUNT(*) AS total FROM (
|
||||||
|
SELECT parent_id FROM invoices WHERE school_year = ?
|
||||||
|
AND LOWER(COALESCE(status,'')) NOT IN ('void','voided','cancelled','canceled')
|
||||||
|
GROUP BY parent_id HAVING COUNT(*) > 1
|
||||||
|
) duplicates",
|
||||||
|
[$year]
|
||||||
|
)->getRowArray();
|
||||||
|
$duplicateInvoiceCount = (int) ($duplicateInvoices['total'] ?? 0);
|
||||||
|
$checks['duplicate_invoices'] = $duplicateInvoiceCount === 0 ? $this->check('pass', 'No parent has multiple active invoices.') : $this->check('blocking', $duplicateInvoiceCount . ' parent(s) have multiple active invoices and require review.');
|
||||||
|
|
||||||
|
$blocking = count(array_filter($checks, static fn (array $check): bool => $check['severity'] === 'blocking'));
|
||||||
|
$report = ['school_year' => $year, 'generated_at' => date('Y-m-d H:i:s'), 'ready_to_enable' => $blocking === 0, 'blocking_count' => $blocking, 'checks' => $checks, 'open_refund_comparison' => $comparisonRows];
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CLI::write('Withdrawal and inventory audit for ' . $year, 'yellow');
|
||||||
|
foreach ($checks as $name => $check) {
|
||||||
|
$color = $check['severity'] === 'blocking' ? 'red' : ($check['severity'] === 'warning' ? 'yellow' : 'green');
|
||||||
|
CLI::write(strtoupper($check['severity']) . ' ' . $name . ': ' . $check['message'], $color);
|
||||||
|
}
|
||||||
|
CLI::write($blocking === 0 ? 'READY TO ENABLE' : 'DO NOT ENABLE — blockers remain', $blocking === 0 ? 'green' : 'red');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function check(string $severity, string $message): array
|
||||||
|
{
|
||||||
|
return ['severity' => $severity, 'message' => $message];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,4 +215,16 @@ class App extends BaseConfig
|
|||||||
// Sessions (also in App.php)
|
// Sessions (also in App.php)
|
||||||
public string $sessionCookieName = '__Host-ci_session';
|
public string $sessionCookieName = '__Host-ci_session';
|
||||||
public bool $sessionRegenerateDestroy = true;
|
public bool $sessionRegenerateDestroy = true;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$isProduction = ENVIRONMENT === 'production';
|
||||||
|
$this->forceGlobalSecureRequests = $isProduction;
|
||||||
|
$this->cookieSecure = $isProduction;
|
||||||
|
if (! $isProduction) {
|
||||||
|
$this->cookiePrefix = '';
|
||||||
|
$this->sessionCookieName = 'ci_session';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,6 @@ class Autoload extends AutoloadConfig
|
|||||||
*
|
*
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api'];
|
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api', 'global_config', 'student_status'];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,4 +168,12 @@ class Cache extends BaseConfig
|
|||||||
'redis' => RedisHandler::class,
|
'redis' => RedisHandler::class,
|
||||||
'wincache' => WincacheHandler::class,
|
'wincache' => WincacheHandler::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (ENVIRONMENT === 'testing') {
|
||||||
|
$this->storePath = WRITEPATH . 'cache/testing/';
|
||||||
|
$this->file['storePath'] = WRITEPATH . 'cache/testing/';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Commands extends BaseService
|
|||||||
\App\Commands\SendAbsenteesSummary::class,
|
\App\Commands\SendAbsenteesSummary::class,
|
||||||
\App\Commands\SendLatesSummary::class,
|
\App\Commands\SendLatesSummary::class,
|
||||||
\App\Commands\SendMonthlyPaymentNotifications::class,
|
\App\Commands\SendMonthlyPaymentNotifications::class,
|
||||||
|
\App\Commands\SendRegistrationOpeningEmail::class,
|
||||||
\App\Commands\SendTestPaymentNotification::class,
|
\App\Commands\SendTestPaymentNotification::class,
|
||||||
\App\Commands\RecalculateAttendance::class,
|
\App\Commands\RecalculateAttendance::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
|
|||||||
*/
|
*/
|
||||||
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
|
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
|
||||||
|
|
||||||
|
/*
|
||||||
|
| --------------------------------------------------------------------------
|
||||||
|
| Test Support Path
|
||||||
|
| --------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Spark may be run with CI_ENVIRONMENT=testing before CodeIgniter's PHPUnit
|
||||||
|
| bootstrap has had a chance to define SUPPORTPATH. The framework autoloader
|
||||||
|
| expects this constant when that environment flag is present.
|
||||||
|
*/
|
||||||
|
if (! defined('SUPPORTPATH') && defined('TESTPATH') && is_dir(TESTPATH . '_support')) {
|
||||||
|
define('SUPPORTPATH', realpath(TESTPATH . '_support') . DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Timing Constants
|
| Timing Constants
|
||||||
|
|||||||
@@ -47,28 +47,63 @@ class ContentSecurityPolicy extends BaseConfig
|
|||||||
*
|
*
|
||||||
* @var list<string>|string|null
|
* @var list<string>|string|null
|
||||||
*/
|
*/
|
||||||
public $defaultSrc;
|
public $defaultSrc = 'self';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists allowed scripts' URLs.
|
* Lists allowed scripts' URLs.
|
||||||
*
|
*
|
||||||
* @var list<string>|string
|
* @var list<string>|string
|
||||||
*/
|
*/
|
||||||
public $scriptSrc = 'self';
|
public $scriptSrc = [
|
||||||
|
'self',
|
||||||
|
'unsafe-inline',
|
||||||
|
'https://cdn.jsdelivr.net',
|
||||||
|
'https://code.jquery.com',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists allowed stylesheets' URLs.
|
* Lists allowed stylesheets' URLs.
|
||||||
*
|
*
|
||||||
* @var list<string>|string
|
* @var list<string>|string
|
||||||
*/
|
*/
|
||||||
public $styleSrc = 'self';
|
public $styleSrc = [
|
||||||
|
'self',
|
||||||
|
'unsafe-inline',
|
||||||
|
'https://cdn.jsdelivr.net',
|
||||||
|
'https://fonts.googleapis.com',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists allowed stylesheet element URLs.
|
||||||
|
*
|
||||||
|
* @var list<string>|string
|
||||||
|
*/
|
||||||
|
public $styleSrcElem = [
|
||||||
|
'self',
|
||||||
|
'unsafe-inline',
|
||||||
|
'https://cdn.jsdelivr.net',
|
||||||
|
'https://fonts.googleapis.com',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows inline style attributes used by legacy views.
|
||||||
|
*
|
||||||
|
* @var list<string>|string
|
||||||
|
*/
|
||||||
|
public $styleSrcAttr = [
|
||||||
|
'self',
|
||||||
|
'unsafe-inline',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the origins from which images can be loaded.
|
* Defines the origins from which images can be loaded.
|
||||||
*
|
*
|
||||||
* @var list<string>|string
|
* @var list<string>|string
|
||||||
*/
|
*/
|
||||||
public $imageSrc = 'self';
|
public $imageSrc = ['self', 'data:', 'https:'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restricts the URLs that can appear in a page's `<base>` element.
|
* Restricts the URLs that can appear in a page's `<base>` element.
|
||||||
@@ -99,7 +134,13 @@ class ContentSecurityPolicy extends BaseConfig
|
|||||||
*
|
*
|
||||||
* @var list<string>|string
|
* @var list<string>|string
|
||||||
*/
|
*/
|
||||||
public $fontSrc;
|
public $fontSrc = [
|
||||||
|
'self',
|
||||||
|
'data:',
|
||||||
|
'https://fonts.gstatic.com',
|
||||||
|
'https://cdn.jsdelivr.net',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists valid endpoints for submission from `<form>` tags.
|
* Lists valid endpoints for submission from `<form>` tags.
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ class Cookie extends BaseConfig
|
|||||||
*/
|
*/
|
||||||
public bool $secure = false;
|
public bool $secure = false;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->secure = ENVIRONMENT === 'production';
|
||||||
|
if ($this->secure) {
|
||||||
|
$this->prefix = '__Host-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
* Cookie HTTPOnly
|
* Cookie HTTPOnly
|
||||||
|
|||||||
+2
-2
@@ -34,7 +34,7 @@ class Cors extends BaseConfig
|
|||||||
* - ['http://localhost:8080']
|
* - ['http://localhost:8080']
|
||||||
* - ['https://www.example.com']
|
* - ['https://www.example.com']
|
||||||
*/
|
*/
|
||||||
'allowedOrigins' => ['*'], // Allow all origins for mobile apps
|
'allowedOrigins' => ['*'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
||||||
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
|
|||||||
*
|
*
|
||||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
||||||
*/
|
*/
|
||||||
'supportsCredentials' => true, // Enable for mobile apps using cookies/auth
|
'supportsCredentials' => false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set headers to allow.
|
* Set headers to allow.
|
||||||
|
|||||||
+13
-9
@@ -16,6 +16,10 @@ class Database extends Config
|
|||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
|
||||||
|
if (ENVIRONMENT === 'testing') {
|
||||||
|
$this->defaultGroup = 'tests';
|
||||||
|
}
|
||||||
|
|
||||||
$this->default = [
|
$this->default = [
|
||||||
'DSN' => '',
|
'DSN' => '',
|
||||||
'hostname' => env('database.default.hostname'),
|
'hostname' => env('database.default.hostname'),
|
||||||
@@ -25,7 +29,7 @@ class Database extends Config
|
|||||||
'DBDriver' => env('database.default.DBDriver', 'MySQLi'),
|
'DBDriver' => env('database.default.DBDriver', 'MySQLi'),
|
||||||
'DBPrefix' => '',
|
'DBPrefix' => '',
|
||||||
'pConnect' => false,
|
'pConnect' => false,
|
||||||
'DBDebug' => (ENVIRONMENT !== 'development'),
|
'DBDebug' => (ENVIRONMENT === 'development'),
|
||||||
'charset' => 'utf8',
|
'charset' => 'utf8',
|
||||||
'DBCollat' => 'utf8_general_ci',
|
'DBCollat' => 'utf8_general_ci',
|
||||||
'swapPre' => '',
|
'swapPre' => '',
|
||||||
@@ -38,22 +42,22 @@ class Database extends Config
|
|||||||
|
|
||||||
$this->tests = [
|
$this->tests = [
|
||||||
'DSN' => '',
|
'DSN' => '',
|
||||||
'hostname' => env('database.tests.hostname', env('database.default.hostname')),
|
'hostname' => env('TEST_DB_HOST', env('database.tests.hostname', env('database.default.hostname'))),
|
||||||
'username' => env('database.tests.username', env('database.default.username')),
|
'username' => env('TEST_DB_USER', env('database.tests.username', env('database.default.username'))),
|
||||||
'password' => env('database.tests.password', env('database.default.password')),
|
'password' => env('TEST_DB_PASSWORD', env('database.tests.password', env('database.default.password'))),
|
||||||
'database' => env('database.tests.database', env('database.default.database')),
|
'database' => env('TEST_DB_NAME', env('database.tests.database', 'alrahma_test')),
|
||||||
'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')),
|
'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')),
|
||||||
'DBPrefix' => env('database.tests.DBPrefix', 'db_'),
|
'DBPrefix' => env('database.tests.DBPrefix', ''),
|
||||||
'pConnect' => false,
|
'pConnect' => false,
|
||||||
'DBDebug' => true,
|
'DBDebug' => true,
|
||||||
'charset' => 'utf8',
|
'charset' => 'utf8mb4',
|
||||||
'DBCollat' => 'utf8_general_ci',
|
'DBCollat' => 'utf8mb4_general_ci',
|
||||||
'swapPre' => '',
|
'swapPre' => '',
|
||||||
'encrypt' => false,
|
'encrypt' => false,
|
||||||
'compress' => false,
|
'compress' => false,
|
||||||
'strictOn' => false,
|
'strictOn' => false,
|
||||||
'failover' => [],
|
'failover' => [],
|
||||||
'port' => (int) env('database.tests.port', env('database.default.port', 3306)),
|
'port' => (int) env('TEST_DB_PORT', env('database.tests.port', env('database.default.port', 3306))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-9
@@ -6,16 +6,16 @@ use CodeIgniter\Config\BaseConfig;
|
|||||||
|
|
||||||
class Email extends BaseConfig
|
class Email extends BaseConfig
|
||||||
{
|
{
|
||||||
public string $protocol = 'smtp';
|
public string $protocol;
|
||||||
public string $SMTPHost = 'smtp.gmail.com';
|
public string $SMTPHost;
|
||||||
public string $SMTPUser = 'alrahma.sunday.school@gmail.com';
|
public string $SMTPUser;
|
||||||
public string $SMTPPass = 'psnp emdq dykw ypul'; // Consider using ENV()
|
public string $SMTPPass;
|
||||||
public int $SMTPPort = 465;
|
public int $SMTPPort;
|
||||||
public string $SMTPCrypto = 'ssl'; // ✅ Correct for port 465
|
public string $SMTPCrypto;
|
||||||
|
|
||||||
public bool $SMTPAuth = true;
|
public bool $SMTPAuth;
|
||||||
public int $SMTPTimeout = 5;
|
public int $SMTPTimeout;
|
||||||
public bool $SMTPKeepAlive = true;
|
public bool $SMTPKeepAlive;
|
||||||
|
|
||||||
public string $charset = 'UTF-8';
|
public string $charset = 'UTF-8';
|
||||||
public string $mailType = 'html';
|
public string $mailType = 'html';
|
||||||
@@ -23,4 +23,19 @@ class Email extends BaseConfig
|
|||||||
|
|
||||||
public string $newline = "\r\n";
|
public string $newline = "\r\n";
|
||||||
public string $CRLF = "\r\n";
|
public string $CRLF = "\r\n";
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$this->protocol = (string) env('mail.protocol', env('email.protocol', 'smtp'));
|
||||||
|
$this->SMTPHost = (string) env('mail.SMTPHost', env('email.SMTPHost', env('SMTP_HOST', 'smtp.gmail.com')));
|
||||||
|
$this->SMTPUser = (string) env('mail.SMTPUser', env('email.SMTPUser', env('SMTP_USER', '')));
|
||||||
|
$this->SMTPPass = (string) env('mail.SMTPPass', env('email.SMTPPass', env('SMTP_PASS', '')));
|
||||||
|
$this->SMTPPort = (int) env('mail.SMTPPort', env('email.SMTPPort', env('SMTP_PORT', 465)));
|
||||||
|
$this->SMTPCrypto = (string) env('mail.SMTPCrypto', env('email.SMTPCrypto', env('SMTP_ENCRYPTION', 'ssl')));
|
||||||
|
$this->SMTPAuth = filter_var(env('mail.SMTPAuth', env('email.SMTPAuth', true)), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$this->SMTPTimeout = (int) env('mail.SMTPTimeout', env('email.SMTPTimeout', 5));
|
||||||
|
$this->SMTPKeepAlive = filter_var(env('mail.SMTPKeepAlive', env('email.SMTPKeepAlive', true)), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,15 @@ class Encryption extends BaseConfig
|
|||||||
*/
|
*/
|
||||||
public string $key = '';
|
public string $key = '';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$fromEnv = (string) env('encryption.key', env('ENCRYPTION_KEY', ''));
|
||||||
|
if ($fromEnv !== '') {
|
||||||
|
$this->key = $fromEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
* Encryption Driver to Use
|
* Encryption Driver to Use
|
||||||
|
|||||||
@@ -27,4 +27,9 @@ class Feature extends BaseConfig
|
|||||||
* Use improved new auto routing instead of the default legacy version.
|
* Use improved new auto routing instead of the default legacy version.
|
||||||
*/
|
*/
|
||||||
public bool $autoRoutesImproved = false;
|
public bool $autoRoutesImproved = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the global school-year selector in authenticated admin layouts.
|
||||||
|
*/
|
||||||
|
public bool $globalSchoolYearSelector = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-5
@@ -31,6 +31,8 @@ class Filters extends BaseConfig
|
|||||||
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
|
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
|
||||||
'permission' => \App\Filters\PermissionFilter::class,
|
'permission' => \App\Filters\PermissionFilter::class,
|
||||||
'timezone' => \App\Filters\TimezoneFilter::class,
|
'timezone' => \App\Filters\TimezoneFilter::class,
|
||||||
|
'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
|
||||||
|
'schoolYearWritable'=> \App\Filters\SchoolYearWritableFilter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@@ -43,14 +45,31 @@ class Filters extends BaseConfig
|
|||||||
*/
|
*/
|
||||||
public array $globals = [
|
public array $globals = [
|
||||||
'before' => [
|
'before' => [
|
||||||
'timezone',
|
// Heartbeat must not load timezone preferences / settings.
|
||||||
|
'timezone' => ['except' => [
|
||||||
|
'session/ping-activity',
|
||||||
|
'index.php/session/ping-activity',
|
||||||
|
'session/ping',
|
||||||
|
'index.php/session/ping',
|
||||||
|
'session/check-timeout',
|
||||||
|
'index.php/session/check-timeout',
|
||||||
|
'session/get-timeout-config',
|
||||||
|
'index.php/session/get-timeout-config',
|
||||||
|
]],
|
||||||
'sanitizeinput',
|
'sanitizeinput',
|
||||||
'invalidchars',
|
'invalidchars',
|
||||||
|
// Heartbeat is not a school-year write; skip writable-year DB work.
|
||||||
|
'schoolYearWritable' => ['except' => [
|
||||||
|
'session/ping-activity',
|
||||||
|
'index.php/session/ping-activity',
|
||||||
|
'session/ping',
|
||||||
|
'index.php/session/ping',
|
||||||
|
'session/check-timeout',
|
||||||
|
'index.php/session/check-timeout',
|
||||||
|
'session/get-timeout-config',
|
||||||
|
'index.php/session/get-timeout-config',
|
||||||
|
]],
|
||||||
'csrf' => ['except' => [
|
'csrf' => ['except' => [
|
||||||
// WhatsApp membership management (legacy allowances retained)
|
|
||||||
'whatsapp/update-membership',
|
|
||||||
'index.php/whatsapp/update-membership',
|
|
||||||
|
|
||||||
// Attendance management AJAX saves
|
// Attendance management AJAX saves
|
||||||
'attendance/update',
|
'attendance/update',
|
||||||
'index.php/attendance/update',
|
'index.php/attendance/update',
|
||||||
@@ -77,6 +96,12 @@ class Filters extends BaseConfig
|
|||||||
// Badge PDF generation posts (handled by auth; allow multiple submits without reload)
|
// Badge PDF generation posts (handled by auth; allow multiple submits without reload)
|
||||||
'badge',
|
'badge',
|
||||||
'index.php/badge',
|
'index.php/badge',
|
||||||
|
|
||||||
|
// Session keep-alive endpoint updates only the current browser session.
|
||||||
|
'session/ping',
|
||||||
|
'index.php/session/ping',
|
||||||
|
'session/ping-activity',
|
||||||
|
'index.php/session/ping-activity',
|
||||||
]],
|
]],
|
||||||
],
|
],
|
||||||
'after' => [
|
'after' => [
|
||||||
|
|||||||
+265
-232
@@ -47,6 +47,8 @@ $routes->setAutoRoute(false);
|
|||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
$routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']);
|
$routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']);
|
||||||
|
$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', ['filter' => 'auth']);
|
||||||
|
$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', ['filter' => 'auth']);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
@@ -105,6 +107,28 @@ $routes->post('student/score-card', 'View\StudentController::scoreCard');
|
|||||||
$routes->get('student/score-card', 'View\StudentController::scoreCardIndex');
|
$routes->get('student/score-card', 'View\StudentController::scoreCardIndex');
|
||||||
$routes->get('student/score-card/list', 'View\StudentController::scoreCardList');
|
$routes->get('student/score-card/list', 'View\StudentController::scoreCardList');
|
||||||
$routes->get('administrator/student-score-card', 'View\StudentController::scoreCardAdmin');
|
$routes->get('administrator/student-score-card', 'View\StudentController::scoreCardAdmin');
|
||||||
|
$enrollmentAdminFilter = 'auth:admin|administrator|principal|vice_principal|vice principal';
|
||||||
|
$routes->get('administrator/enrollment-admin', 'View\EnrollmentAdminController::dashboard', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->get('administrator/enrollment-admin/email-preview', 'View\EnrollmentAdminController::previewEmail', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/approve-launch', 'View\EnrollmentAdminController::approveLaunch', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/send-registration-emails', 'View\EnrollmentAdminController::sendRegistrationEmails', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\EnrollmentAdminController::resolveFlag/$1', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/exceptions/create', 'View\EnrollmentAdminController::createException', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => $enrollmentAdminFilter]);
|
||||||
|
$routes->get('administrator/financial-aid', 'Administrator\FinancialAidController::index', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||||
|
$routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||||
|
$routes->post('administrator/financial-aid/(:num)/estimate', 'Administrator\FinancialAidController::estimate/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||||
|
$routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||||
|
$routes->post('administrator/financial-aid/(:num)/deny', 'Administrator\FinancialAidController::deny/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||||
|
$withdrawalFinancialFilter = 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal';
|
||||||
|
$routes->get('administrator/withdrawals/(:num)/review', 'Administrator\WithdrawalFinancialController::review/$1', ['filter' => $withdrawalFinancialFilter]);
|
||||||
|
$routes->post('administrator/withdrawals/(:num)/recalculate', 'Administrator\WithdrawalFinancialController::recalculate/$1', ['filter' => $withdrawalFinancialFilter . ',update']);
|
||||||
|
$routes->post('administrator/withdrawal-calculations/(:num)/confirm', 'Administrator\WithdrawalFinancialController::confirm/$1', ['filter' => $withdrawalFinancialFilter . ',update']);
|
||||||
|
$routes->get('administrator/withdrawal-calculations/(:num)', 'Administrator\WithdrawalFinancialController::calculation/$1', ['filter' => $withdrawalFinancialFilter]);
|
||||||
|
$routes->get('administrator/invoices/(:num)/withdrawal-calculations', 'Administrator\WithdrawalFinancialController::invoiceSummary/$1', ['filter' => $withdrawalFinancialFilter]);
|
||||||
// API for report card meta (students, class sections, school years)
|
// API for report card meta (students, class sections, school years)
|
||||||
$routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']);
|
$routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']);
|
||||||
$routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']);
|
$routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']);
|
||||||
@@ -174,15 +198,15 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
|
|||||||
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
|
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
|
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']);
|
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
// Certificates
|
// Certificates
|
||||||
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
|
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin']);
|
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
|
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']);
|
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
|
$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
|
||||||
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
||||||
|
|
||||||
@@ -198,14 +222,39 @@ $routes->post('/user/store', 'View\UserController::store');
|
|||||||
$routes->get('/thankyou', 'View\UserController::thankyou'); // Thank you page route
|
$routes->get('/thankyou', 'View\UserController::thankyou'); // Thank you page route
|
||||||
$routes->get('/', 'View\UserController::home'); // Home page route
|
$routes->get('/', 'View\UserController::home'); // Home page route
|
||||||
$routes->get('/about', 'View\UserController::about'); // About page route
|
$routes->get('/about', 'View\UserController::about'); // About page route
|
||||||
|
$routes->get('/careers', 'View\JobPostingController::publicIndex'); // Careers page route
|
||||||
|
$routes->get('/careers/application-received', 'View\JobPostingController::applicationReceived');
|
||||||
|
$routes->get('/careers/(:segment)/details', 'View\JobPostingController::trackDetailsClick/$1');
|
||||||
|
$routes->get('/careers/(:segment)', 'View\JobPostingController::show/$1');
|
||||||
|
$routes->get('/careers/(:segment)/apply', 'View\JobPostingController::apply/$1');
|
||||||
|
$routes->post('/careers/(:segment)/apply', 'View\JobPostingController::submitApplication/$1');
|
||||||
|
$routes->group('administrator/job-postings', ['filter' => 'auth:administrator|administrative staff|principal'], static function ($routes) {
|
||||||
|
$routes->get('', 'View\JobPostingController::positions');
|
||||||
|
$routes->get('templates', 'View\JobPostingController::templates');
|
||||||
|
$routes->get('templates/new', 'View\JobPostingController::newTemplate');
|
||||||
|
$routes->post('templates', 'View\JobPostingController::createTemplate');
|
||||||
|
$routes->get('templates/(:segment)/edit', 'View\JobPostingController::editTemplate/$1');
|
||||||
|
$routes->post('templates/(:segment)', 'View\JobPostingController::updateTemplate/$1');
|
||||||
|
$routes->post('templates/(:segment)/archive', 'View\JobPostingController::archiveTemplate/$1');
|
||||||
|
$routes->post('templates/versions/(:segment)/restore', 'View\JobPostingController::restoreTemplateVersion/$1');
|
||||||
|
$routes->get('positions', 'View\JobPostingController::positions');
|
||||||
|
$routes->get('positions/new', 'View\JobPostingController::newPosition');
|
||||||
|
$routes->post('positions', 'View\JobPostingController::createPosition');
|
||||||
|
$routes->get('positions/(:segment)/edit', 'View\JobPostingController::editPosition/$1');
|
||||||
|
$routes->post('positions/(:segment)', 'View\JobPostingController::updatePosition/$1');
|
||||||
|
$routes->get('applications', 'View\JobPostingController::applications');
|
||||||
|
$routes->post('applications/(:segment)', 'View\JobPostingController::updateApplication/$1');
|
||||||
|
$routes->get('applications/(:segment)/resume', 'View\JobPostingController::resume/$1');
|
||||||
|
});
|
||||||
$routes->get('/classes', 'View\UserController::classes'); // Classes page route
|
$routes->get('/classes', 'View\UserController::classes'); // Classes page route
|
||||||
$routes->get('/contact', 'View\UserController::contact'); // Contact Us page route
|
$routes->get('/contact', 'View\UserController::contact'); // Contact Us page route
|
||||||
$routes->post('/user/login', 'AuthController::login');
|
$routes->post('/user/login', 'AuthController::login');
|
||||||
$routes->post('api/login', 'AuthController::apiLogin');
|
$routes->post('api/login', 'AuthController::apiLogin');
|
||||||
|
$routes->get('admin/api/v1/admin/auth/me', 'AuthController::adminAuthMe');
|
||||||
$routes->get('/welcome_back', 'View\UserController::welcomeBack');
|
$routes->get('/welcome_back', 'View\UserController::welcomeBack');
|
||||||
|
|
||||||
$routes->get('user/forgot_password', 'View\UserController::forgotPassword'); //display forgot password form
|
$routes->get('user/forgot_password', 'View\UserController::forgotPassword'); //display forgot password form
|
||||||
//$routes->post('user/forgot_password', 'View\UserController::processForgotPassword'); //process the password forgot request
|
$routes->post('user/forgot_password', 'View\UserController::processForgotPassword'); //process the password forgot request
|
||||||
|
|
||||||
$routes->get('login', 'AuthController::loginMask');
|
$routes->get('login', 'AuthController::loginMask');
|
||||||
$routes->get(
|
$routes->get(
|
||||||
@@ -253,6 +302,8 @@ $routes->post('administrator/remove_class_student', 'View\StudentController::rem
|
|||||||
// Sections auto-distribution (admin)
|
// Sections auto-distribution (admin)
|
||||||
$routes->get('administrator/sections/auto-distribute', 'View\StudentController::autoDistributePage');
|
$routes->get('administrator/sections/auto-distribute', 'View\StudentController::autoDistributePage');
|
||||||
$routes->post('administrator/sections/auto-distribute', 'View\StudentController::autoDistributeSections');
|
$routes->post('administrator/sections/auto-distribute', 'View\StudentController::autoDistributeSections');
|
||||||
|
$routes->post('administrator/sections/distribution-draft/update', 'View\StudentController::updateDistributionDraft');
|
||||||
|
$routes->post('administrator/sections/distribution-candidate/update', 'View\StudentController::updateDistributionCandidate');
|
||||||
// Totals API for dashboard
|
// Totals API for dashboard
|
||||||
$routes->get('administrator/sections/promotion-totals', 'View\StudentController::promotionTotalsApi');
|
$routes->get('administrator/sections/promotion-totals', 'View\StudentController::promotionTotalsApi');
|
||||||
|
|
||||||
@@ -261,6 +312,7 @@ $routes->get('refunds/list', 'View\RefundController::listRefunds', ['filter' =>
|
|||||||
$routes->post('refunds/request', 'View\RefundController::requestRefund', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
$routes->post('refunds/request', 'View\RefundController::requestRefund', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||||
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('refunds/reversePayout/(:num)', 'View\RefundController::reversePayout/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
@@ -285,7 +337,9 @@ $routes->get('ui/style', 'View\UiController::style');
|
|||||||
|
|
||||||
|
|
||||||
//Timeout page after timeout
|
//Timeout page after timeout
|
||||||
|
$routes->get('session/get-timeout-config', 'View\SessionTimeoutController::getTimeoutConfig');
|
||||||
$routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout');
|
$routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout');
|
||||||
|
$routes->post('session/ping-activity', 'View\SessionTimeoutController::pingActivity');
|
||||||
$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity');
|
$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity');
|
||||||
|
|
||||||
|
|
||||||
@@ -393,7 +447,7 @@ $routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherSt
|
|||||||
|
|
||||||
$routes->get('teacher/print-requests', 'PrintRequests::teacher_index', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('teacher/print-requests', 'PrintRequests::teacher_index', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
|
|
||||||
$routes->get('admin/print-requests', 'PrintRequests::admin_index', ['filter' => 'auth:admin']);
|
$routes->get('admin/print-requests', 'PrintRequests::admin_index', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
$routes->post('print-requests/create', 'PrintRequests::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('print-requests/create', 'PrintRequests::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->post('print-requests/create-copy', 'PrintRequests::createCopy', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('print-requests/create-copy', 'PrintRequests::createCopy', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
@@ -412,10 +466,7 @@ $routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDr
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$routes->get('teacher/progress', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$routes->get('teacher/progress/submit', 'ClassProgressController::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('teacher/progress/submit', 'ClassProgressController::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->get('teacher/progress/history', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('teacher/progress/history', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
@@ -429,18 +480,18 @@ $routes->get('parent/progress/view/(:num)', 'ParentProgressController::view/$1',
|
|||||||
$routes->get('parent/progress/attachment/(:num)', 'ParentProgressController::attachment/$1', ['filter' => 'auth:parent']);
|
$routes->get('parent/progress/attachment/(:num)', 'ParentProgressController::attachment/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->get('parent/progress/attachment-file/(:num)', 'ParentProgressController::attachmentFile/$1', ['filter' => 'auth:parent']);
|
$routes->get('parent/progress/attachment-file/(:num)', 'ParentProgressController::attachmentFile/$1', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
$routes->get('/teacher/calendar', 'View\SchoolCalendarController::calendarTeacherView');
|
$routes->get('/teacher/calendar', 'View\SchoolCalendarController::calendarTeacherView');
|
||||||
$routes->get('/teacher/absence', 'View\TeacherController::absenceForm', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('/teacher/absence', 'View\TeacherController::absenceForm', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->post('/teacher/absence', 'View\TeacherController::submitAbsence', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('/teacher/absence', 'View\TeacherController::submitAbsence', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
|
|
||||||
// Admin self-service staff absence (same features as teacher page)
|
// Admin self-service staff absence (same features as teacher page)
|
||||||
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/timeoff/notify/(:segment)', 'TimeOffNotificationController::notify/$1');
|
$routes->get('/timeoff/notify/(:segment)', 'TimeOffNotificationController::notify/$1');
|
||||||
$routes->get('/teacher/showupdate_attendance', 'View\AttendanceController::showUpdateAttendanceForm');
|
$routes->get('/teacher/showupdate_attendance', 'View\AttendanceController::showUpdateAttendanceForm');
|
||||||
$routes->post('/teacher/update_attendance', 'View\AttendanceController::updateAttendance');
|
$routes->post('/teacher/update_attendance', 'View\AttendanceController::updateAttendance');
|
||||||
@@ -555,33 +606,30 @@ $routes->post('grading/release-scores', 'View\GradingController::toggleParentSco
|
|||||||
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
||||||
|
|
||||||
// Event admin routes
|
// Event admin routes
|
||||||
$routes->get('administrator/events', 'View\EventController::index');
|
$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/events/create', 'View\EventController::create'); // show create form
|
$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('administrator/events/create', 'View\EventController::create'); // handle event create submission
|
$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin|principal']);
|
||||||
// Route to show form and handle POST in same controller method
|
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1');
|
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['filter' => 'auth:admin|principal']);
|
||||||
|
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1'); // handle delete
|
|
||||||
$routes->post('payment/event_charges', 'View\EventController::eventUpdate');
|
|
||||||
|
|
||||||
// Parent event participation
|
// Parent event participation
|
||||||
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
|
$routes->get('administrator/event-charges', 'View\EventController::eventShow', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
|
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
|
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
|
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
|
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
|
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
|
|
||||||
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
|
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
|
||||||
// parent event participation page
|
// parent event participation page
|
||||||
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates
|
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
|
|
||||||
// Web View Routes for Invoices
|
// Web View Routes for Invoices
|
||||||
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1');
|
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1', ['filter' => 'auth:parent|view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->get('invoices/create', 'View\InvoiceController::create');
|
$routes->get('invoices/create', 'View\InvoiceController::create', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||||
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1');
|
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
// app/Config/Routes.php
|
// app/Config/Routes.php
|
||||||
|
|
||||||
@@ -603,24 +651,6 @@ $routes->group('payment', ['filter' => 'auth'], static function ($routes) {
|
|||||||
$routes->get('file/(:num)', 'View\PaymentController::servePaymentFile/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('file/(:num)', 'View\PaymentController::servePaymentFile/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Optional: if someone opens it in the browser via GET, redirect to the search page:
|
|
||||||
$routes->get('payment/manual_pay_update', function () {
|
|
||||||
return redirect()->to(site_url('payment/manual_pay'));
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
$routes->group('admin/charges', ['filter' => 'auth'], static function($routes) {
|
|
||||||
$routes->get('/', 'ExtraChargesController::index');
|
|
||||||
$routes->get('parents', 'ExtraChargesController::parentOptions'); // <-- for Select2
|
|
||||||
$routes->post('store', 'ExtraChargesController::store');
|
|
||||||
$routes->post('update/(:num)', 'ExtraChargesController::update/$1');
|
|
||||||
$routes->post('void/(:num)', 'ExtraChargesController::void/$1');
|
|
||||||
});
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
||||||
$routes->group('charges', static function ($routes) {
|
$routes->group('charges', static function ($routes) {
|
||||||
// Serve HTML page from ::page, keep ::index for API JSON
|
// Serve HTML page from ::page, keep ::index for API JSON
|
||||||
@@ -628,10 +658,12 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
|||||||
$routes->get('parents', [ExtraChargesController::class, 'parentOptions']);
|
$routes->get('parents', [ExtraChargesController::class, 'parentOptions']);
|
||||||
$routes->get('invoices', [ExtraChargesController::class, 'invoicesForParent']); // <-- AJAX endpoint
|
$routes->get('invoices', [ExtraChargesController::class, 'invoicesForParent']); // <-- AJAX endpoint
|
||||||
$routes->get('list', [ExtraChargesController::class, 'apiList']);
|
$routes->get('list', [ExtraChargesController::class, 'apiList']);
|
||||||
$routes->post('store', [ExtraChargesController::class, 'store']);
|
$routes->post('store', [ExtraChargesController::class, 'store'], ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||||
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1']);
|
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1']);
|
$routes->post('approve/(:num)', [ExtraChargesController::class, 'approve/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1']);
|
$routes->post('apply/(:num)', [ExtraChargesController::class, 'apply/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -639,8 +671,8 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
|||||||
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
||||||
$routes->get('broadcast-email', 'View\BroadcastEmailController::index');
|
$routes->get('broadcast-email', 'View\BroadcastEmailController::index');
|
||||||
$routes->post('broadcast-email/send', 'View\BroadcastEmailController::send');
|
$routes->post('broadcast-email/send', 'View\BroadcastEmailController::send');
|
||||||
|
$routes->post('broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
|
||||||
});
|
});
|
||||||
$routes->post('admin/broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
|
|
||||||
|
|
||||||
|
|
||||||
$routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
$routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
||||||
@@ -651,7 +683,7 @@ $routes->get('payment/download_csv', 'View\FinancialController::downloadCsv', ['
|
|||||||
$routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
$routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
||||||
$routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
$routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
||||||
$routes->get('administrator/tuition-forecast/export', 'View\TuitionForecastController::exportCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
$routes->get('administrator/tuition-forecast/export', 'View\TuitionForecastController::exportCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
||||||
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport');
|
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
|
||||||
// Financial APIs (JSON)
|
// Financial APIs (JSON)
|
||||||
$routes->get('api/financial/report', 'View\FinancialController::financialReportData', ['filter' => 'auth']);
|
$routes->get('api/financial/report', 'View\FinancialController::financialReportData', ['filter' => 'auth']);
|
||||||
$routes->get('api/financial/summary', 'View\FinancialController::financialReportSummaryData', ['filter' => 'auth']);
|
$routes->get('api/financial/summary', 'View\FinancialController::financialReportSummaryData', ['filter' => 'auth']);
|
||||||
@@ -680,24 +712,25 @@ $routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\Reim
|
|||||||
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->post('reimbursements/process', 'View\ReimbursementController::process', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('reimbursements/process', 'View\ReimbursementController::process', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('reimbursements/reverse/(:num)', 'View\ReimbursementController::reverse/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
$routes->get('reimbursements/export', 'View\ReimbursementController::export', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('reimbursements/export', 'View\ReimbursementController::export', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||||
|
|
||||||
// Health check (upload dirs + DB timezone columns)
|
// Health check (upload dirs + DB timezone columns)
|
||||||
$routes->get('admin/health', 'View\HealthController::index');
|
$routes->get('admin/health', 'View\HealthController::index', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
//Notifications
|
//Notifications
|
||||||
$routes->get('notifications/active', 'View\NotificationsController::listActive');
|
$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData');
|
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted');
|
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData');
|
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
|
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin|principal']);
|
||||||
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
|
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
|
||||||
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
|
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
|
||||||
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts');
|
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects');
|
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients');
|
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients');
|
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
|
|
||||||
$routes->get(
|
$routes->get(
|
||||||
@@ -730,21 +763,23 @@ $routes->post(
|
|||||||
'View\InvoiceController::generateInvoice',
|
'View\InvoiceController::generateInvoice',
|
||||||
['filter' => 'auth:create_invoice']
|
['filter' => 'auth:create_invoice']
|
||||||
);
|
);
|
||||||
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment'); // Route for invoice payment page
|
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment', ['filter' => 'auth:parent']); // Route for invoice payment page
|
||||||
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1'); // Route for paying the invoice
|
$routes->get('/parent/financial-aid', 'View\ParentFinancialAidController::index', ['filter' => 'auth:parent']);
|
||||||
$routes->get('invoices', 'View\InvoiceController::index'); // Default route for invoices
|
$routes->post('/parent/financial-aid', 'View\ParentFinancialAidController::submit', ['filter' => 'auth:parent']);
|
||||||
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1'); // Route with school year filter
|
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1', ['filter' => 'auth:view_invoice|view_payment|administrator|administrative staff|principal|parent']); // Route for paying the invoice
|
||||||
$routes->get('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
|
$routes->get('invoices', 'View\InvoiceController::index', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Default route for invoices
|
||||||
|
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Route with school year filter
|
||||||
|
$routes->post('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
|
||||||
$routes->get('invoice/pdf/(:num)', 'View\InvoiceController::generatePdfInvoice/$1', ['filter' => 'auth:invoice_pdf,read']);
|
$routes->get('invoice/pdf/(:num)', 'View\InvoiceController::generatePdfInvoice/$1', ['filter' => 'auth:invoice_pdf,read']);
|
||||||
|
|
||||||
|
|
||||||
// Web View Routes for Payments
|
// Web View Routes for Payments
|
||||||
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1');
|
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->get('payments/create', 'View\PaymentController::create');
|
$routes->get('payments/create', 'View\PaymentController::create', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1');
|
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1', ['filter' => 'auth:update_payment|update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
// Routes for payment pages
|
// Routes for payment pages
|
||||||
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1');
|
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
|
|
||||||
// Payment Notification Management
|
// Payment Notification Management
|
||||||
@@ -767,35 +802,34 @@ $routes->get('administrator/userSearch', 'View\AdministratorController::userSear
|
|||||||
|
|
||||||
$routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles');
|
$routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles');
|
||||||
$routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles');
|
$routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles');
|
||||||
$routes->get('/administrator/removed_students', 'View\StudentController::removedStudents');
|
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
|
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
|
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
|
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
* routes for View\MessagesController
|
* routes for View\MessagesController
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
$routes->get('messages', 'View\MessagesController::index');
|
$routes->get('messages', 'View\MessagesController::index', ['filter' => 'auth']);
|
||||||
$routes->post('messages/send', 'View\MessagesController::send');
|
$routes->post('messages/send', 'View\MessagesController::send', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/inbox', 'View\MessagesController::inbox');
|
$routes->get('/messages/inbox', 'View\MessagesController::inbox', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/sent', 'View\MessagesController::sent');
|
$routes->get('/messages/sent', 'View\MessagesController::sent', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/drafts', 'View\MessagesController::drafts');
|
$routes->get('/messages/drafts', 'View\MessagesController::drafts', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/trash', 'View\MessagesController::trash');
|
$routes->get('/messages/trash', 'View\MessagesController::trash', ['filter' => 'auth']);
|
||||||
|
|
||||||
// Additional routes for handling actions like viewing, sending, deleting, etc.
|
// Additional routes for handling actions like viewing, sending, deleting, etc.
|
||||||
$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1');
|
$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1', ['filter' => 'auth']);
|
||||||
$routes->post('/messages/send', 'View\MessagesController::send');
|
$routes->post('/messages/send', 'View\MessagesController::send', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1');
|
$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1');
|
$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1');
|
$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1', ['filter' => 'auth']);
|
||||||
$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1');
|
$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1', ['filter' => 'auth']);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
@@ -840,7 +874,7 @@ $routes->group('api/time', ['namespace' => 'App\Controllers\Api'], static functi
|
|||||||
* routes for View\SchoolCalendarController
|
* routes for View\SchoolCalendarController
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
$routes->group('administrator', ['namespace' => 'App\Controllers'], function ($routes) {
|
$routes->group('administrator', ['namespace' => 'App\Controllers', 'filter' => 'auth:admin|administrator|principal|vice_principal|vice principal'], function ($routes) {
|
||||||
$routes->get('calendar_view', 'View\SchoolCalendarController::index');
|
$routes->get('calendar_view', 'View\SchoolCalendarController::index');
|
||||||
$routes->get('calendar_edit/(:num)', 'View\SchoolCalendarController::edit/$1');
|
$routes->get('calendar_edit/(:num)', 'View\SchoolCalendarController::edit/$1');
|
||||||
$routes->get('calendar_delete/(:num)', 'View\SchoolCalendarController::delete/$1');
|
$routes->get('calendar_delete/(:num)', 'View\SchoolCalendarController::delete/$1');
|
||||||
@@ -857,64 +891,70 @@ $routes->group('administrator', ['namespace' => 'App\Controllers'], function ($r
|
|||||||
* routes for Parent Pages
|
* routes for Parent Pages
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent');
|
$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent');
|
$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent', ['filter' => 'auth:parent']);
|
||||||
$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent');
|
$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses');
|
$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses', ['filter' => 'auth:parent']);
|
||||||
$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler');
|
$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess');
|
$routes->get('/parent/enrollment_eligibility_refresh', 'View\ParentController::enrollmentEligibilityRefresh', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure');
|
$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/payment', 'View\ParentController::viewPayments');
|
$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView');
|
$routes->get('/parent/payment', 'View\ParentController::viewPayments', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/attendance', 'View\ParentController::attendance');
|
$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView', ['filter' => 'auth:parent']);
|
||||||
|
$routes->get('/parent/attendance', 'View\ParentController::attendance', ['filter' => 'auth:parent']);
|
||||||
// Parent reporting: absence/late/early dismissal
|
// Parent reporting: absence/late/early dismissal
|
||||||
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form');
|
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form', ['filter' => 'auth:parent']);
|
||||||
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit');
|
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['filter' => 'auth:parent']);
|
||||||
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update');
|
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update', ['filter' => 'auth:parent']);
|
||||||
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list');
|
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list', ['filter' => 'auth:admin|principal']);
|
||||||
// Admin/Teacher: early dismissal page by month
|
// Admin/Teacher: early dismissal page by month
|
||||||
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals');
|
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals', ['filter' => 'auth:admin|principal']);
|
||||||
// Admin/Teacher: add early dismissal
|
// Admin/Teacher: add early dismissal
|
||||||
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm');
|
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal');
|
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature');
|
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin|principal']);
|
||||||
// Parent report: client-side check API
|
// Parent report: client-side check API
|
||||||
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting');
|
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
|
|
||||||
$routes->get('/support', 'View\StatsController::support');
|
$routes->get('/support', 'View\StatsController::support');
|
||||||
|
|
||||||
$routes->get('no-kids', 'View\ParentController::noKids');
|
$routes->get('no-kids', 'View\ParentController::noKids', ['filter' => 'auth:parent']);
|
||||||
$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1');
|
$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal');
|
$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal', ['filter' => 'auth:parent']);
|
||||||
$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents');
|
$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
// Show registration form with dynamic student and emergency contact blocks
|
// Show registration form with dynamic student and emergency contact blocks
|
||||||
$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm');
|
$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck');
|
$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
// Handle form submission (students + emergency contacts)
|
// Handle form submission (students + emergency contacts)
|
||||||
$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration');
|
$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration', ['filter' => 'auth:parent']);
|
||||||
$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1');
|
$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1');
|
$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1');
|
$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1');
|
$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
|
$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
|
$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
|
|
||||||
/*management navigation bar*/
|
/*management navigation bar*/
|
||||||
$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth']);
|
$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth:administrator']);
|
||||||
$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth']);
|
$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth:administrator']);
|
||||||
$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth']);
|
$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth:administrator']);
|
||||||
$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth']);
|
$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth:administrator']);
|
||||||
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth']);
|
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth:administrator']);
|
||||||
|
$routes->post('nav-builder/role-access', 'View\NavBuilderController::roleAccess', ['filter' => 'auth:administrator']);
|
||||||
|
|
||||||
|
|
||||||
|
// Teacher book distribution is the only inventory path teachers may access directly.
|
||||||
|
$routes->get('inventory/books/distribute', 'View\InventoryController::teacherDistributeForm', ['filter' => 'auth:view_inventory|update_inventory|administrator|administrative staff|principal|teacher|teacher_assistant']);
|
||||||
|
$routes->post('inventory/books/distribute', 'View\InventoryController::teacherDistributeStore', ['filter' => 'auth:view_inventory|update_inventory|administrator|administrative staff|principal|teacher|teacher_assistant,update']);
|
||||||
|
|
||||||
// Inventory routes
|
// Inventory routes
|
||||||
$routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
|
$routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|administrative staff|principal'], static function ($routes) {
|
||||||
|
|
||||||
/** ---------------------------
|
/** ---------------------------
|
||||||
* Movements (put FIRST)
|
* Movements (put FIRST)
|
||||||
@@ -922,11 +962,11 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
|
|||||||
$routes->group('movements', static function ($routes) {
|
$routes->group('movements', static function ($routes) {
|
||||||
$routes->get('/', 'View\InventoryController::movementsIndex'); // /inventory/movements
|
$routes->get('/', 'View\InventoryController::movementsIndex'); // /inventory/movements
|
||||||
$routes->get('create', 'View\InventoryController::movementsCreate'); // /inventory/movements/create
|
$routes->get('create', 'View\InventoryController::movementsCreate'); // /inventory/movements/create
|
||||||
$routes->post('store', 'View\InventoryController::movementsStore'); // /inventory/movements/store
|
$routes->post('store', 'View\InventoryController::movementsStore', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']); // /inventory/movements/store
|
||||||
$routes->get('edit/(:num)', 'View\InventoryController::movementsEdit/$1'); // /inventory/movements/edit/123
|
$routes->get('edit/(:num)', 'View\InventoryController::movementsEdit/$1'); // /inventory/movements/edit/123
|
||||||
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1'); // /inventory/movements/update/123
|
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); // /inventory/movements/update/123
|
||||||
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1'); // /inventory/movements/delete/123
|
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']); // /inventory/movements/delete/123
|
||||||
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete');
|
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** ---------------------------
|
/** ---------------------------
|
||||||
@@ -935,19 +975,21 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
|
|||||||
// Summary & actions (keep these BEFORE the wildcard too)
|
// Summary & actions (keep these BEFORE the wildcard too)
|
||||||
$routes->get('summary-all', 'View\InventoryController::summaryAll');
|
$routes->get('summary-all', 'View\InventoryController::summaryAll');
|
||||||
$routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1');
|
$routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1');
|
||||||
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1');
|
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
$routes->get('books/distribute', 'View\InventoryController::teacherDistributeForm');
|
$routes->get('book-prices', 'View\InventoryController::bookPrices');
|
||||||
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore');
|
$routes->post('book-prices', 'View\InventoryController::updateBookPrices', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
$routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1');
|
$routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1');
|
||||||
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1');
|
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
// CRUD for items/categories
|
// CRUD for items/categories
|
||||||
$routes->get('create/(:alpha)', 'View\InventoryController::create/$1');
|
$routes->get('create/(:alpha)', 'View\InventoryController::create/$1');
|
||||||
$routes->post('store', 'View\InventoryController::store');
|
$routes->post('store', 'View\InventoryController::store', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']);
|
||||||
$routes->get('edit/(:num)', 'View\InventoryController::edit/$1');
|
$routes->get('edit/(:num)', 'View\InventoryController::edit/$1');
|
||||||
$routes->post('update/(:num)', 'View\InventoryController::update/$1');
|
$routes->post('update/(:num)', 'View\InventoryController::update/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1');
|
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
|
||||||
$routes->post('category/save', 'View\InventoryController::saveCategory');
|
$routes->post('category/save', 'View\InventoryController::saveCategory', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('po/receive/(:num)', 'View\PurchaseOrderController::receive/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('po/receipt/reverse/(:num)', 'View\PurchaseOrderController::reverseReceipt/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
/** ---------------------------
|
/** ---------------------------
|
||||||
* Index & section switcher
|
* Index & section switcher
|
||||||
@@ -959,9 +1001,6 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
|
|||||||
// $routes->get('(:alpha)', 'View\InventoryController::index/$1');
|
// $routes->get('(:alpha)', 'View\InventoryController::index/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$routes->get('admin/enrollment/new-students', 'View\AdministratorController::showNewStudents', ['filter' => 'auth:view_new_students']);
|
$routes->get('admin/enrollment/new-students', 'View\AdministratorController::showNewStudents', ['filter' => 'auth:view_new_students']);
|
||||||
|
|
||||||
|
|
||||||
@@ -1010,28 +1049,29 @@ $routes->group('communications', static function ($routes) {
|
|||||||
////////////////////////////////////////////////
|
////////////////////////////////////////////////
|
||||||
|
|
||||||
// app/Config/Routes.php
|
// app/Config/Routes.php
|
||||||
$routes->group('api', static function ($routes) {
|
$routes->group('api', ['filter' => 'auth:admin|principal'], static function ($routes) {
|
||||||
// Families & Guardians API
|
// Families & Guardians API
|
||||||
$routes->get('students/(:num)/families', 'FamilyController::familiesByStudent/$1');
|
$routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$1');
|
||||||
$routes->get('families/(:num)/guardians', 'FamilyController::guardiansByFamily/$1');
|
$routes->get('families/(:num)/guardians', 'View\FamilyController::guardiansByFamily/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->group('families', static function ($routes) {
|
$routes->group('families', ['filter' => 'auth:admin|principal'], static function ($routes) {
|
||||||
// Allow GET for convenience in browser; keep POST for API/automation
|
// Allow GET for convenience in browser; keep POST for API/automation
|
||||||
$routes->match(['get', 'post'], 'bootstrap', 'FamilyController::bootstrap'); // protect with auth in production
|
$routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production
|
||||||
$routes->post('attach-second-by-user', 'FamilyController::attachSecondByUser');
|
$routes->post('attach-second-by-user', 'View\FamilyController::attachSecondByUser');
|
||||||
$routes->post('attach-second-by-email', 'FamilyController::attachSecondByEmail');
|
$routes->post('attach-second-by-email', 'View\FamilyController::attachSecondByEmail');
|
||||||
|
|
||||||
$routes->post('set-primary-home', 'FamilyController::setPrimaryHome');
|
$routes->post('set-primary-home', 'View\FamilyController::setPrimaryHome');
|
||||||
$routes->post('set-guardian-flags', 'FamilyController::setGuardianFlags');
|
$routes->post('set-guardian-flags', 'View\FamilyController::setGuardianFlags');
|
||||||
$routes->post('unlink-guardian', 'FamilyController::unlinkGuardian');
|
$routes->post('unlink-guardian', 'View\FamilyController::unlinkGuardian');
|
||||||
$routes->post('unlink-student', 'FamilyController::unlinkStudent');
|
$routes->post('unlink-student', 'View\FamilyController::unlinkStudent');
|
||||||
});
|
});
|
||||||
// Allow GET for manual triggering from browser
|
// Allow GET for manual triggering from browser
|
||||||
$routes->match(['get', 'post'], 'families/import-legacy', 'FamilyController::importSecondParentsFromLegacy');
|
$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
// Admin page (protect with your auth/permission)
|
// Admin page (protect with your auth/permission)
|
||||||
$routes->group('family', static function ($routes) {
|
$routes->group('family', ['filter' => 'auth:admin|principal'], static function ($routes) {
|
||||||
|
$routes->get('', 'View\FamilyAdminController::index');
|
||||||
$routes->get('index', 'View\FamilyAdminController::index');
|
$routes->get('index', 'View\FamilyAdminController::index');
|
||||||
$routes->get('search', 'View\FamilyAdminController::search');
|
$routes->get('search', 'View\FamilyAdminController::search');
|
||||||
$routes->get('card', 'View\FamilyAdminController::card');
|
$routes->get('card', 'View\FamilyAdminController::card');
|
||||||
@@ -1039,13 +1079,13 @@ $routes->group('family', static function ($routes) {
|
|||||||
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
|
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
|
||||||
});
|
});
|
||||||
// Convenience alias
|
// Convenience alias
|
||||||
$routes->get('family', 'View\FamilyAdminController::index');
|
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
|
||||||
//////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////
|
||||||
|
|
||||||
//upload files
|
//upload files
|
||||||
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1', ['filter' => 'auth']);
|
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1', ['filter' => 'auth']);
|
||||||
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1', ['filter' => 'auth']); // serves from writable/uploads/reimbursements
|
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1', ['filter' => 'auth']); // serves from writable/uploads/reimbursements
|
||||||
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1');
|
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1', ['filter' => 'auth']);
|
||||||
// Expenses
|
// Expenses
|
||||||
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
@@ -1057,26 +1097,24 @@ $routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::upd
|
|||||||
|
|
||||||
|
|
||||||
// app/Config/Routes.php
|
// app/Config/Routes.php
|
||||||
$routes->get('whatsapp/', 'View\WhatsappController::index');
|
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites');
|
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink');
|
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts');
|
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass');
|
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin|principal']);
|
||||||
// Track WhatsApp group membership per class/parent
|
// Track WhatsApp group membership per class/parent
|
||||||
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership');
|
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//Attendance
|
//Attendance
|
||||||
$staffAttendanceAccess = 'auth:admin|administrator|administrative staff|principal|vice principal';
|
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => 'auth:admin|principal']);
|
||||||
|
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => $staffAttendanceAccess]);
|
|
||||||
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['filter' => $staffAttendanceAccess]);
|
|
||||||
// NEW: monthly overview (all sections)
|
// NEW: monthly overview (all sections)
|
||||||
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => $staffAttendanceAccess]);
|
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => $staffAttendanceAccess]);
|
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => $staffAttendanceAccess]);
|
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
|
|
||||||
$routes->post('attendance/record', 'View\AttendanceTrackingController::record');
|
$routes->post('attendance/record', 'View\AttendanceTrackingController::record');
|
||||||
@@ -1105,15 +1143,15 @@ $routes->post('admin/teacher-attendance/save-cell', 'View\AttendanceController::
|
|||||||
$routes->post('admin/admins-attendance/save-cell', 'View\AttendanceController::saveCell');
|
$routes->post('admin/admins-attendance/save-cell', 'View\AttendanceController::saveCell');
|
||||||
|
|
||||||
|
|
||||||
$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact');
|
$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact', ['filter' => 'auth:parent']);
|
||||||
$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
|
$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
|
|
||||||
$routes->get('rfid_coming_soon', 'View\RFIDController::rfidComingSoon');
|
$routes->get('rfid_coming_soon', 'View\RFIDController::rfidComingSoon');
|
||||||
|
|
||||||
|
|
||||||
$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact');
|
$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm');
|
$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
|
|
||||||
$routes->get('report/combined', 'View\ScorePredictor::combinedReport');
|
$routes->get('report/combined', 'View\ScorePredictor::combinedReport');
|
||||||
@@ -1121,11 +1159,11 @@ $routes->get('report/combined', 'View\ScorePredictor::combinedReport');
|
|||||||
|
|
||||||
//$routes->get('/parent/withdraw_success', 'View\ParentController::withdraw_success');
|
//$routes->get('/parent/withdraw_success', 'View\ParentController::withdraw_success');
|
||||||
|
|
||||||
$routes->get('/profile/(:num)', 'View\ParentController::profile/$1');
|
$routes->get('/profile/(:num)', 'View\ParentController::profile/$1', ['filter' => 'auth']);
|
||||||
$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1');
|
$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1', ['filter' => 'auth']);
|
||||||
|
|
||||||
$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1');
|
$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1', ['filter' => 'auth']);
|
||||||
$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1');
|
$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1', ['filter' => 'auth']);
|
||||||
|
|
||||||
// Route to view_add_edit the configuration page
|
// Route to view_add_edit the configuration page
|
||||||
$routes->get('/configuration/configuration_view', 'View\ConfigurationController::index');
|
$routes->get('/configuration/configuration_view', 'View\ConfigurationController::index');
|
||||||
@@ -1134,6 +1172,23 @@ $routes->post('/configuration/addConfig', 'View\ConfigurationController::addConf
|
|||||||
$routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\ConfigurationController::editConfig/$1');
|
$routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\ConfigurationController::editConfig/$1');
|
||||||
$routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1');
|
$routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1');
|
||||||
|
|
||||||
|
// School-year management
|
||||||
|
$routes->group('administrator/school-years', ['filter' => 'auth:admin|administrator|principal|vice_principal'], static function ($routes) {
|
||||||
|
$routes->get('', 'Administrator\SchoolYearController::index');
|
||||||
|
$routes->post('store', 'Administrator\SchoolYearController::store');
|
||||||
|
$routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1');
|
||||||
|
$routes->post('(:num)/activate', 'Administrator\SchoolYearController::activate/$1');
|
||||||
|
$routes->post('(:num)/delete-draft', 'Administrator\SchoolYearController::deleteDraft/$1');
|
||||||
|
$routes->post('(:num)/archive', 'Administrator\SchoolYearController::archive/$1');
|
||||||
|
$routes->post('(:num)/reopen', 'Administrator\SchoolYearController::reopen/$1');
|
||||||
|
$routes->get('(:num)/closing/preview', 'Administrator\SchoolYearClosingController::preview/$1');
|
||||||
|
$routes->get('(:segment)/closing/preview', 'Administrator\SchoolYearClosingController::previewByName/$1');
|
||||||
|
$routes->post('(:num)/closing/start', 'Administrator\SchoolYearClosingController::start/$1');
|
||||||
|
$routes->post('(:num)/closing/execute', 'Administrator\SchoolYearClosingController::execute/$1');
|
||||||
|
$routes->post('(:num)/closing/complete', 'Administrator\SchoolYearClosingController::complete/$1');
|
||||||
|
$routes->post('(:num)/closing/cancel', 'Administrator\SchoolYearClosingController::cancel/$1');
|
||||||
|
});
|
||||||
|
|
||||||
// Route for Attendance Comment Templates
|
// Route for Attendance Comment Templates
|
||||||
$routes->get('/administrator/attendance-templates', 'View\AttendanceCommentTemplateController::index');
|
$routes->get('/administrator/attendance-templates', 'View\AttendanceCommentTemplateController::index');
|
||||||
$routes->get('/api/attendance-templates', 'View\AttendanceCommentTemplateController::listData');
|
$routes->get('/api/attendance-templates', 'View\AttendanceCommentTemplateController::listData');
|
||||||
@@ -1200,14 +1255,18 @@ $routes->get('/help_center', 'View\PageController::helpCenter');
|
|||||||
|
|
||||||
//payment
|
//payment
|
||||||
$routes->get('/payment', 'View\PaymentController::redirectPage', ['filter' => 'auth:parent']);
|
$routes->get('/payment', 'View\PaymentController::redirectPage', ['filter' => 'auth:parent']);
|
||||||
$routes->get('/payment/manual', 'View\PaymentController::manual');
|
$routes->get('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->post('/payment/manual', 'View\PaymentController::manual');
|
$routes->post('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||||
|
|
||||||
// Voucher management
|
// Voucher management
|
||||||
$routes->get('discounts/list', 'View\DiscountController::listVouchers', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('discounts/list', 'View\DiscountController::listVouchers', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->match(['get', 'post'], 'discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
$routes->get('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
$routes->match(['get', 'post'], 'discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||||
$routes->match(['get', 'post'], 'discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->get('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
|
$routes->post('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->get('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||||
|
$routes->post('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
$routes->post('discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1226,20 +1285,20 @@ $routes->post('/register', 'View\RegisterController::register');
|
|||||||
$routes->get('/register/success', 'View\RegisterController::success');
|
$routes->get('/register/success', 'View\RegisterController::success');
|
||||||
|
|
||||||
|
|
||||||
$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm');
|
$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
|
|
||||||
$routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin', ['filter' => 'auth:admin_dashboard,read']);
|
$routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin', ['filter' => 'auth:admin_dashboard,read']);
|
||||||
$routes->get('/teacher_dashboard', 'View\LandingPageController::teacher', ['filter' => 'auth:teacher_dashboard,read']);
|
$routes->get('/teacher_dashboard', 'View\LandingPageController::teacher', ['filter' => 'auth:teacher_dashboard,read']);
|
||||||
$routes->get('/landing_page/student_dashboard', 'View\LandingPageController::student', ['filter' => 'auth:student_dashboard,read']);
|
$routes->get('/landing_page/student_dashboard', 'View\LandingPageController::student', ['filter' => 'auth:student_dashboard,read']);
|
||||||
$routes->get('/parent_dashboard', 'View\LandingPageController::parentDashboard', ['filter' => 'auth:parent_dashboard,read']);
|
$routes->get('/parent_dashboard', 'View\LandingPageController::parentDashboard', ['filter' => 'auth:parent_dashboard,read']);
|
||||||
|
$routes->post('/parent_dashboard/job-openings-popup', 'View\LandingPageController::hideJobOpeningsPopup', ['filter' => 'auth:parent_dashboard,read']);
|
||||||
$routes->get('/landing_page/guest_dashboard', 'View\LandingPageController::guest', ['filter' => 'auth:guest_dashboard,read']);
|
$routes->get('/landing_page/guest_dashboard', 'View\LandingPageController::guest', ['filter' => 'auth:guest_dashboard,read']);
|
||||||
$routes->get('/dashboard', 'View\LandingPageController::index');
|
$routes->get('/dashboard', 'View\LandingPageController::index');
|
||||||
$routes->get('/access_denied', 'ErrorController::accessDenied');
|
$routes->get('/access_denied', 'ErrorController::accessDenied');
|
||||||
|
|
||||||
|
|
||||||
$routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']);
|
$routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']);
|
||||||
$routes->post('administrator/students/set-active', 'View\StudentController::setStudentActive', ['filter' => 'auth:edit_student,update']);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
@@ -1267,7 +1326,6 @@ $routes->get('flags/flags_management', 'View\FlagController::index');
|
|||||||
$routes->get('flags/processed_flags', 'View\FlagController::processedFlags');
|
$routes->get('flags/processed_flags', 'View\FlagController::processedFlags');
|
||||||
$routes->get('flags/incident_analysis', 'View\FlagController::incidentAnalysis');
|
$routes->get('flags/incident_analysis', 'View\FlagController::incidentAnalysis');
|
||||||
$routes->post('flags/add', 'View\FlagController::addFlag');
|
$routes->post('flags/add', 'View\FlagController::addFlag');
|
||||||
$routes->get('/flags/update_state/(:num)', 'View\FlagController::updateState/$1');
|
|
||||||
$routes->post('/flags/update_state/(:num)', 'View\FlagController::updateState/$1');
|
$routes->post('/flags/update_state/(:num)', 'View\FlagController::updateState/$1');
|
||||||
$routes->get('flags/getStudentsByGrade/(:num)', 'View\FlagController::getStudentsByGrade/$1');
|
$routes->get('flags/getStudentsByGrade/(:num)', 'View\FlagController::getStudentsByGrade/$1');
|
||||||
$routes->get('flags/history', 'View\FlagController::history');
|
$routes->get('flags/history', 'View\FlagController::history');
|
||||||
@@ -1351,14 +1409,11 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
$routes->get('payments', 'View\UserController::index');
|
$routes->get('payments', 'View\UserController::index');
|
||||||
$routes->get('payments/(:num)', 'View\SupportController::show/$1');
|
$routes->get('payments/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->get('payments/parent/(:num)', 'View\SupportController::getByParent/$1');
|
$routes->get('payments/parent/(:num)', 'View\SupportController::getByParent/$1');
|
||||||
$routes->post('payments', 'View\TeacherController::create');
|
|
||||||
$routes->put('payments/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
|
|
||||||
// Invoices
|
// Invoices
|
||||||
$routes->get('invoices', 'View\UserController::index');
|
$routes->get('invoices', 'View\UserController::index');
|
||||||
$routes->get('invoices/(:num)', 'View\SupportController::show/$1');
|
$routes->get('invoices/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->get('invoices/parent/(:num)', 'View\SupportController::getByParent/$1');
|
$routes->get('invoices/parent/(:num)', 'View\SupportController::getByParent/$1');
|
||||||
$routes->put('invoices/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
$routes->get('notifications', 'View\UserController::index');
|
$routes->get('notifications', 'View\UserController::index');
|
||||||
@@ -1379,14 +1434,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
// Expenses
|
// Expenses
|
||||||
$routes->get('expenses', 'View\UserController::index');
|
$routes->get('expenses', 'View\UserController::index');
|
||||||
$routes->get('expenses/(:num)', 'View\SupportController::show/$1');
|
$routes->get('expenses/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->post('expenses', 'View\TeacherController::create');
|
|
||||||
$routes->put('expenses/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
|
|
||||||
// Reimbursements
|
// Reimbursements
|
||||||
$routes->get('reimbursements', 'View\UserController::index');
|
$routes->get('reimbursements', 'View\UserController::index');
|
||||||
$routes->get('reimbursements/(:num)', 'View\SupportController::show/$1');
|
$routes->get('reimbursements/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->post('reimbursements', 'View\TeacherController::create');
|
|
||||||
$routes->put('reimbursements/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
|
|
||||||
// Dashboard Data
|
// Dashboard Data
|
||||||
$routes->get('dashboard', 'DashboardController::index');
|
$routes->get('dashboard', 'DashboardController::index');
|
||||||
@@ -1452,19 +1503,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
// Refunds
|
// Refunds
|
||||||
$routes->get('refunds', 'View\UserController::index');
|
$routes->get('refunds', 'View\UserController::index');
|
||||||
$routes->get('refunds/(:num)', 'View\SupportController::show/$1');
|
$routes->get('refunds/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->post('refunds', 'View\TeacherController::create');
|
|
||||||
$routes->put('refunds/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
|
|
||||||
// Discounts
|
// Discounts
|
||||||
$routes->get('discounts', 'View\UserController::index');
|
$routes->get('discounts', 'View\UserController::index');
|
||||||
$routes->get('discounts/code/(:segment)', 'View\SupportController::getByCode/$1');
|
$routes->get('discounts/code/(:segment)', 'View\SupportController::getByCode/$1');
|
||||||
$routes->post('discounts/apply', 'View\SupportController::apply');
|
|
||||||
|
|
||||||
// Payment Transactions
|
|
||||||
$routes->get('payment-transactions', 'View\UserController::index');
|
|
||||||
$routes->get('payment-transactions/(:num)', 'View\SupportController::show/$1');
|
|
||||||
$routes->get('payment-transactions/payment/(:num)', 'View\SupportController::getByPayment/$1');
|
|
||||||
$routes->post('payment-transactions', 'View\TeacherController::create');
|
|
||||||
|
|
||||||
// Emergency Contacts
|
// Emergency Contacts
|
||||||
$routes->get('emergency-contacts', 'View\UserController::index');
|
$routes->get('emergency-contacts', 'View\UserController::index');
|
||||||
@@ -1510,9 +1552,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
// Extra Charges
|
// Extra Charges
|
||||||
$routes->get('extra-charges', 'View\UserController::index');
|
$routes->get('extra-charges', 'View\UserController::index');
|
||||||
$routes->get('extra-charges/(:num)', 'View\SupportController::show/$1');
|
$routes->get('extra-charges/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->post('extra-charges', 'View\TeacherController::create');
|
|
||||||
$routes->put('extra-charges/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
$routes->post('extra-charges/(:num)/void', 'View\SupportController::void/$1');
|
|
||||||
$routes->get('extra-charges/parents', 'View\SupportController::parentOptions');
|
$routes->get('extra-charges/parents', 'View\SupportController::parentOptions');
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
@@ -1563,7 +1602,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
|
|
||||||
// Payment Notifications
|
// Payment Notifications
|
||||||
$routes->get('payment-notifications', 'View\UserController::index');
|
$routes->get('payment-notifications', 'View\UserController::index');
|
||||||
$routes->post('payment-notifications/send', 'View\SupportController::send');
|
|
||||||
|
|
||||||
// RFID
|
// RFID
|
||||||
$routes->post('rfid/process', 'View\SupportController::process');
|
$routes->post('rfid/process', 'View\SupportController::process');
|
||||||
@@ -1580,9 +1618,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
// Inventory
|
// Inventory
|
||||||
$routes->get('inventory', 'View\UserController::index');
|
$routes->get('inventory', 'View\UserController::index');
|
||||||
$routes->get('inventory/(:num)', 'View\SupportController::show/$1');
|
$routes->get('inventory/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->post('inventory', 'View\TeacherController::create');
|
|
||||||
$routes->put('inventory/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
$routes->delete('inventory/(:num)', 'View\TeacherController::delete/$1');
|
|
||||||
$routes->get('inventory/movements', 'View\SupportController::movements');
|
$routes->get('inventory/movements', 'View\SupportController::movements');
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
@@ -1634,8 +1669,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
|||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
$routes->get('purchase-orders', 'View\UserController::index');
|
$routes->get('purchase-orders', 'View\UserController::index');
|
||||||
$routes->get('purchase-orders/(:num)', 'View\SupportController::show/$1');
|
$routes->get('purchase-orders/(:num)', 'View\SupportController::show/$1');
|
||||||
$routes->post('purchase-orders', 'View\TeacherController::create');
|
|
||||||
$routes->put('purchase-orders/(:num)', 'View\TeacherController::update/$1');
|
|
||||||
|
|
||||||
// Grading
|
// Grading
|
||||||
$routes->get('grading/(:segment)/(:num)/(:num)', 'View\SupportController::show/$1/$2/$3');
|
$routes->get('grading/(:segment)/(:num)/(:num)', 'View\SupportController::show/$1/$2/$3');
|
||||||
@@ -1771,13 +1804,13 @@ if (file_exists(APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php')) {
|
|||||||
// ---- Administrator API endpoints (added by Codex) ----
|
// ---- Administrator API endpoints (added by Codex) ----
|
||||||
$routes->group('api/administrator', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
$routes->group('api/administrator', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
||||||
$routes->get('dashboard', 'AdministratorController::dashboard');
|
$routes->get('dashboard', 'AdministratorController::dashboard');
|
||||||
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin']);
|
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin']);
|
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin']);
|
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin']);
|
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin']);
|
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin']);
|
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin']);
|
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin|principal']);
|
||||||
});
|
});
|
||||||
// ---- Parent API additions (parity with View) ----
|
// ---- Parent API additions (parity with View) ----
|
||||||
$routes->group('api/parents', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
$routes->group('api/parents', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
||||||
|
|||||||
+354
-12
@@ -70,21 +70,26 @@ class Services extends BaseService
|
|||||||
|
|
||||||
$mail = new PHPMailer(true);
|
$mail = new PHPMailer(true);
|
||||||
|
|
||||||
// Set up your PHPMailer configuration
|
$host = (string) env('MAIL_DEFAULT_HOST', env('SMTP_HOST', 'smtp.gmail.com'));
|
||||||
|
$user = (string) env('MAIL_DEFAULT_USER', env('SMTP_USER', ''));
|
||||||
|
$pass = (string) env('MAIL_DEFAULT_PASS', env('SMTP_PASS', ''));
|
||||||
|
$port = (int) env('MAIL_DEFAULT_PORT', env('SMTP_PORT', 465));
|
||||||
|
$encryption = strtolower((string) env('MAIL_DEFAULT_ENCRYPTION', env('SMTP_ENCRYPTION', 'ssl')));
|
||||||
|
|
||||||
$mail->isSMTP();
|
$mail->isSMTP();
|
||||||
$mail->Host = 'smtp.gmail.com'; // Your SMTP host
|
$mail->Host = $host;
|
||||||
$mail->SMTPAuth = true;
|
$mail->SMTPAuth = true;
|
||||||
$mail->Username = 'alrahma.sunday.school@gmail.com'; // Your email
|
$mail->Username = $user;
|
||||||
$mail->Password = 'psnp emdq dykw ypul'; // Your email password
|
$mail->Password = $pass;
|
||||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
$mail->SMTPSecure = in_array($encryption, ['tls', 'starttls'], true)
|
||||||
$mail->Port = 465;
|
? PHPMailer::ENCRYPTION_STARTTLS
|
||||||
|
: PHPMailer::ENCRYPTION_SMTPS;
|
||||||
|
$mail->Port = $port > 0 ? $port : 465;
|
||||||
|
|
||||||
$mail->Timeout = 10; // ⏱ 10-second timeout max
|
$mail->Timeout = 10;
|
||||||
$mail->SMTPKeepAlive = false; // Prevent hanging connections
|
$mail->SMTPKeepAlive = false;
|
||||||
$mail->SMTPDebug = 0; // Set to 2 temporarily for debugging
|
$mail->SMTPDebug = 0;
|
||||||
|
$mail->Debugoutput = 'error_log';
|
||||||
$mail->SMTPDebug = 2; // You can set it to 1, 2, or 3 for increasing verbosity
|
|
||||||
$mail->Debugoutput = 'html'; // You can output it to 'html' or 'error_log'
|
|
||||||
|
|
||||||
return $mail;
|
return $mail;
|
||||||
}
|
}
|
||||||
@@ -180,4 +185,341 @@ class Services extends BaseService
|
|||||||
$http = static::curlrequest($options);
|
$http = static::curlrequest($options);
|
||||||
return new \App\Services\ApiClient($http, $apiConfig);
|
return new \App\Services\ApiClient($http, $apiConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function schoolYearContext(bool $getShared = true): \App\Services\SchoolYearContextService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearContext');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearContextService(
|
||||||
|
model(\App\Models\SchoolYearModel::class),
|
||||||
|
static::schoolYearAccessPolicy()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schoolYearAccessPolicy(bool $getShared = true): \App\Services\SchoolYearAccessPolicy
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearAccessPolicy');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearAccessPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schoolYearViewData(bool $getShared = true): \App\Services\SchoolYearViewDataService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearViewData');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearViewDataService(
|
||||||
|
static::schoolYearContext(),
|
||||||
|
static::schoolYearAccessPolicy()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schoolYearWriteGuard(bool $getShared = true): \App\Services\SchoolYearWriteGuard
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearWriteGuard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearWriteGuard();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schoolYearValidation(bool $getShared = true): \App\Services\SchoolYearValidationService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearValidation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearValidationService(
|
||||||
|
model(\App\Models\SchoolYearModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function staffDirectorySync(bool $getShared = true): \App\Services\StaffDirectorySyncService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('staffDirectorySync');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\StaffDirectorySyncService(
|
||||||
|
model(\App\Models\StaffModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
model(\App\Models\ConfigurationModel::class),
|
||||||
|
\Config\Database::connect()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schoolYearManagement(bool $getShared = true): \App\Services\SchoolYearManagementService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearManagement');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearManagementService(
|
||||||
|
model(\App\Models\SchoolYearModel::class),
|
||||||
|
model(\App\Models\ConfigurationModel::class),
|
||||||
|
model(\App\Models\SchoolYearTransitionLogModel::class),
|
||||||
|
model(\App\Models\SchoolYearClosingBatchModel::class),
|
||||||
|
static::schoolYearValidation(),
|
||||||
|
\Config\Database::connect()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schoolYearClosing(bool $getShared = true): \App\Services\SchoolYearClosingService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('schoolYearClosing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\SchoolYearClosingService(
|
||||||
|
model(\App\Models\SchoolYearModel::class),
|
||||||
|
model(\App\Models\SchoolYearClosingBatchModel::class),
|
||||||
|
model(\App\Models\SchoolYearClosingItemModel::class),
|
||||||
|
static::schoolYearManagement(),
|
||||||
|
\Config\Database::connect()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enrollmentTransition(bool $getShared = true): \App\Services\EnrollmentTransitionService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('enrollmentTransition');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\EnrollmentTransitionService(\Config\Database::connect());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enrollmentStatus(bool $getShared = true): \App\Services\EnrollmentStatusService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('enrollmentStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\EnrollmentStatusService(\Config\Database::connect());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enrollmentRegistrationEmail(bool $getShared = true): \App\Services\EnrollmentRegistrationEmailService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('enrollmentRegistrationEmail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\EnrollmentRegistrationEmailService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
static::enrollmentTransition(),
|
||||||
|
static::emailService(),
|
||||||
|
model(\App\Models\ConfigurationModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function financialAid(bool $getShared = true): \App\Services\FinancialAidService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('financialAid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\FinancialAidService(
|
||||||
|
model(\App\Models\FinancialAidRequestModel::class),
|
||||||
|
model(\App\Models\InvoiceModel::class),
|
||||||
|
model(\App\Models\DiscountVoucherModel::class),
|
||||||
|
model(\App\Models\DiscountUsageModel::class),
|
||||||
|
new \App\Libraries\InvoiceLedgerService()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enrollmentWithdrawal(bool $getShared = true): \App\Services\EnrollmentWithdrawalService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('enrollmentWithdrawal');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
return new \App\Services\EnrollmentWithdrawalService(
|
||||||
|
$db,
|
||||||
|
model(\App\Models\StudentModel::class),
|
||||||
|
model(\App\Models\EnrollmentModel::class),
|
||||||
|
model(\App\Models\StudentClassModel::class),
|
||||||
|
model(\App\Models\ClassSectionModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
model(\App\Models\InvoiceModel::class),
|
||||||
|
model(\App\Models\RefundModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function teacherSubmissionReport(bool $getShared = true): \App\Services\TeacherSubmissionReportService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('teacherSubmissionReport');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\TeacherSubmissionReportService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
model(\App\Models\ConfigurationModel::class),
|
||||||
|
model(\App\Models\StudentClassModel::class),
|
||||||
|
model(\App\Models\ClassSectionModel::class),
|
||||||
|
model(\App\Models\UserModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function administratorDashboard(bool $getShared = true): \App\Services\AdministratorDashboardService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('administratorDashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\AdministratorDashboardService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
model(\App\Models\LoginActivityModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminNotificationSettings(bool $getShared = true): \App\Services\AdminNotificationSettingsService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('adminNotificationSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\AdminNotificationSettingsService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
model(\App\Models\AdminNotificationSubjectModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function administratorDirectory(bool $getShared = true): \App\Services\AdministratorDirectoryService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('administratorDirectory');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\AdministratorDirectoryService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
model(\App\Models\StudentClassModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
model(\App\Models\UserRoleModel::class),
|
||||||
|
model(\App\Models\InvoiceModel::class),
|
||||||
|
model(\App\Models\StudentModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function gradingScore(bool $getShared = true): \App\Services\GradingScoreService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('gradingScore');
|
||||||
|
}
|
||||||
|
|
||||||
|
$configModel = model(\App\Models\ConfigurationModel::class);
|
||||||
|
$attendanceCalculator = new \App\Services\Calculators\AttendanceCalculator(
|
||||||
|
model(\App\Models\AttendanceRecordModel::class),
|
||||||
|
$configModel,
|
||||||
|
model(\App\Models\CalendarModel::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new \App\Services\GradingScoreService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
$configModel,
|
||||||
|
model(\App\Models\HomeworkModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
model(\App\Models\StudentClassModel::class),
|
||||||
|
model(\App\Models\StudentModel::class),
|
||||||
|
model(\App\Models\TeacherClassModel::class),
|
||||||
|
model(\App\Models\ClassSectionModel::class),
|
||||||
|
$attendanceCalculator,
|
||||||
|
model(\App\Models\GradingLockModel::class),
|
||||||
|
static::semesterScoreService(),
|
||||||
|
(string) $configModel->getConfig('school_year'),
|
||||||
|
(string) getSemester()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function placementGrading(bool $getShared = true): \App\Services\PlacementGradingService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('placementGrading');
|
||||||
|
}
|
||||||
|
|
||||||
|
$configModel = model(\App\Models\ConfigurationModel::class);
|
||||||
|
|
||||||
|
return new \App\Services\PlacementGradingService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
model(\App\Models\StudentModel::class),
|
||||||
|
model(\App\Models\PlacementLevelModel::class),
|
||||||
|
model(\App\Models\PlacementBatchModel::class),
|
||||||
|
model(\App\Models\PlacementScoreModel::class),
|
||||||
|
(string) $configModel->getConfig('school_year')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function belowSixty(bool $getShared = true): \App\Services\BelowSixtyService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('belowSixty');
|
||||||
|
}
|
||||||
|
|
||||||
|
$configModel = model(\App\Models\ConfigurationModel::class);
|
||||||
|
|
||||||
|
return new \App\Services\BelowSixtyService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
$configModel,
|
||||||
|
model(\App\Models\StudentModel::class),
|
||||||
|
model(\App\Models\StudentClassModel::class),
|
||||||
|
model(\App\Models\ParentMeetingScheduleModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
(string) $configModel->getConfig('school_year'),
|
||||||
|
(string) getSemester()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function studentDecision(bool $getShared = true): \App\Services\StudentDecisionService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('studentDecision');
|
||||||
|
}
|
||||||
|
|
||||||
|
$configModel = model(\App\Models\ConfigurationModel::class);
|
||||||
|
|
||||||
|
return new \App\Services\StudentDecisionService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
$configModel,
|
||||||
|
model(\App\Models\StudentModel::class),
|
||||||
|
model(\App\Models\StudentClassModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
(string) $configModel->getConfig('school_year'),
|
||||||
|
(string) getSemester()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function studentYearStatus(bool $getShared = true): \App\Services\StudentYearStatusService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('studentYearStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\StudentYearStatusService(
|
||||||
|
\Config\Database::connect(),
|
||||||
|
model(\App\Models\StudentYearStatusModel::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withdrawalFinancial(bool $getShared = true): \App\Services\WithdrawalFinancialService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('withdrawalFinancial');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
return new \App\Services\WithdrawalFinancialService(
|
||||||
|
$db,
|
||||||
|
new \App\Services\WithdrawalRefundCalculator(),
|
||||||
|
new \App\Services\StudentBookIssueService($db),
|
||||||
|
new \App\Libraries\InvoiceLedgerService(),
|
||||||
|
new \App\Libraries\RefundEligibilityService()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class Session extends BaseConfig
|
|||||||
*/
|
*/
|
||||||
public string $cookieName = 'ci_session';
|
public string $cookieName = 'ci_session';
|
||||||
public string $cookieDomain = ''; // Leave blank for localhost
|
public string $cookieDomain = ''; // Leave blank for localhost
|
||||||
public bool $cookieSecure = false; // Set to false if not using HTTPS
|
public bool $cookieSecure = false; // Forced on in production via constructor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
@@ -101,4 +101,12 @@ class Session extends BaseConfig
|
|||||||
* DB Group for the database session.
|
* DB Group for the database session.
|
||||||
*/
|
*/
|
||||||
public ?string $DBGroup = null;
|
public ?string $DBGroup = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$isProduction = ENVIRONMENT === 'production';
|
||||||
|
$this->cookieSecure = $isProduction;
|
||||||
|
$this->cookieName = $isProduction ? '__Host-ci_session' : 'ci_session';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,18 @@ namespace Config;
|
|||||||
|
|
||||||
class SessionTimeout
|
class SessionTimeout
|
||||||
{
|
{
|
||||||
// Session timeout in seconds (12 hours)
|
// Session timeout in seconds (30 minutes)
|
||||||
public const TIMEOUT_DURATION = 43200;
|
public const TIMEOUT_DURATION = 1800;
|
||||||
|
|
||||||
// Warning threshold in seconds (5 minutes before timeout)
|
// Show warning during the last 30 seconds before timeout.
|
||||||
public const WARNING_THRESHOLD = 42900;
|
public const WARNING_THRESHOLD = 1770;
|
||||||
|
|
||||||
// Server-side check interval (in seconds)
|
// Server-side check interval (in seconds)
|
||||||
public const CHECK_INTERVAL = 300; // 5 minutes
|
public const CHECK_INTERVAL = 30;
|
||||||
|
|
||||||
// Client-side check interval (in milliseconds)
|
// Client-side session status check interval (in milliseconds)
|
||||||
public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute
|
public const CLIENT_CHECK_INTERVAL = 30000;
|
||||||
|
|
||||||
|
// Minimum interval between successful activity pings (in milliseconds)
|
||||||
|
public const CLIENT_PING_INTERVAL = 60000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class CompetitionWinnersController extends BaseController
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$competitionModel = new CompetitionModel();
|
$competitionModel = new CompetitionModel();
|
||||||
|
$schoolYear = $this->currentCompetitionSchoolYear();
|
||||||
|
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$competitionModel->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
$competitions = $competitionModel
|
$competitions = $competitionModel
|
||||||
->orderBy('id', 'DESC')
|
->orderBy('id', 'DESC')
|
||||||
@@ -62,12 +67,13 @@ class CompetitionWinnersController extends BaseController
|
|||||||
return view('admin/competition_winners/index', [
|
return view('admin/competition_winners/index', [
|
||||||
'competitions' => $competitions,
|
'competitions' => $competitions,
|
||||||
'sectionMap' => $this->getClassSectionMap(),
|
'sectionMap' => $this->getClassSectionMap(),
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$schoolYear = $this->configModel->getConfig('school_year');
|
$schoolYear = $this->currentCompetitionSchoolYear();
|
||||||
$classCounts = $this->getClassStudentCounts($schoolYear);
|
$classCounts = $this->getClassStudentCounts($schoolYear);
|
||||||
$classSections = $this->classSectionModel
|
$classSections = $this->classSectionModel
|
||||||
->orderBy('class_section_name', 'ASC')
|
->orderBy('class_section_name', 'ASC')
|
||||||
@@ -81,7 +87,7 @@ class CompetitionWinnersController extends BaseController
|
|||||||
'errors' => session('errors'),
|
'errors' => session('errors'),
|
||||||
'classSections' => $classSections,
|
'classSections' => $classSections,
|
||||||
'classRows' => $classRows,
|
'classRows' => $classRows,
|
||||||
'defaultSemester' => $this->configModel->getConfig('semester'),
|
'defaultSemester' => getSemester(),
|
||||||
'defaultSchoolYear'=> $schoolYear,
|
'defaultSchoolYear'=> $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -113,7 +119,7 @@ class CompetitionWinnersController extends BaseController
|
|||||||
'errors' => session('errors'),
|
'errors' => session('errors'),
|
||||||
'classSections' => $classSections,
|
'classSections' => $classSections,
|
||||||
'classRows' => $classRows,
|
'classRows' => $classRows,
|
||||||
'defaultSemester' => $this->configModel->getConfig('semester'),
|
'defaultSemester' => getSemester(),
|
||||||
'defaultSchoolYear'=> $schoolYear,
|
'defaultSchoolYear'=> $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -385,7 +391,7 @@ class CompetitionWinnersController extends BaseController
|
|||||||
$semester = session('semester');
|
$semester = session('semester');
|
||||||
$schoolYear = session('school_year');
|
$schoolYear = session('school_year');
|
||||||
if ($semester === null || $semester === '') {
|
if ($semester === null || $semester === '') {
|
||||||
$semester = $this->configModel->getConfig('semester');
|
$semester = getSemester();
|
||||||
}
|
}
|
||||||
if ($schoolYear === null || $schoolYear === '') {
|
if ($schoolYear === null || $schoolYear === '') {
|
||||||
$schoolYear = $this->configModel->getConfig('school_year');
|
$schoolYear = $this->configModel->getConfig('school_year');
|
||||||
@@ -1088,6 +1094,11 @@ class CompetitionWinnersController extends BaseController
|
|||||||
return $questionCounts;
|
return $questionCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentCompetitionSchoolYear(): string
|
||||||
|
{
|
||||||
|
return $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
private function getClassSectionMap(): array
|
private function getClassSectionMap(): array
|
||||||
{
|
{
|
||||||
$sections = $this->classSectionModel
|
$sections = $this->classSectionModel
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ class AdminProgressController extends BaseController
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
$filters = [
|
$filters = [
|
||||||
|
'school_year' => $schoolYear,
|
||||||
'from' => (string) $this->request->getGet('from'),
|
'from' => (string) $this->request->getGet('from'),
|
||||||
'to' => (string) $this->request->getGet('to'),
|
'to' => (string) $this->request->getGet('to'),
|
||||||
'class_section_id' => (string) $this->request->getGet('class_section_id'),
|
'class_section_id' => (string) $this->request->getGet('class_section_id'),
|
||||||
@@ -51,6 +57,8 @@ class AdminProgressController extends BaseController
|
|||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
|
||||||
|
|
||||||
|
$this->applyProgressSchoolYearScope($builder, $schoolYear);
|
||||||
|
|
||||||
if ($filters['from']) {
|
if ($filters['from']) {
|
||||||
$builder->where('week_start >=', $filters['from']);
|
$builder->where('week_start >=', $filters['from']);
|
||||||
}
|
}
|
||||||
@@ -91,16 +99,17 @@ class AdminProgressController extends BaseController
|
|||||||
$reportGroupsBySection[$sectionId] = $groups;
|
$reportGroupsBySection[$sectionId] = $groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
$classSections = $this->classSectionModel->getClassSections();
|
$classSections = $this->classSectionsForYear($schoolYear, $rows);
|
||||||
$studentCounts = $this->studentClassModel->getStudentCountsBySection();
|
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
|
||||||
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
|
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts, $reportGroupsBySection) {
|
||||||
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
||||||
return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0;
|
return (isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0)
|
||||||
|
|| isset($reportGroupsBySection[$sectionId]);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
$filterStart = $this->normalizeDate($filters['from'] ?? '');
|
$filterStart = $this->normalizeDate($filters['from'] ?? '');
|
||||||
$filterEnd = $this->normalizeDate($filters['to'] ?? '');
|
$filterEnd = $this->normalizeDate($filters['to'] ?? '');
|
||||||
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates();
|
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates($schoolYear);
|
||||||
[$expectedDays, $activeDatesSet] = $this->resolveExpectedDays(
|
[$expectedDays, $activeDatesSet] = $this->resolveExpectedDays(
|
||||||
$dateList,
|
$dateList,
|
||||||
$noSchoolDays,
|
$noSchoolDays,
|
||||||
@@ -132,6 +141,8 @@ class AdminProgressController extends BaseController
|
|||||||
return view('admin/class_progress_list', [
|
return view('admin/class_progress_list', [
|
||||||
'reportGroupsBySection' => $reportGroupsBySection,
|
'reportGroupsBySection' => $reportGroupsBySection,
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'schoolYears' => $this->availableSchoolYears($schoolYear),
|
||||||
'classSections' => $filteredSections,
|
'classSections' => $filteredSections,
|
||||||
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
|
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
|
||||||
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
|
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
|
||||||
@@ -142,6 +153,162 @@ class AdminProgressController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function classSectionsForYear(string $schoolYear, array $reportRows = []): array
|
||||||
|
{
|
||||||
|
$db = db_connect();
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && $db->fieldExists('school_year', 'classSection')) {
|
||||||
|
$sections = $db->table('classSection')
|
||||||
|
->select('classSection.class_section_id, classSection.class_section_name, classes.class_name')
|
||||||
|
->join('classes', 'classSection.class_id = classes.id', 'left')
|
||||||
|
->where('classSection.school_year', $schoolYear)
|
||||||
|
->orderBy('classSection.class_section_name', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
} else {
|
||||||
|
$sections = $this->classSectionModel->getClassSections();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionsById = [];
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$sectionsById[$sectionId] = $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($reportRows as $row) {
|
||||||
|
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId <= 0 || isset($sectionsById[$sectionId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionsById[$sectionId] = [
|
||||||
|
'class_section_id' => $sectionId,
|
||||||
|
'class_section_name' => (string) ($row['class_section_name'] ?? ('Section #' . $sectionId)),
|
||||||
|
'class_name' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($sectionsById, static function (array $a, array $b): int {
|
||||||
|
return strnatcasecmp((string) ($a['class_section_name'] ?? ''), (string) ($b['class_section_name'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($sectionsById);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyProgressSchoolYearScope($builder, string $schoolYear): void
|
||||||
|
{
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = db_connect();
|
||||||
|
$hasReportYear = $db->fieldExists('school_year', 'class_progress_reports');
|
||||||
|
$hasSectionYear = $db->fieldExists('school_year', 'classSection');
|
||||||
|
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
|
||||||
|
|
||||||
|
if ($hasReportYear) {
|
||||||
|
if ($this->hasAnyExplicitProgressSchoolYearRows()) {
|
||||||
|
$builder->where('class_progress_reports.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$builder
|
||||||
|
->groupStart()
|
||||||
|
->groupStart()
|
||||||
|
->where('class_progress_reports.school_year IS NULL', null, false)
|
||||||
|
->orWhere('class_progress_reports.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||||
|
->where('class_progress_reports.week_start <=', $rangeEnd)
|
||||||
|
->groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->where('class_progress_reports.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasSectionYear) {
|
||||||
|
$builder->where('cs.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$builder
|
||||||
|
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||||
|
->where('class_progress_reports.week_start <=', $rangeEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasAnyExplicitProgressSchoolYearRows(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return db_connect()
|
||||||
|
->table('class_progress_reports')
|
||||||
|
->where('school_year IS NOT NULL', null, false)
|
||||||
|
->where('school_year !=', '')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray() !== null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to check explicit progress school-year rows: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function availableSchoolYears(string $selectedYear): array
|
||||||
|
{
|
||||||
|
$years = [];
|
||||||
|
$addYear = static function (mixed $value) use (&$years): void {
|
||||||
|
$year = trim((string) $value);
|
||||||
|
if ($year !== '' && ! in_array($year, $years, true)) {
|
||||||
|
$years[] = $year;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$addYear($selectedYear);
|
||||||
|
$addYear($this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')));
|
||||||
|
|
||||||
|
$db = db_connect();
|
||||||
|
foreach ([
|
||||||
|
['table' => 'school_years', 'column' => 'name'],
|
||||||
|
['table' => 'class_progress_reports', 'column' => 'school_year'],
|
||||||
|
['table' => 'classSection', 'column' => 'school_year'],
|
||||||
|
['table' => 'student_class', 'column' => 'school_year'],
|
||||||
|
] as $source) {
|
||||||
|
try {
|
||||||
|
if (! $db->tableExists($source['table']) || ! $db->fieldExists($source['column'], $source['table'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $db->table($source['table'])
|
||||||
|
->select($source['column'])
|
||||||
|
->distinct()
|
||||||
|
->where($source['column'] . ' IS NOT NULL', null, false)
|
||||||
|
->orderBy($source['column'], 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$addYear($row[$source['column']] ?? '');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to load school-year options for admin progress: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsort($years, SORT_NATURAL);
|
||||||
|
|
||||||
|
return $years;
|
||||||
|
}
|
||||||
|
|
||||||
public function view($id)
|
public function view($id)
|
||||||
{
|
{
|
||||||
$row = $this->reportModel
|
$row = $this->reportModel
|
||||||
@@ -277,11 +444,15 @@ class AdminProgressController extends BaseController
|
|||||||
return checkdate($m, $d, $y) ? $value : '';
|
return checkdate($m, $d, $y) ? $value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function buildSemesterDates(): array
|
protected function buildSemesterDates(?string $schoolYear = null): array
|
||||||
{
|
{
|
||||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$schoolYear = trim((string) ($schoolYear ?? ''));
|
||||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
if ($schoolYear === '') {
|
||||||
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? '');
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$semester = (string) (getSemester() ?? '');
|
||||||
|
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange);
|
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange);
|
||||||
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
|
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
|
||||||
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Administrator;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\FinancialAidEstimateModel;
|
||||||
|
use App\Models\FinancialAidRequestModel;
|
||||||
|
use App\Models\InvoiceModel;
|
||||||
|
use App\Models\StudentModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class FinancialAidController extends BaseController
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$schoolYear = $this->request->getGet('school_year');
|
||||||
|
$model = new FinancialAidRequestModel();
|
||||||
|
$builder = $model->orderBy('created_at', 'DESC');
|
||||||
|
if (is_string($schoolYear) && trim($schoolYear) !== '') {
|
||||||
|
$builder->where('school_year', trim($schoolYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
$requests = $builder->findAll();
|
||||||
|
$parentIds = array_values(array_unique(array_filter(array_map('intval', array_column($requests, 'parent_id')))));
|
||||||
|
$parents = [];
|
||||||
|
if ($parentIds !== []) {
|
||||||
|
foreach ((new UserModel())->select('id, firstname, lastname, email')->whereIn('id', $parentIds)->findAll() as $parent) {
|
||||||
|
$parents[(int) $parent['id']] = $parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('administrator/financial_aid_queue', [
|
||||||
|
'requests' => $requests,
|
||||||
|
'parents' => $parents,
|
||||||
|
'schoolYear' => is_string($schoolYear) ? trim($schoolYear) : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
$request = (new FinancialAidRequestModel())->find($id);
|
||||||
|
if ($request === null) {
|
||||||
|
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = (new UserModel())->find((int) $request['parent_id']);
|
||||||
|
$studentIds = json_decode((string) ($request['student_ids_json'] ?? '[]'), true);
|
||||||
|
$studentIds = is_array($studentIds) ? array_map('intval', $studentIds) : [];
|
||||||
|
$students = $studentIds !== []
|
||||||
|
? (new StudentModel())->whereIn('id', $studentIds)->findAll()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$schoolYear = (string) ($request['school_year'] ?? '');
|
||||||
|
$invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear);
|
||||||
|
$householdSize = max(1, (int) ($request['household_size'] ?? 1));
|
||||||
|
$householdIncome = (float) ($request['household_income'] ?? 0);
|
||||||
|
$requestedAmount = (float) ($request['requested_amount'] ?? 0);
|
||||||
|
$estimateModel = new FinancialAidEstimateModel();
|
||||||
|
$estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSize, $householdIncome);
|
||||||
|
$finalRebate = $estimateModel->finalRebate($invoiceBalance, $householdSize, $householdIncome, $requestedAmount);
|
||||||
|
|
||||||
|
return view('administrator/financial_aid_review', [
|
||||||
|
'requestRow' => $request,
|
||||||
|
'parent' => $parent,
|
||||||
|
'students' => $students,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'invoiceBalance' => $invoiceBalance,
|
||||||
|
'estimatedAid' => $estimatedAid,
|
||||||
|
'finalRebate' => $finalRebate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estimate(int $id)
|
||||||
|
{
|
||||||
|
$request = (new FinancialAidRequestModel())->find($id);
|
||||||
|
if ($request === null) {
|
||||||
|
return $this->response->setStatusCode(404)->setJSON(['error' => 'Financial aid request was not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$householdIncomeRaw = trim((string) $this->request->getPost('household_income'));
|
||||||
|
$householdSizeRaw = (int) $this->request->getPost('household_size');
|
||||||
|
$schoolYear = (string) ($request['school_year'] ?? '');
|
||||||
|
$invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear);
|
||||||
|
|
||||||
|
if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) {
|
||||||
|
return $this->response->setStatusCode(422)->setJSON(['error' => 'Enter a valid household income.']);
|
||||||
|
}
|
||||||
|
if ($householdSizeRaw < 1) {
|
||||||
|
return $this->response->setStatusCode(422)->setJSON(['error' => 'Household size must be at least 1.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$estimateModel = new FinancialAidEstimateModel();
|
||||||
|
$householdIncome = round((float) $householdIncomeRaw, 2);
|
||||||
|
$estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSizeRaw, $householdIncome);
|
||||||
|
$finalRebate = $estimateModel->finalRebate(
|
||||||
|
$invoiceBalance,
|
||||||
|
$householdSizeRaw,
|
||||||
|
$householdIncome,
|
||||||
|
(float) ($request['requested_amount'] ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'invoice_balance' => $invoiceBalance,
|
||||||
|
'estimated_aid' => $estimatedAid,
|
||||||
|
'final_rebate' => $finalRebate,
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
return $this->response->setStatusCode(422)->setJSON(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = new FinancialAidRequestModel();
|
||||||
|
$request = $model->find($id);
|
||||||
|
if ($request === null) {
|
||||||
|
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
|
||||||
|
}
|
||||||
|
if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) {
|
||||||
|
return redirect()->back()->with('error', 'Only open requests can be approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $this->approvalAmount($request);
|
||||||
|
$note = trim((string) $this->request->getPost('admin_note'));
|
||||||
|
service('financialAid')->applyApprovedAmount($request, $amount, (int) session()->get('user_id'), $note);
|
||||||
|
|
||||||
|
return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid was approved and applied to the invoice.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deny(int $id)
|
||||||
|
{
|
||||||
|
$model = new FinancialAidRequestModel();
|
||||||
|
$request = $model->find($id);
|
||||||
|
if ($request === null) {
|
||||||
|
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
|
||||||
|
}
|
||||||
|
if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) {
|
||||||
|
return redirect()->back()->with('error', 'Only open requests can be denied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$note = trim((string) $this->request->getPost('admin_note'));
|
||||||
|
if ($note === '') {
|
||||||
|
return redirect()->back()->with('error', 'A denial note is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->update($id, [
|
||||||
|
'status' => 'denied',
|
||||||
|
'admin_note' => $note,
|
||||||
|
'reviewed_by' => (int) session()->get('user_id'),
|
||||||
|
'reviewed_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid request was denied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function approvalAmount(array $request): float
|
||||||
|
{
|
||||||
|
$postedAmount = trim((string) $this->request->getPost('admin_amount'));
|
||||||
|
if ($postedAmount !== '') {
|
||||||
|
return (float) $postedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($request['requested_amount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice = (new InvoiceModel())
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($invoice === null) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round((float) ($invoice['balance'] ?? 0), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Administrator;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\SchoolYearModel;
|
||||||
|
use App\Support\SchoolYear\SchoolYearStatus;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SchoolYearClosingController extends BaseController
|
||||||
|
{
|
||||||
|
public function preview(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$targetId = $this->normalizeInt($this->request->getGet('target_school_year_id'));
|
||||||
|
$source = (new SchoolYearModel())->find($id);
|
||||||
|
if ($targetId === null && (string) ($source['status'] ?? '') === SchoolYearStatus::ACTIVE) {
|
||||||
|
$target = service('schoolYearManagement')->ensureNextDraftForClosing($id, $this->userId());
|
||||||
|
$targetId = (int) ($target['id'] ?? 0) ?: null;
|
||||||
|
}
|
||||||
|
$preview = service('schoolYearClosing')->preview($id, $targetId);
|
||||||
|
$promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []);
|
||||||
|
$carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []);
|
||||||
|
$latestBatch = service('schoolYearClosing')->latestBatch($id);
|
||||||
|
|
||||||
|
return view('school_years/closing_preview', [
|
||||||
|
'preview' => $preview,
|
||||||
|
'promotionTable' => $promotionTable,
|
||||||
|
'carryForwardTable' => $carryForwardTable,
|
||||||
|
'latestBatch' => $latestBatch,
|
||||||
|
'missingCarryForwardInvoices' => $this->missingCarryForwardInvoiceCount($latestBatch),
|
||||||
|
'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewByName(string $name)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$year = (new SchoolYearModel())
|
||||||
|
->where('name', rawurldecode($name))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($year === null) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', 'School year was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->preview((int) $year['id']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$targetId = $this->normalizeInt($this->request->getPost('target_school_year_id'));
|
||||||
|
if ($targetId === null) {
|
||||||
|
return redirect()->back()->with('error', 'Select a target school year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
service('schoolYearClosing')->start($id, $targetId, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'End-year process started.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearClosing')->execute($id, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'Carry-forward confirmed.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearClosing')->complete($id, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'School year ended and closed.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearClosing')->cancel($id, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'End-year process cancelled.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeInt(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function missingCarryForwardInvoiceCount(?array $batch): int
|
||||||
|
{
|
||||||
|
if ($batch === null || ! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->tableExists('school_year_closing_items')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $db->table('school_year_closing_items')
|
||||||
|
->where('closing_batch_id', (int) $batch['id'])
|
||||||
|
->groupStart()
|
||||||
|
->where('target_invoice_id', null)
|
||||||
|
->orWhere('target_invoice_id', 0)
|
||||||
|
->groupEnd()
|
||||||
|
->countAllResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function promotionTablePayload(array $rows): array
|
||||||
|
{
|
||||||
|
$allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status'];
|
||||||
|
$sort = 'class';
|
||||||
|
$order = 'asc';
|
||||||
|
$perPage = 25;
|
||||||
|
$allowedPerPage = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
usort($rows, function (array $a, array $b) use ($sort, $order): int {
|
||||||
|
$comparison = $this->comparePromotionRows($a, $b, $sort);
|
||||||
|
if ($comparison === 0 && $sort !== 'class') {
|
||||||
|
$comparison = $this->comparePromotionRows($a, $b, 'class');
|
||||||
|
}
|
||||||
|
if ($comparison === 0 && $sort !== 'student') {
|
||||||
|
$comparison = $this->comparePromotionRows($a, $b, 'student');
|
||||||
|
}
|
||||||
|
if ($comparison === 0) {
|
||||||
|
$comparison = ((int) ($a['student_id'] ?? 0)) <=> ((int) ($b['student_id'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $order === 'desc' ? -$comparison : $comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
$total = count($rows);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'sort' => $sort,
|
||||||
|
'order' => $order,
|
||||||
|
'page' => 1,
|
||||||
|
'perPage' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'pageCount' => 1,
|
||||||
|
'allowedPerPage' => $allowedPerPage,
|
||||||
|
'from' => $total === 0 ? 0 : 1,
|
||||||
|
'to' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function comparePromotionRows(array $a, array $b, string $sort): int
|
||||||
|
{
|
||||||
|
$aValue = $this->promotionSortValue($a, $sort);
|
||||||
|
$bValue = $this->promotionSortValue($b, $sort);
|
||||||
|
|
||||||
|
if ($sort === 'year_score') {
|
||||||
|
if ($aValue === null && $bValue === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ($aValue === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($bValue === null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $aValue <=> $bValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strnatcasecmp((string) $aValue, (string) $bValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function promotionSortValue(array $row, string $sort): mixed
|
||||||
|
{
|
||||||
|
return match ($sort) {
|
||||||
|
'student' => (string) ($row['student_name'] ?? ''),
|
||||||
|
'school_id' => (string) ($row['school_id'] ?? ''),
|
||||||
|
'class' => (string) ($row['class_section_name'] ?? ''),
|
||||||
|
'year_score' => is_numeric($row['year_score'] ?? null) ? (float) $row['year_score'] : null,
|
||||||
|
'decision' => (string) ($row['decision'] ?? ''),
|
||||||
|
'source' => (string) ($row['source'] ?? ''),
|
||||||
|
'queue' => (string) ($row['queue_status'] ?? ''),
|
||||||
|
'target' => trim((string) ($row['target_section'] ?? '') . ' ' . (string) ($row['target_class'] ?? '')),
|
||||||
|
'status' => (string) ($row['status'] ?? ''),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function carryForwardTablePayload(array $rows): array
|
||||||
|
{
|
||||||
|
$allowedSorts = ['family', 'parent', 'source_balance', 'credits', 'adjustments', 'net'];
|
||||||
|
$sort = (string) ($this->request->getGet('cf_sort') ?? 'family');
|
||||||
|
$sort = in_array($sort, $allowedSorts, true) ? $sort : 'family';
|
||||||
|
$order = strtolower((string) ($this->request->getGet('cf_order') ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
usort($rows, function (array $a, array $b) use ($sort, $order): int {
|
||||||
|
$comparison = $this->compareCarryForwardRows($a, $b, $sort);
|
||||||
|
if ($comparison === 0 && $sort !== 'family') {
|
||||||
|
$comparison = $this->compareCarryForwardRows($a, $b, 'family');
|
||||||
|
}
|
||||||
|
if ($comparison === 0) {
|
||||||
|
$comparison = ((int) ($a['family_id'] ?? 0)) <=> ((int) ($b['family_id'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $order === 'desc' ? -$comparison : $comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'sort' => $sort,
|
||||||
|
'order' => $order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareCarryForwardRows(array $a, array $b, string $sort): int
|
||||||
|
{
|
||||||
|
$numericSorts = ['source_balance', 'credits', 'adjustments', 'net'];
|
||||||
|
$aValue = $this->carryForwardSortValue($a, $sort);
|
||||||
|
$bValue = $this->carryForwardSortValue($b, $sort);
|
||||||
|
|
||||||
|
if (in_array($sort, $numericSorts, true)) {
|
||||||
|
return (float) $aValue <=> (float) $bValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strnatcasecmp((string) $aValue, (string) $bValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function carryForwardSortValue(array $row, string $sort): mixed
|
||||||
|
{
|
||||||
|
return match ($sort) {
|
||||||
|
'family' => (string) ($row['family'] ?? ''),
|
||||||
|
'parent' => trim((string) ($row['parent'] ?? '') . ' ' . (string) ($row['parent_email'] ?? '')),
|
||||||
|
'source_balance' => (float) ($row['source_balance'] ?? 0),
|
||||||
|
'credits' => (float) ($row['credit_amount'] ?? 0),
|
||||||
|
'adjustments' => (float) ($row['adjustment_amount'] ?? 0),
|
||||||
|
'net' => (float) ($row['carry_forward_amount'] ?? 0),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userId(): ?int
|
||||||
|
{
|
||||||
|
$id = session('user_id') ?? session('id');
|
||||||
|
|
||||||
|
return is_numeric($id) ? (int) $id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Administrator;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Models\SchoolYearModel;
|
||||||
|
use App\Support\SchoolYear\SchoolYearStatus;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SchoolYearController extends BaseController
|
||||||
|
{
|
||||||
|
private SchoolYearModel $schoolYearModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->schoolYearModel = new SchoolYearModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$schoolYears = $this->schoolYearModel
|
||||||
|
->orderBy('name', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
$activeYear = null;
|
||||||
|
$nextDraftYear = null;
|
||||||
|
$closingYear = null;
|
||||||
|
$archivedCount = 0;
|
||||||
|
|
||||||
|
foreach ($schoolYears as $year) {
|
||||||
|
$status = (string) ($year['status'] ?? '');
|
||||||
|
if ($status === SchoolYearStatus::ACTIVE && $activeYear === null) {
|
||||||
|
$activeYear = $year;
|
||||||
|
}
|
||||||
|
if ($status === SchoolYearStatus::DRAFT && $nextDraftYear === null) {
|
||||||
|
$nextDraftYear = $year;
|
||||||
|
}
|
||||||
|
if ($status === SchoolYearStatus::CLOSING && $closingYear === null) {
|
||||||
|
$closingYear = $year;
|
||||||
|
}
|
||||||
|
if ($status === SchoolYearStatus::ARCHIVED) {
|
||||||
|
$archivedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$configurationModel = new ConfigurationModel();
|
||||||
|
|
||||||
|
return view('school_years/index', [
|
||||||
|
'schoolYears' => $schoolYears,
|
||||||
|
'statuses' => SchoolYearStatus::ALL,
|
||||||
|
'activeYear' => $activeYear,
|
||||||
|
'nextDraftYear' => $nextDraftYear,
|
||||||
|
'nextDraftDefaults' => service('schoolYearManagement')->nextDraftDefaults(),
|
||||||
|
'closingYear' => $closingYear,
|
||||||
|
'archivedCount' => $archivedCount,
|
||||||
|
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
|
||||||
|
'yearVerification' => $this->verificationByYear($schoolYears),
|
||||||
|
'schoolYearNamesById' => array_column($schoolYears, 'name', 'id'),
|
||||||
|
'totalInstructionalWeeks' => $configurationModel->getConfigValueByKey('total_instructional_weeks'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearManagement')->createDraft($this->request->getPost(), $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'Draft school year created.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearManagement')->updateMetadata($id, $this->request->getPost(), $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'School year metadata updated.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activate(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($this->request->getPost('confirm_activation') !== '1') {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', 'Activation confirmation is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
service('schoolYearManagement')->activate($id, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'School year activated. Any previous active year is now in closing.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteDraft(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearManagement')->deleteDraft($id, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'Draft school year deleted.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archive(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearManagement')->archive($id, $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'School year archived.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopen(int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('schoolYearManagement')->reopen($id, (string) $this->request->getPost('reason'), $this->userId());
|
||||||
|
return redirect()->to('/administrator/school-years')->with('success', 'School year reopened.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->to('/administrator/school-years')->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userId(): ?int
|
||||||
|
{
|
||||||
|
$id = session('user_id') ?? session('id');
|
||||||
|
|
||||||
|
return is_numeric($id) ? (int) $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verificationByYear(array $schoolYears): array
|
||||||
|
{
|
||||||
|
$db = db_connect();
|
||||||
|
$verification = [];
|
||||||
|
|
||||||
|
$hasStudentClass = $db->tableExists('student_class');
|
||||||
|
$hasStudentDecisions = $db->tableExists('student_decisions');
|
||||||
|
$hasPromotionQueue = $db->tableExists('promotion_queue');
|
||||||
|
$hasInvoices = $db->tableExists('invoices');
|
||||||
|
$hasClosingBatches = $db->tableExists('school_year_closing_batches');
|
||||||
|
|
||||||
|
foreach ($schoolYears as $year) {
|
||||||
|
$id = (int) ($year['id'] ?? 0);
|
||||||
|
$name = (string) ($year['name'] ?? '');
|
||||||
|
if ($id <= 0 || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'students' => 0,
|
||||||
|
'decisions' => 0,
|
||||||
|
'promoted' => 0,
|
||||||
|
'queued' => 0,
|
||||||
|
'carry_forward_balance' => 0.0,
|
||||||
|
'positive_balance' => 0.0,
|
||||||
|
'credit_balance' => 0.0,
|
||||||
|
'closing_status' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($hasStudentClass && $db->fieldExists('school_year', 'student_class')) {
|
||||||
|
$row = $db->table('student_class')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||||
|
->where('school_year', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['students'] = (int) ($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasStudentDecisions && $db->fieldExists('school_year', 'student_decisions')) {
|
||||||
|
$row = $db->table('student_decisions')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS decisions', false)
|
||||||
|
->select("COUNT(DISTINCT CASE WHEN LOWER(decision) = 'pass' THEN student_id END) AS promoted", false)
|
||||||
|
->where('school_year', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['decisions'] = (int) ($row['decisions'] ?? 0);
|
||||||
|
$summary['promoted'] = (int) ($row['promoted'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasPromotionQueue && $db->fieldExists('school_year_from', 'promotion_queue')) {
|
||||||
|
$row = $db->table('promotion_queue')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||||
|
->where('school_year_from', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['queued'] = (int) ($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasInvoices && $db->fieldExists('school_year', 'invoices')) {
|
||||||
|
$row = $db->table('invoices')
|
||||||
|
->select('COALESCE(SUM(balance), 0) AS carry_forward_balance')
|
||||||
|
->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false)
|
||||||
|
->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false)
|
||||||
|
->where('school_year', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['carry_forward_balance'] = round((float) ($row['carry_forward_balance'] ?? 0), 2);
|
||||||
|
$summary['positive_balance'] = round((float) ($row['positive_balance'] ?? 0), 2);
|
||||||
|
$summary['credit_balance'] = round((float) ($row['credit_balance'] ?? 0), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasClosingBatches) {
|
||||||
|
$batch = $db->table('school_year_closing_batches')
|
||||||
|
->select('status')
|
||||||
|
->where('source_school_year_id', $id)
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
$summary['closing_status'] = (string) ($batch['status'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$verification[$id] = $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $verification;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Administrator;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class WithdrawalFinancialController extends BaseController
|
||||||
|
{
|
||||||
|
public function review(int $enrollmentId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$calculation = service('withdrawalFinancial')->latestForEnrollment($enrollmentId);
|
||||||
|
if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') {
|
||||||
|
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId());
|
||||||
|
} elseif ($this->hasStaleInstructionalWeeksBlocker($calculation)) {
|
||||||
|
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('withdrawals/review', [
|
||||||
|
'calculation' => $this->withPostingGateBlockers($calculation),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recalculate(int $enrollmentId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId(), [
|
||||||
|
'enrollment_date' => (string) $this->request->getPost('enrollment_date'),
|
||||||
|
'withdrawal_request_date' => (string) $this->request->getPost('withdrawal_request_date'),
|
||||||
|
'override_reason' => (string) $this->request->getPost('override_reason'),
|
||||||
|
'legacy_invoice_confirmed' => (string) $this->request->getPost('legacy_invoice_confirmed') === '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->to('/administrator/withdrawals/' . $enrollmentId . '/review')
|
||||||
|
->with('success', 'Calculation preview version ' . (int) $calculation['version'] . ' saved.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirm(int $calculationId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$calculation = service('withdrawalFinancial')->post($calculationId, $this->userId());
|
||||||
|
|
||||||
|
return redirect()->to('/administrator/withdrawal-calculations/' . $calculationId)
|
||||||
|
->with('success', ((int) ($calculation['new_refund_request_cents'] ?? 0)) > 0
|
||||||
|
? 'Withdrawal posted and the refund request was created.'
|
||||||
|
: 'Withdrawal posted. No refund is due; the invoice balance was recalculated.');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculation(int $calculationId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$calculation = service('withdrawalFinancial')->details($calculationId);
|
||||||
|
if ($this->hasStaleInstructionalWeeksBlocker($calculation)) {
|
||||||
|
$calculation = service('withdrawalFinancial')->preview((int) $calculation['enrollment_id'], $this->userId());
|
||||||
|
|
||||||
|
return redirect()->to('/administrator/withdrawal-calculations/' . (int) $calculation['id'])
|
||||||
|
->with('success', 'Calculation preview refreshed with the configured total instructional weeks.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('withdrawals/calculation', [
|
||||||
|
'calculation' => $calculation,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoiceSummary(int $invoiceId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return view('withdrawals/invoice_summary', [
|
||||||
|
'calculations' => service('withdrawalFinancial')->calculationsForInvoice($invoiceId),
|
||||||
|
'invoiceId' => $invoiceId,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasStaleInstructionalWeeksBlocker(array $calculation): bool
|
||||||
|
{
|
||||||
|
if (($calculation['status'] ?? '') !== 'requires_review' || ! empty($calculation['posted_at'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$blockers = array_map('strval', (array) ($calculation['blockers'] ?? []));
|
||||||
|
|
||||||
|
return in_array('Set a positive total_instructional_weeks value for this school year.', $blockers, true)
|
||||||
|
|| in_array('Set a positive total_instructional_weeks value in configuration.', $blockers, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
private function withPostingGateBlockers(array $calculation): array
|
||||||
|
{
|
||||||
|
$blockers = array_values(array_filter(array_map('strval', (array) ($calculation['blockers'] ?? []))));
|
||||||
|
foreach (service('withdrawalFinancial')->postingGateBlockers($calculation) as $blocker) {
|
||||||
|
if (! in_array($blocker, $blockers, true)) {
|
||||||
|
$blockers[] = $blocker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$calculation['blockers'] = $blockers;
|
||||||
|
if (isset($calculation['explanation']) && is_array($calculation['explanation'])) {
|
||||||
|
$calculation['explanation']['blockers'] = $blockers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $calculation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userId(): ?int
|
||||||
|
{
|
||||||
|
$id = session()->get('user_id');
|
||||||
|
|
||||||
|
return $id === null || $id === '' ? null : (int) $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
|||||||
use App\Models\LoginActivityModel;
|
use App\Models\LoginActivityModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use App\Models\UserRoleModel;
|
use App\Models\UserRoleModel;
|
||||||
|
use App\Models\UserAccessProfileModel;
|
||||||
use CodeIgniter\Events\Events;
|
use CodeIgniter\Events\Events;
|
||||||
use App\Models\IpAttemptModel;
|
use App\Models\IpAttemptModel;
|
||||||
use App\Models\PasswordResetModel;
|
use App\Models\PasswordResetModel;
|
||||||
@@ -38,7 +39,7 @@ class AuthController extends BaseController
|
|||||||
$this->loginActivityModel = new LoginActivityModel();
|
$this->loginActivityModel = new LoginActivityModel();
|
||||||
$this->preferencesModel = new PreferencesModel();
|
$this->preferencesModel = new PreferencesModel();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,13 +212,8 @@ class AuthController extends BaseController
|
|||||||
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
|
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
|
||||||
|
|
||||||
// Fetch roles
|
// Fetch roles
|
||||||
$userRoleModel = new UserRoleModel();
|
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
||||||
$rolesRows = $userRoleModel->select('roles.name')
|
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
|
||||||
->where('user_roles.user_id', $user['id'])
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
$roleNames = array_column($rolesRows, 'name');
|
|
||||||
|
|
||||||
// Build roles map (object with keys per example)
|
// Build roles map (object with keys per example)
|
||||||
$rolesMap = [];
|
$rolesMap = [];
|
||||||
@@ -254,6 +250,40 @@ class AuthController extends BaseController
|
|||||||
'id' => (int) $user['id'],
|
'id' => (int) $user['id'],
|
||||||
'name' => $payload['name'],
|
'name' => $payload['name'],
|
||||||
'roles' => (object) $rolesMap,
|
'roles' => (object) $rolesMap,
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminAuthMe()
|
||||||
|
{
|
||||||
|
if (! session()->get('is_logged_in')) {
|
||||||
|
return $this->response->setStatusCode(401)->setJSON([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Unauthenticated.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) session()->get('user_id');
|
||||||
|
$roles = array_values(array_filter((array) (session()->get('roles') ?? [])));
|
||||||
|
$activeRole = session()->get('role');
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'status' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $userId,
|
||||||
|
'email' => session()->get('user_email'),
|
||||||
|
'name' => session()->get('user_name'),
|
||||||
|
'type' => session()->get('user_type'),
|
||||||
|
'roles' => $roles,
|
||||||
|
'role' => $activeRole,
|
||||||
|
'primary_category' => session()->get('primary_category'),
|
||||||
|
'is_admin' => (bool) session()->get('is_admin'),
|
||||||
|
'is_teacher' => (bool) session()->get('is_teacher'),
|
||||||
|
'is_parent' => (bool) session()->get('is_parent'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -312,7 +342,7 @@ class AuthController extends BaseController
|
|||||||
'state' => $requestData['state'] ?? null,
|
'state' => $requestData['state'] ?? null,
|
||||||
'zip' => $requestData['zip'] ?? null,
|
'zip' => $requestData['zip'] ?? null,
|
||||||
'school_year' => $this->configModel->getConfig('school_year'),
|
'school_year' => $this->configModel->getConfig('school_year'),
|
||||||
'semester' => $this->configModel->getConfig('semester'),
|
'semester' => getSemester(),
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'is_verified' => 0, // Require email verification
|
'is_verified' => 0, // Require email verification
|
||||||
];
|
];
|
||||||
@@ -351,6 +381,7 @@ class AuthController extends BaseController
|
|||||||
'iat' => $now,
|
'iat' => $now,
|
||||||
'exp' => $exp,
|
'exp' => $exp,
|
||||||
];
|
];
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
|
||||||
|
|
||||||
$secret = require_env('JWT_SECRET');
|
$secret = require_env('JWT_SECRET');
|
||||||
$token = jwt_encode($payload, $secret, 'HS256');
|
$token = jwt_encode($payload, $secret, 'HS256');
|
||||||
@@ -364,6 +395,10 @@ class AuthController extends BaseController
|
|||||||
'name' => $payload['name'],
|
'name' => $payload['name'],
|
||||||
'email' => $userData['email'],
|
'email' => $userData['email'],
|
||||||
'roles' => $payload['roles'],
|
'roles' => $payload['roles'],
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -454,6 +489,24 @@ class AuthController extends BaseController
|
|||||||
$this->userModel->update($userId, ['failed_attempts' => 0, 'last_failed_at' => null]);
|
$this->userModel->update($userId, ['failed_attempts' => 0, 'last_failed_at' => null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getUserRoleNames(int $userId): array
|
||||||
|
{
|
||||||
|
$userRoleModel = new UserRoleModel();
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$builder = $userRoleModel->select('roles.name')
|
||||||
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
|
->where('user_roles.user_id', $userId)
|
||||||
|
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||||
|
|
||||||
|
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$builder->where('user_roles.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rolesRows = $builder->get()->getResultArray();
|
||||||
|
|
||||||
|
return array_column($rolesRows, 'name');
|
||||||
|
}
|
||||||
|
|
||||||
public function logout()
|
public function logout()
|
||||||
{
|
{
|
||||||
// Get user ID and email from session
|
// Get user ID and email from session
|
||||||
@@ -533,11 +586,17 @@ class AuthController extends BaseController
|
|||||||
private function loginUser($user, ?string $redirectTo = null)
|
private function loginUser($user, ?string $redirectTo = null)
|
||||||
{
|
{
|
||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
$roles = $userRoleModel->select('roles.name')
|
$db = \Config\Database::connect();
|
||||||
|
$rolesBuilder = $userRoleModel->select('roles.name')
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
->where('user_roles.user_id', $user['id'])
|
->where('user_roles.user_id', $user['id'])
|
||||||
->get()
|
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||||
->getResultArray();
|
|
||||||
|
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$rolesBuilder->where('user_roles.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $rolesBuilder->get()->getResultArray();
|
||||||
|
|
||||||
if (empty($roles)) {
|
if (empty($roles)) {
|
||||||
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
||||||
@@ -545,7 +604,9 @@ class AuthController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$roleNames = array_column($roles, 'name');
|
$roleNames = array_column($roles, 'name');
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||||
|
|
||||||
|
session()->regenerate(true);
|
||||||
session()->set([
|
session()->set([
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'user_email' => $user['email'],
|
'user_email' => $user['email'],
|
||||||
@@ -553,13 +614,16 @@ class AuthController extends BaseController
|
|||||||
'user_type' => $user['user_type'],
|
'user_type' => $user['user_type'],
|
||||||
'is_logged_in' => true,
|
'is_logged_in' => true,
|
||||||
'login_time' => time(),
|
'login_time' => time(),
|
||||||
|
'last_activity' => time(),
|
||||||
'roles' => $roleNames,
|
'roles' => $roleNames,
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
'semester' => $this->semester,
|
'semester' => $this->semester,
|
||||||
'school_year' => $this->schoolYear,
|
'school_year' => $this->schoolYear,
|
||||||
]);
|
]);
|
||||||
$this->applyStylePreferences((int) $user['id']);
|
$this->applyStylePreferences((int) $user['id']);
|
||||||
log_message('debug', 'Session after login: ' . print_r(session()->get(), true));
|
|
||||||
//dd('Login successful. Roles:', $roleNames, session()->get());
|
|
||||||
|
|
||||||
if (count($roleNames) === 1) {
|
if (count($roleNames) === 1) {
|
||||||
// One role → set and redirect directly
|
// One role → set and redirect directly
|
||||||
@@ -580,6 +644,32 @@ class AuthController extends BaseController
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function accessProfileForUser(int $userId, array $roleNames): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
|
||||||
|
if ($profile !== null) {
|
||||||
|
return [
|
||||||
|
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
|
||||||
|
'is_admin' => (bool) ($profile['is_admin'] ?? false),
|
||||||
|
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
|
||||||
|
'is_parent' => (bool) ($profile['is_parent'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
|
||||||
|
'is_admin' => (bool) $flags['is_admin'],
|
||||||
|
'is_teacher' => (bool) $flags['is_teacher'],
|
||||||
|
'is_parent' => (bool) $flags['is_parent'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function applyStylePreferences(int $userId): void
|
private function applyStylePreferences(int $userId): void
|
||||||
{
|
{
|
||||||
if (!$userId) {
|
if (!$userId) {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ use CodeIgniter\HTTP\RequestInterface;
|
|||||||
use CodeIgniter\HTTP\ResponseInterface;
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use App\Services\ApiClient;
|
use App\Services\ApiClient;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||||
|
use App\Support\SchoolYear\SchoolYearContext;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class BaseController
|
* Class BaseController
|
||||||
@@ -58,6 +61,8 @@ abstract class BaseController extends Controller
|
|||||||
// Preload any models, libraries, etc, here.
|
// Preload any models, libraries, etc, here.
|
||||||
// E.g.: $this->session = \Config\Services::session();
|
// E.g.: $this->session = \Config\Services::session();
|
||||||
session();
|
session();
|
||||||
|
$this->syncSchoolYearPropertyFromContext();
|
||||||
|
$this->injectSchoolYearViewData();
|
||||||
|
|
||||||
// Shared API client available to all controllers
|
// Shared API client available to all controllers
|
||||||
$this->api = service('apiClient');
|
$this->api = service('apiClient');
|
||||||
@@ -88,6 +93,139 @@ abstract class BaseController extends Controller
|
|||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveSchoolYearContext(?int $routeSchoolYearId = null): SchoolYearContext
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->resolve($this->request, $routeSchoolYearId);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
log_message('warning', 'School-year resolution failed: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function currentSchoolYearName(?string $fallback = null): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->resolveSchoolYearContext()->yearName();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($fallback !== null && $fallback !== '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
|
||||||
|
} catch (Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertSchoolYearWritable(
|
||||||
|
SchoolYearContext $context,
|
||||||
|
bool $allowDraftForAdmin = false,
|
||||||
|
bool $isAdmin = false
|
||||||
|
): void {
|
||||||
|
service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertSchoolYearNameWritable(
|
||||||
|
string $schoolYear,
|
||||||
|
bool $allowDraftForAdmin = false,
|
||||||
|
bool $isAdmin = false
|
||||||
|
): void {
|
||||||
|
$context = service('schoolYearContext')->forYearName($schoolYear);
|
||||||
|
|
||||||
|
$this->assertSchoolYearWritable($context, $allowDraftForAdmin, $isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncSchoolYearPropertyFromContext(): void
|
||||||
|
{
|
||||||
|
if (! property_exists($this, 'schoolYear')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->request instanceof IncomingRequest || is_cli()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! session()->get('user_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to sync controller school-year property: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function injectSchoolYearViewData(): void
|
||||||
|
{
|
||||||
|
if (! config('Feature')->globalSchoolYearSelector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->request instanceof IncomingRequest || is_cli()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! session()->get('user_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->shouldShowSchoolYearSelector()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accept = strtolower($this->request->getHeaderLine('Accept'));
|
||||||
|
if ($this->request->isAJAX() || str_contains($accept, 'application/json')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request));
|
||||||
|
} catch (SchoolYearConfigurationException $e) {
|
||||||
|
log_message('warning', 'Unable to inject school-year view data: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to inject school-year view data: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldShowSchoolYearSelector(): bool
|
||||||
|
{
|
||||||
|
$roles = (array) session()->get('roles');
|
||||||
|
$singleRole = session()->get('role');
|
||||||
|
if ($singleRole !== null && $singleRole !== '') {
|
||||||
|
$roles[] = $singleRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = array_map(
|
||||||
|
static fn ($role): string => strtolower(trim((string) $role)),
|
||||||
|
$roles
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) array_intersect($roles, [
|
||||||
|
'admin',
|
||||||
|
'administrator',
|
||||||
|
'administrative staff',
|
||||||
|
'parent',
|
||||||
|
'principal',
|
||||||
|
'teacher',
|
||||||
|
'teacher assistant',
|
||||||
|
'teacher_assistant',
|
||||||
|
'vice principal',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class ClassProgressController extends BaseController
|
|||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
|
$context = $this->resolveSchoolYearContext();
|
||||||
$teacherId = (int) session()->get('user_id');
|
$teacherId = (int) session()->get('user_id');
|
||||||
$assignments = $this->loadTeacherSections($teacherId);
|
$assignments = $this->loadTeacherSections($teacherId);
|
||||||
$first = $assignments[0] ?? null;
|
$first = $assignments[0] ?? null;
|
||||||
@@ -76,12 +77,15 @@ class ClassProgressController extends BaseController
|
|||||||
'classId' => $classId,
|
'classId' => $classId,
|
||||||
'sundayOptions' => $sundayOptions,
|
'sundayOptions' => $sundayOptions,
|
||||||
'defaultWeekStart' => $defaultWeekStart,
|
'defaultWeekStart' => $defaultWeekStart,
|
||||||
|
'isSchoolYearReadonly' => $context->isReadonly(),
|
||||||
];
|
];
|
||||||
return view('teacher/class_progress_submit', $data);
|
return view('teacher/class_progress_submit', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
|
[, $schoolYear] = $this->resolveCurrentTerm();
|
||||||
$subjectSections = self::SUBJECT_SECTIONS;
|
$subjectSections = self::SUBJECT_SECTIONS;
|
||||||
$rules = [
|
$rules = [
|
||||||
'class_section_id' => 'required|integer',
|
'class_section_id' => 'required|integer',
|
||||||
@@ -132,8 +136,9 @@ class ClassProgressController extends BaseController
|
|||||||
->select('id')
|
->select('id')
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
->where('week_start', $weekStart)
|
->where('week_start', $weekStart)
|
||||||
->where('teacher_id', $teacherId)
|
->where('teacher_id', $teacherId);
|
||||||
->findAll();
|
$this->applyProgressSchoolYearScope($existingReports, $schoolYear);
|
||||||
|
$existingReports = $existingReports->findAll();
|
||||||
|
|
||||||
if (! $confirmOverwrite && ! empty($existingReports)) {
|
if (! $confirmOverwrite && ! empty($existingReports)) {
|
||||||
return redirect()->back()
|
return redirect()->back()
|
||||||
@@ -175,7 +180,7 @@ class ClassProgressController extends BaseController
|
|||||||
'homework' => $homework ?: null,
|
'homework' => $homework ?: null,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'flags_json' => $this->normalizeFlags($this->request->getPost('flags')),
|
'flags_json' => $this->normalizeFlags($this->request->getPost('flags')),
|
||||||
];
|
] + $this->progressReportSchoolYearData($schoolYear);
|
||||||
|
|
||||||
$reportId = $this->reportModel->insert($data, true);
|
$reportId = $this->reportModel->insert($data, true);
|
||||||
$attachmentField = "attachment_$slug";
|
$attachmentField = "attachment_$slug";
|
||||||
@@ -198,6 +203,7 @@ class ClassProgressController extends BaseController
|
|||||||
public function history()
|
public function history()
|
||||||
{
|
{
|
||||||
$teacherId = (int) session()->get('user_id');
|
$teacherId = (int) session()->get('user_id');
|
||||||
|
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
||||||
$assignments = $this->loadTeacherSections($teacherId);
|
$assignments = $this->loadTeacherSections($teacherId);
|
||||||
$selectedSectionId = (int) $this->request->getGet('class_section_id');
|
$selectedSectionId = (int) $this->request->getGet('class_section_id');
|
||||||
$validSectionIds = array_column($assignments, 'class_section_id');
|
$validSectionIds = array_column($assignments, 'class_section_id');
|
||||||
@@ -207,7 +213,6 @@ class ClassProgressController extends BaseController
|
|||||||
if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) {
|
if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) {
|
||||||
$selectedSectionId = $validSectionIds[0] ?? null;
|
$selectedSectionId = $validSectionIds[0] ?? null;
|
||||||
}
|
}
|
||||||
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
|
||||||
$allowedTeacherIds = $this->resolveAssignedTeacherIds($selectedSectionId, $semester, $schoolYear);
|
$allowedTeacherIds = $this->resolveAssignedTeacherIds($selectedSectionId, $semester, $schoolYear);
|
||||||
if (empty($allowedTeacherIds)) {
|
if (empty($allowedTeacherIds)) {
|
||||||
$allowedTeacherIds = [$teacherId];
|
$allowedTeacherIds = [$teacherId];
|
||||||
@@ -217,6 +222,7 @@ class ClassProgressController extends BaseController
|
|||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
||||||
->whereIn('teacher_id', $allowedTeacherIds);
|
->whereIn('teacher_id', $allowedTeacherIds);
|
||||||
|
$this->applyProgressSchoolYearScope($builder, $schoolYear, true);
|
||||||
if ($selectedSectionId) {
|
if ($selectedSectionId) {
|
||||||
$builder->where('class_progress_reports.class_section_id', $selectedSectionId);
|
$builder->where('class_progress_reports.class_section_id', $selectedSectionId);
|
||||||
}
|
}
|
||||||
@@ -251,24 +257,27 @@ class ClassProgressController extends BaseController
|
|||||||
'subjectSections' => self::SUBJECT_SECTIONS,
|
'subjectSections' => self::SUBJECT_SECTIONS,
|
||||||
'classSectionOptions' => $sectionOptions,
|
'classSectionOptions' => $sectionOptions,
|
||||||
'selectedSectionId' => $selectedSectionId,
|
'selectedSectionId' => $selectedSectionId,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'isSchoolYearReadonly' => $this->resolveSchoolYearContext()->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view($id)
|
public function view($id)
|
||||||
{
|
{
|
||||||
$teacherId = (int) session()->get('user_id');
|
$teacherId = (int) session()->get('user_id');
|
||||||
$row = $this->reportModel
|
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
||||||
|
$rowQuery = $this->reportModel
|
||||||
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
|
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
|
||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
||||||
->where('class_progress_reports.id', (int) $id)
|
->where('class_progress_reports.id', (int) $id);
|
||||||
->first();
|
$this->applyProgressSchoolYearScope($rowQuery, $schoolYear, true);
|
||||||
|
$row = $rowQuery->first();
|
||||||
|
|
||||||
if (! $row) {
|
if (! $row) {
|
||||||
throw new PageNotFoundException('Progress report not found.');
|
throw new PageNotFoundException('Progress report not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
|
||||||
$allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
|
$allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
|
||||||
if (empty($allowedTeacherIds)) {
|
if (empty($allowedTeacherIds)) {
|
||||||
if ($teacherId !== (int) $row['teacher_id']) {
|
if ($teacherId !== (int) $row['teacher_id']) {
|
||||||
@@ -280,13 +289,15 @@ class ClassProgressController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
|
$row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
|
||||||
$weeklyReports = $this->reportModel
|
$weeklyReportsQuery = $this->reportModel
|
||||||
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
|
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
|
||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
||||||
->whereIn('teacher_id', $allowedTeacherIds)
|
->whereIn('teacher_id', $allowedTeacherIds)
|
||||||
->where('class_progress_reports.class_section_id', $row['class_section_id'])
|
->where('class_progress_reports.class_section_id', $row['class_section_id'])
|
||||||
->where('week_start', $row['week_start'])
|
->where('week_start', $row['week_start']);
|
||||||
|
$this->applyProgressSchoolYearScope($weeklyReportsQuery, $schoolYear);
|
||||||
|
$weeklyReports = $weeklyReportsQuery
|
||||||
->orderBy('subject', 'ASC')
|
->orderBy('subject', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
@@ -313,17 +324,18 @@ class ClassProgressController extends BaseController
|
|||||||
public function edit($id)
|
public function edit($id)
|
||||||
{
|
{
|
||||||
$teacherId = (int) session()->get('user_id');
|
$teacherId = (int) session()->get('user_id');
|
||||||
$row = $this->reportModel
|
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
||||||
|
$rowQuery = $this->reportModel
|
||||||
->select('class_progress_reports.*, cs.class_section_name')
|
->select('class_progress_reports.*, cs.class_section_name')
|
||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->where('class_progress_reports.id', (int) $id)
|
->where('class_progress_reports.id', (int) $id);
|
||||||
->first();
|
$this->applyProgressSchoolYearScope($rowQuery, $schoolYear);
|
||||||
|
$row = $rowQuery->first();
|
||||||
|
|
||||||
if (! $row) {
|
if (! $row) {
|
||||||
throw new PageNotFoundException('Progress report not found.');
|
throw new PageNotFoundException('Progress report not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
|
||||||
$allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
|
$allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
|
||||||
if (empty($allowedTeacherIds)) {
|
if (empty($allowedTeacherIds)) {
|
||||||
if ($teacherId !== (int) $row['teacher_id']) {
|
if ($teacherId !== (int) $row['teacher_id']) {
|
||||||
@@ -334,11 +346,13 @@ class ClassProgressController extends BaseController
|
|||||||
throw new PageNotFoundException('Progress report not found.');
|
throw new PageNotFoundException('Progress report not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyReports = $this->reportModel
|
$weeklyReportsQuery = $this->reportModel
|
||||||
->select('class_progress_reports.*')
|
->select('class_progress_reports.*')
|
||||||
->whereIn('teacher_id', $allowedTeacherIds)
|
->whereIn('teacher_id', $allowedTeacherIds)
|
||||||
->where('class_section_id', $row['class_section_id'])
|
->where('class_section_id', $row['class_section_id'])
|
||||||
->where('week_start', $row['week_start'])
|
->where('week_start', $row['week_start']);
|
||||||
|
$this->applyProgressSchoolYearScope($weeklyReportsQuery, $schoolYear);
|
||||||
|
$weeklyReports = $weeklyReportsQuery
|
||||||
->orderBy('subject', 'ASC')
|
->orderBy('subject', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
@@ -405,18 +419,23 @@ class ClassProgressController extends BaseController
|
|||||||
'isEdit' => true,
|
'isEdit' => true,
|
||||||
'formAction' => base_url('teacher/progress/update/' . (int) $row['id']),
|
'formAction' => base_url('teacher/progress/update/' . (int) $row['id']),
|
||||||
'submitLabel' => 'Update Progress',
|
'submitLabel' => 'Update Progress',
|
||||||
|
'isSchoolYearReadonly' => $this->resolveSchoolYearContext()->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id)
|
public function update($id)
|
||||||
{
|
{
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
$teacherId = (int) session()->get('user_id');
|
$teacherId = (int) session()->get('user_id');
|
||||||
$row = $this->reportModel->find((int) $id);
|
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
||||||
|
$rowQuery = $this->reportModel
|
||||||
|
->where('class_progress_reports.id', (int) $id);
|
||||||
|
$this->applyProgressSchoolYearScope($rowQuery, $schoolYear);
|
||||||
|
$row = $rowQuery->first();
|
||||||
if (! $row) {
|
if (! $row) {
|
||||||
throw new PageNotFoundException('Progress report not found.');
|
throw new PageNotFoundException('Progress report not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
|
||||||
$allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
|
$allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
|
||||||
if (empty($allowedTeacherIds)) {
|
if (empty($allowedTeacherIds)) {
|
||||||
if ($teacherId !== (int) $row['teacher_id']) {
|
if ($teacherId !== (int) $row['teacher_id']) {
|
||||||
@@ -473,8 +492,9 @@ class ClassProgressController extends BaseController
|
|||||||
->select('id')
|
->select('id')
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
->where('week_start', $weekStart)
|
->where('week_start', $weekStart)
|
||||||
->where('teacher_id', $teacherId)
|
->where('teacher_id', $teacherId);
|
||||||
->findAll();
|
$this->applyProgressSchoolYearScope($conflicts, $schoolYear);
|
||||||
|
$conflicts = $conflicts->findAll();
|
||||||
if (! $confirmOverwrite && ! empty($conflicts)) {
|
if (! $confirmOverwrite && ! empty($conflicts)) {
|
||||||
return redirect()->back()
|
return redirect()->back()
|
||||||
->withInput()
|
->withInput()
|
||||||
@@ -493,11 +513,13 @@ class ClassProgressController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyReports = $this->reportModel
|
$weeklyReportsQuery = $this->reportModel
|
||||||
->select('class_progress_reports.*')
|
->select('class_progress_reports.*')
|
||||||
->whereIn('teacher_id', $allowedTeacherIds)
|
->whereIn('teacher_id', $allowedTeacherIds)
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
->where('week_start', $row['week_start'])
|
->where('week_start', $row['week_start']);
|
||||||
|
$this->applyProgressSchoolYearScope($weeklyReportsQuery, $schoolYear);
|
||||||
|
$weeklyReports = $weeklyReportsQuery
|
||||||
->orderBy('subject', 'ASC')
|
->orderBy('subject', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
@@ -533,7 +555,7 @@ class ClassProgressController extends BaseController
|
|||||||
'unit_title' => $unitTitle,
|
'unit_title' => $unitTitle,
|
||||||
'covered' => $covered,
|
'covered' => $covered,
|
||||||
'homework' => $homework ?: null,
|
'homework' => $homework ?: null,
|
||||||
];
|
] + $this->progressReportSchoolYearData($schoolYear);
|
||||||
if ($flagsInput !== null) {
|
if ($flagsInput !== null) {
|
||||||
$data['flags_json'] = $this->normalizeFlags($flagsInput);
|
$data['flags_json'] = $this->normalizeFlags($flagsInput);
|
||||||
}
|
}
|
||||||
@@ -698,8 +720,7 @@ class ClassProgressController extends BaseController
|
|||||||
|
|
||||||
protected function loadTeacherSections(int $teacherId): array
|
protected function loadTeacherSections(int $teacherId): array
|
||||||
{
|
{
|
||||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
[$semester, $schoolYear] = $this->resolveCurrentTerm();
|
||||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
|
||||||
return $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $schoolYear, $semester);
|
return $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $schoolYear, $semester);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,13 +899,13 @@ class ClassProgressController extends BaseController
|
|||||||
|
|
||||||
protected function resolveProgressDateRange(): ?array
|
protected function resolveProgressDateRange(): ?array
|
||||||
{
|
{
|
||||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
if ($schoolYear === '') {
|
if ($schoolYear === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||||
$semester = $semesterResolver->normalizeSemester((string) ($this->configModel->getConfig('semester') ?? ''));
|
$semester = $semesterResolver->normalizeSemester((string) (getSemester() ?? ''));
|
||||||
if ($semester === '') {
|
if ($semester === '') {
|
||||||
$semester = $semesterResolver->getSemesterForDate();
|
$semester = $semesterResolver->getSemesterForDate();
|
||||||
}
|
}
|
||||||
@@ -946,11 +967,65 @@ class ClassProgressController extends BaseController
|
|||||||
|
|
||||||
protected function resolveCurrentTerm(): array
|
protected function resolveCurrentTerm(): array
|
||||||
{
|
{
|
||||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
$semester = (string) (getSemester() ?? '');
|
||||||
return [$semester, $schoolYear];
|
return [$semester, $schoolYear];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function applyProgressSchoolYearScope($builder, string $schoolYear, bool $canScopeBySection = false): void
|
||||||
|
{
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = db_connect();
|
||||||
|
$hasReportYear = $db->fieldExists('school_year', 'class_progress_reports');
|
||||||
|
$hasSectionYear = $db->fieldExists('school_year', 'classSection');
|
||||||
|
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||||
|
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYear);
|
||||||
|
|
||||||
|
if ($hasReportYear) {
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$builder
|
||||||
|
->groupStart()
|
||||||
|
->where('class_progress_reports.school_year', $schoolYear)
|
||||||
|
->orGroupStart()
|
||||||
|
->groupStart()
|
||||||
|
->where('class_progress_reports.school_year IS NULL', null, false)
|
||||||
|
->orWhere('class_progress_reports.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||||
|
->where('class_progress_reports.week_start <=', $rangeEnd)
|
||||||
|
->groupEnd()
|
||||||
|
->groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->where('class_progress_reports.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canScopeBySection && $hasSectionYear) {
|
||||||
|
$builder->where('cs.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$builder
|
||||||
|
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||||
|
->where('class_progress_reports.week_start <=', $rangeEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function progressReportSchoolYearData(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if ($schoolYear === '' || ! db_connect()->fieldExists('school_year', 'class_progress_reports')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['school_year' => $schoolYear];
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveAssignedTeacherIds(?int $classSectionId, string $semester, string $schoolYear): array
|
protected function resolveAssignedTeacherIds(?int $classSectionId, string $semester, string $schoolYear): array
|
||||||
{
|
{
|
||||||
if (! $classSectionId || $schoolYear === '') {
|
if (! $classSectionId || $schoolYear === '') {
|
||||||
|
|||||||
@@ -115,9 +115,10 @@ class InitializeRolesPermissions extends Controller
|
|||||||
|
|
||||||
// Associating permissions with roles
|
// Associating permissions with roles
|
||||||
$rolePermissions = [
|
$rolePermissions = [
|
||||||
'administrator' => ['manage_users', 'view_reports', 'manage_settings', 'access_student_records', 'communicate_parents'],
|
'administrator' => ['manage_users', 'view_reports', 'manage_settings', 'access_student_records', 'communicate_parents', 'update_curriculum'],
|
||||||
'principal' => ['manage_users', 'view_reports', 'access_student_records', 'communicate_parents'],
|
'principal' => ['manage_users', 'view_reports', 'access_student_records', 'communicate_parents', 'update_curriculum'],
|
||||||
'vice principal' => ['view_reports', 'access_student_records', 'communicate_parents'],
|
'vice principal' => ['view_reports', 'access_student_records', 'communicate_parents', 'update_curriculum'],
|
||||||
|
'admin' => ['manage_users', 'view_reports', 'manage_settings', 'access_student_records', 'communicate_parents', 'update_curriculum'],
|
||||||
'teacher' => ['access_student_records', 'communicate_parents', 'enter_grades', 'access_assignments'],
|
'teacher' => ['access_student_records', 'communicate_parents', 'enter_grades', 'access_assignments'],
|
||||||
'student' => ['view_own_records', 'access_assignments'],
|
'student' => ['view_own_records', 'access_assignments'],
|
||||||
'parent' => ['view_own_records', 'communicate_parents'],
|
'parent' => ['view_own_records', 'communicate_parents'],
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ namespace App\Controllers;
|
|||||||
use App\Controllers\ClassProgressController;
|
use App\Controllers\ClassProgressController;
|
||||||
use App\Models\ClassProgressAttachmentModel;
|
use App\Models\ClassProgressAttachmentModel;
|
||||||
use App\Models\ClassProgressReportModel;
|
use App\Models\ClassProgressReportModel;
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
use App\Models\EnrollmentModel;
|
use App\Models\EnrollmentModel;
|
||||||
|
use App\Services\SemesterRangeService;
|
||||||
use CodeIgniter\Database\BaseConnection;
|
use CodeIgniter\Database\BaseConnection;
|
||||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||||
use Config\Database;
|
use Config\Database;
|
||||||
@@ -15,8 +17,10 @@ class ParentProgressController extends BaseController
|
|||||||
protected ClassProgressReportModel $reportModel;
|
protected ClassProgressReportModel $reportModel;
|
||||||
protected ClassProgressAttachmentModel $attachmentModel;
|
protected ClassProgressAttachmentModel $attachmentModel;
|
||||||
protected EnrollmentModel $enrollmentModel;
|
protected EnrollmentModel $enrollmentModel;
|
||||||
|
protected ConfigurationModel $configModel;
|
||||||
protected BaseConnection $db;
|
protected BaseConnection $db;
|
||||||
private ?array $parentSectionIds = null;
|
private ?array $parentSectionIds = null;
|
||||||
|
private array $parentSectionIdsByYear = [];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -24,11 +28,13 @@ class ParentProgressController extends BaseController
|
|||||||
$this->reportModel = new ClassProgressReportModel();
|
$this->reportModel = new ClassProgressReportModel();
|
||||||
$this->attachmentModel = new ClassProgressAttachmentModel();
|
$this->attachmentModel = new ClassProgressAttachmentModel();
|
||||||
$this->enrollmentModel = new EnrollmentModel();
|
$this->enrollmentModel = new EnrollmentModel();
|
||||||
|
$this->configModel = new ConfigurationModel();
|
||||||
$this->db = Database::connect();
|
$this->db = Database::connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
$schoolYear = $this->currentSchoolYearName();
|
||||||
$students = $this->getParentStudents();
|
$students = $this->getParentStudents();
|
||||||
$sectionIds = array_values(array_unique(array_filter(array_map(
|
$sectionIds = array_values(array_unique(array_filter(array_map(
|
||||||
static fn (array $student): int => (int) ($student['class_section_id'] ?? 0),
|
static fn (array $student): int => (int) ($student['class_section_id'] ?? 0),
|
||||||
@@ -43,6 +49,7 @@ class ParentProgressController extends BaseController
|
|||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
||||||
->whereIn('class_progress_reports.class_section_id', $sectionIds);
|
->whereIn('class_progress_reports.class_section_id', $sectionIds);
|
||||||
|
$this->applyProgressSchoolYearScope($builder, $schoolYear);
|
||||||
|
|
||||||
$rows = $builder
|
$rows = $builder
|
||||||
->orderBy('week_start', 'DESC')
|
->orderBy('week_start', 'DESC')
|
||||||
@@ -75,23 +82,34 @@ class ParentProgressController extends BaseController
|
|||||||
|
|
||||||
public function view($id)
|
public function view($id)
|
||||||
{
|
{
|
||||||
|
$schoolYear = $this->currentSchoolYearName();
|
||||||
$row = $this->reportModel
|
$row = $this->reportModel
|
||||||
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
|
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
|
||||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
|
||||||
->find((int) $id);
|
->where('class_progress_reports.id', (int) $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
if (! $row || ! $this->isSectionAccessible($row['class_section_id'] ?? null)) {
|
if (! $row) {
|
||||||
|
throw new PageNotFoundException('Progress report not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportSchoolYear = trim((string) ($row['school_year'] ?? ''));
|
||||||
|
$effectiveSchoolYear = $reportSchoolYear !== '' ? $reportSchoolYear : $schoolYear;
|
||||||
|
|
||||||
|
if (! $this->isSectionAccessible($row['class_section_id'] ?? null, $effectiveSchoolYear)) {
|
||||||
throw new PageNotFoundException('Progress report not found.');
|
throw new PageNotFoundException('Progress report not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
|
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
|
||||||
$row['flags'] = $this->decodeFlags($row['flags_json']);
|
$row['flags'] = $this->decodeFlags($row['flags_json']);
|
||||||
|
|
||||||
$weeklyReports = $this->reportModel
|
$weeklyReportsQuery = $this->reportModel
|
||||||
->select('class_progress_reports.*')
|
->select('class_progress_reports.*')
|
||||||
->where('class_section_id', $row['class_section_id'])
|
->where('class_section_id', $row['class_section_id'])
|
||||||
->where('week_start', $row['week_start'])
|
->where('week_start', $row['week_start']);
|
||||||
|
$this->applyProgressSchoolYearScope($weeklyReportsQuery, $effectiveSchoolYear);
|
||||||
|
$weeklyReports = $weeklyReportsQuery
|
||||||
->orderBy('subject', 'ASC')
|
->orderBy('subject', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
@@ -117,7 +135,14 @@ class ParentProgressController extends BaseController
|
|||||||
public function attachment($id)
|
public function attachment($id)
|
||||||
{
|
{
|
||||||
$row = $this->reportModel->find((int) $id);
|
$row = $this->reportModel->find((int) $id);
|
||||||
if (! $row || ! $this->isSectionAccessible($row['class_section_id'] ?? null) || empty($row['attachment_path'])) {
|
if (! $row) {
|
||||||
|
throw new PageNotFoundException('Attachment not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportSchoolYear = trim((string) ($row['school_year'] ?? ''));
|
||||||
|
$effectiveSchoolYear = $reportSchoolYear !== '' ? $reportSchoolYear : $this->currentSchoolYearName();
|
||||||
|
|
||||||
|
if (! $this->isSectionAccessible($row['class_section_id'] ?? null, $effectiveSchoolYear) || empty($row['attachment_path'])) {
|
||||||
throw new PageNotFoundException('Attachment not found.');
|
throw new PageNotFoundException('Attachment not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +162,14 @@ class ParentProgressController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$report = $this->reportModel->find((int) ($attachment['report_id'] ?? 0));
|
$report = $this->reportModel->find((int) ($attachment['report_id'] ?? 0));
|
||||||
if (! $report || ! $this->isSectionAccessible($report['class_section_id'] ?? null)) {
|
if (! $report) {
|
||||||
|
throw new PageNotFoundException('Attachment not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportSchoolYear = trim((string) ($report['school_year'] ?? ''));
|
||||||
|
$effectiveSchoolYear = $reportSchoolYear !== '' ? $reportSchoolYear : $this->currentSchoolYearName();
|
||||||
|
|
||||||
|
if (! $this->isSectionAccessible($report['class_section_id'] ?? null, $effectiveSchoolYear)) {
|
||||||
throw new PageNotFoundException('Attachment not found.');
|
throw new PageNotFoundException('Attachment not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,21 +182,30 @@ class ParentProgressController extends BaseController
|
|||||||
return $this->response->download($file, null)->setFileName($downloadName);
|
return $this->response->download($file, null)->setFileName($downloadName);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getParentSectionIds(): array
|
protected function getParentSectionIds(?string $schoolYear = null): array
|
||||||
{
|
{
|
||||||
if ($this->parentSectionIds !== null) {
|
$schoolYear = trim((string) ($schoolYear ?? $this->currentSchoolYearName()));
|
||||||
|
if ($schoolYear === $this->currentSchoolYearName() && $this->parentSectionIds !== null) {
|
||||||
return $this->parentSectionIds;
|
return $this->parentSectionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($this->parentSectionIdsByYear[$schoolYear])) {
|
||||||
|
return $this->parentSectionIdsByYear[$schoolYear];
|
||||||
|
}
|
||||||
|
|
||||||
$parentId = (int) session()->get('user_id');
|
$parentId = (int) session()->get('user_id');
|
||||||
if ($parentId === 0) {
|
if ($parentId === 0) {
|
||||||
$this->parentSectionIds = [];
|
$this->parentSectionIdsByYear[$schoolYear] = [];
|
||||||
return $this->parentSectionIds;
|
if ($schoolYear === $this->currentSchoolYearName()) {
|
||||||
|
$this->parentSectionIds = [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $this->enrollmentModel
|
$rows = $this->enrollmentModel
|
||||||
->select('class_section_id')
|
->select('class_section_id')
|
||||||
->where('parent_id', $parentId)
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
->where('is_withdrawn', 0)
|
->where('is_withdrawn', 0)
|
||||||
->groupBy('class_section_id')
|
->groupBy('class_section_id')
|
||||||
->findAll();
|
->findAll();
|
||||||
@@ -177,8 +218,12 @@ class ParentProgressController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->parentSectionIds = array_values(array_unique($ids));
|
$ids = array_values(array_unique($ids));
|
||||||
return $this->parentSectionIds;
|
$this->parentSectionIdsByYear[$schoolYear] = $ids;
|
||||||
|
if ($schoolYear === $this->currentSchoolYearName()) {
|
||||||
|
$this->parentSectionIds = $ids;
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function buildSectionOptions(array $sectionIds): array
|
protected function buildSectionOptions(array $sectionIds): array
|
||||||
@@ -213,6 +258,7 @@ class ParentProgressController extends BaseController
|
|||||||
->join('students s', 's.id = e.student_id')
|
->join('students s', 's.id = e.student_id')
|
||||||
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
||||||
->where('e.parent_id', $parentId)
|
->where('e.parent_id', $parentId)
|
||||||
|
->where('e.school_year', $this->currentSchoolYearName())
|
||||||
->where('e.is_withdrawn', 0)
|
->where('e.is_withdrawn', 0)
|
||||||
->orderBy('e.updated_at', 'DESC')
|
->orderBy('e.updated_at', 'DESC')
|
||||||
->orderBy('e.created_at', 'DESC')
|
->orderBy('e.created_at', 'DESC')
|
||||||
@@ -256,12 +302,50 @@ class ParentProgressController extends BaseController
|
|||||||
return $reportGroups;
|
return $reportGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function isSectionAccessible(?int $classSectionId): bool
|
protected function isSectionAccessible(?int $classSectionId, ?string $schoolYear = null): bool
|
||||||
{
|
{
|
||||||
if (! $classSectionId) {
|
if (! $classSectionId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return in_array((int) $classSectionId, $this->getParentSectionIds(), true);
|
return in_array((int) $classSectionId, $this->getParentSectionIds($schoolYear), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyProgressSchoolYearScope($builder, string $schoolYear): void
|
||||||
|
{
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasReportYear = $this->db->fieldExists('school_year', 'class_progress_reports');
|
||||||
|
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||||
|
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYear);
|
||||||
|
|
||||||
|
if ($hasReportYear) {
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$builder
|
||||||
|
->groupStart()
|
||||||
|
->where('class_progress_reports.school_year', $schoolYear)
|
||||||
|
->orGroupStart()
|
||||||
|
->groupStart()
|
||||||
|
->where('class_progress_reports.school_year IS NULL', null, false)
|
||||||
|
->orWhere('class_progress_reports.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||||
|
->where('class_progress_reports.week_start <=', $rangeEnd)
|
||||||
|
->groupEnd()
|
||||||
|
->groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->where('class_progress_reports.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$builder
|
||||||
|
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||||
|
->where('class_progress_reports.week_start <=', $rangeEnd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveAttachmentFile(array $row): ?string
|
protected function resolveAttachmentFile(array $row): ?string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
use App\Models\ReportCardAcknowledgementModel;
|
use App\Models\ReportCardAcknowledgementModel;
|
||||||
use App\Models\StudentModel;
|
use App\Models\StudentModel;
|
||||||
@@ -31,8 +32,9 @@ class ParentReportCardController extends BaseController
|
|||||||
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
|
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? ''));
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
|
$schoolYear = trim($schoolYearContext->yearName());
|
||||||
|
$semesterOptions = $this->semesterOptions($schoolYear, '');
|
||||||
|
|
||||||
$builder = $this->db->table('students s')
|
$builder = $this->db->table('students s')
|
||||||
->select('s.id, s.firstname, s.lastname, cs.class_section_name')
|
->select('s.id, s.firstname, s.lastname, cs.class_section_name')
|
||||||
@@ -45,31 +47,44 @@ class ParentReportCardController extends BaseController
|
|||||||
if ($schoolYear !== '') {
|
if ($schoolYear !== '') {
|
||||||
$builder->where('sc.school_year', $schoolYear);
|
$builder->where('sc.school_year', $schoolYear);
|
||||||
}
|
}
|
||||||
if ($semester !== '') {
|
|
||||||
$builder->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
$students = $builder->get()->getResultArray();
|
$students = $this->uniqueStudentRows($builder->get()->getResultArray());
|
||||||
$studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students)));
|
$studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students)));
|
||||||
|
|
||||||
$ackMap = [];
|
$ackMap = [];
|
||||||
|
$reportAvailableMap = $this->reportAvailabilityMap($studentIds, $schoolYear);
|
||||||
if (! empty($studentIds)) {
|
if (! empty($studentIds)) {
|
||||||
$rows = $this->ackModel
|
$ackQuery = $this->ackModel
|
||||||
->where('parent_id', $parentId)
|
->where('parent_id', $parentId)
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->where('semester', $semester)
|
->whereIn('student_id', $studentIds);
|
||||||
->whereIn('student_id', $studentIds)
|
|
||||||
->findAll();
|
$rows = $ackQuery->findAll();
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$ackMap[(int) $row['student_id']] = $row;
|
$semester = $this->selectedSemester($row['semester'] ?? '');
|
||||||
|
$ackMap[$this->semesterStudentKey((int) $row['student_id'], $semester)] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportRows = [];
|
||||||
|
foreach ($students as $student) {
|
||||||
|
foreach ($semesterOptions as $semester) {
|
||||||
|
$reportRows[] = [
|
||||||
|
'student' => $student,
|
||||||
|
'semester' => $semester,
|
||||||
|
'key' => $this->semesterStudentKey((int) ($student['id'] ?? 0), $semester),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('parent/report_cards', [
|
return view('parent/report_cards', [
|
||||||
'students' => $students,
|
'students' => $students,
|
||||||
|
'reportRows' => $reportRows,
|
||||||
'ackMap' => $ackMap,
|
'ackMap' => $ackMap,
|
||||||
|
'reportAvailableMap' => $reportAvailableMap,
|
||||||
'schoolYear' => $schoolYear,
|
'schoolYear' => $schoolYear,
|
||||||
'semester' => $semester,
|
'semesterOptions' => $semesterOptions,
|
||||||
|
'isEditable' => ! $schoolYearContext->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +100,20 @@ class ParentReportCardController extends BaseController
|
|||||||
throw new PageNotFoundException('Student not found.');
|
throw new PageNotFoundException('Student not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? ''));
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
|
$schoolYear = trim($schoolYearContext->yearName());
|
||||||
|
$semester = $this->selectedSemester($this->request->getGet('semester'));
|
||||||
|
|
||||||
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) {
|
||||||
'viewed_at' => date('Y-m-d H:i:s'),
|
return redirect()->to(site_url('parent/report-cards'))
|
||||||
]);
|
->with('error', 'No report card exists for the selected school year and semester.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $schoolYearContext->isReadonly()) {
|
||||||
|
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
||||||
|
'viewed_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$url = site_url('report-card/student/' . (int) $studentId);
|
$url = site_url('report-card/student/' . (int) $studentId);
|
||||||
$query = [];
|
$query = [];
|
||||||
@@ -114,6 +137,13 @@ class ParentReportCardController extends BaseController
|
|||||||
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
|
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
try {
|
||||||
|
$this->assertSchoolYearWritable($schoolYearContext);
|
||||||
|
} catch (SchoolYearWriteConflictException $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$student = $this->studentModel->find((int) $studentId);
|
$student = $this->studentModel->find((int) $studentId);
|
||||||
if (! $student || (int) ($student['parent_id'] ?? 0) !== $parentId) {
|
if (! $student || (int) ($student['parent_id'] ?? 0) !== $parentId) {
|
||||||
throw new PageNotFoundException('Student not found.');
|
throw new PageNotFoundException('Student not found.');
|
||||||
@@ -124,8 +154,14 @@ class ParentReportCardController extends BaseController
|
|||||||
return redirect()->back()->with('error', 'Please type your full name to sign.');
|
return redirect()->back()->with('error', 'Please type your full name to sign.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = trim((string) ($this->configModel->getConfig('school_year') ?? ''));
|
$schoolYear = trim($schoolYearContext->yearName());
|
||||||
$semester = trim((string) ($this->configModel->getConfig('semester') ?? ''));
|
$semester = $this->selectedSemester($this->request->getPost('semester'));
|
||||||
|
|
||||||
|
if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) {
|
||||||
|
return redirect()->to(site_url('parent/report-cards'))
|
||||||
|
->with('error', 'No report card exists for the selected school year and semester.');
|
||||||
|
}
|
||||||
|
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
||||||
'viewed_at' => $now,
|
'viewed_at' => $now,
|
||||||
@@ -137,6 +173,78 @@ class ParentReportCardController extends BaseController
|
|||||||
return redirect()->to(site_url('parent/report-cards'))->with('success', 'Report card acknowledged.');
|
return redirect()->to(site_url('parent/report-cards'))->with('success', 'Report card acknowledged.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function selectedSemester($semester): string
|
||||||
|
{
|
||||||
|
$selected = trim((string) ($semester ?? ''));
|
||||||
|
if ($selected === '') {
|
||||||
|
$selected = trim((string) (getSemester() ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($selected);
|
||||||
|
if ($normalized === 'fall' || $normalized === 'first' || str_contains($normalized, 'fall') || str_contains($normalized, '1')) {
|
||||||
|
return 'Fall';
|
||||||
|
}
|
||||||
|
if ($normalized === 'spring' || $normalized === 'second' || str_contains($normalized, 'spring') || str_contains($normalized, '2')) {
|
||||||
|
return 'Spring';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selected !== '' ? $selected : 'Fall';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function uniqueStudentRows(array $students): array
|
||||||
|
{
|
||||||
|
$unique = [];
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$studentId = (int) ($student['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($unique[$studentId])) {
|
||||||
|
$unique[$studentId] = $student;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
empty($unique[$studentId]['class_section_name'])
|
||||||
|
&& ! empty($student['class_section_name'])
|
||||||
|
) {
|
||||||
|
$unique[$studentId]['class_section_name'] = $student['class_section_name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function semesterOptions(string $schoolYear, string $selectedSemester): array
|
||||||
|
{
|
||||||
|
$options = ['Fall', 'Spring'];
|
||||||
|
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$rows = $this->db->table('semester_scores')
|
||||||
|
->select('DISTINCT semester', false)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('semester IS NOT NULL', null, false)
|
||||||
|
->where('semester !=', '')
|
||||||
|
->orderBy('semester', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$semester = $this->selectedSemester($row['semester'] ?? '');
|
||||||
|
if ($semester !== '' && ! in_array($semester, $options, true)) {
|
||||||
|
$options[] = $semester;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selectedSemester !== '' && ! in_array($selectedSemester, $options, true)) {
|
||||||
|
$options[] = $selectedSemester;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($options));
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolvePrimaryParentId(): ?int
|
protected function resolvePrimaryParentId(): ?int
|
||||||
{
|
{
|
||||||
$parentId = (int) (session()->get('user_id') ?? 0);
|
$parentId = (int) (session()->get('user_id') ?? 0);
|
||||||
@@ -163,6 +271,81 @@ class ParentReportCardController extends BaseController
|
|||||||
return $parentId ?: null;
|
return $parentId ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function reportAvailabilityMap(array $studentIds, string $schoolYear): array
|
||||||
|
{
|
||||||
|
$studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn ($id) => $id > 0));
|
||||||
|
if (empty($studentIds) || $schoolYear === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('semester_scores')
|
||||||
|
->select('student_id, semester')
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->where('school_year', $schoolYear);
|
||||||
|
|
||||||
|
$rows = $builder
|
||||||
|
->groupBy('student_id, semester')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$sid = (int) ($row['student_id'] ?? 0);
|
||||||
|
if ($sid > 0) {
|
||||||
|
$semester = $this->selectedSemester($row['semester'] ?? '');
|
||||||
|
$map[$this->semesterStudentKey($sid, $semester)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function semesterStudentKey(int $studentId, string $semester): string
|
||||||
|
{
|
||||||
|
return $studentId . '|' . $this->selectedSemester($semester);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function reportExists(int $studentId, string $schoolYear, string $semester): bool
|
||||||
|
{
|
||||||
|
if ($studentId <= 0 || $schoolYear === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('semester_scores')
|
||||||
|
->select('id')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->limit(1);
|
||||||
|
|
||||||
|
$semesterVariants = $this->semesterVariants($semester);
|
||||||
|
if (! empty($semesterVariants)) {
|
||||||
|
$builder->whereIn('semester', $semesterVariants);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $builder->get()->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function semesterVariants(string $semester): array
|
||||||
|
{
|
||||||
|
$raw = trim($semester);
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($raw);
|
||||||
|
if ($normalized === 'fall' || $normalized === 'first' || str_contains($normalized, 'fall') || str_contains($normalized, '1')) {
|
||||||
|
$values = ['Fall', 'fall', 'First', 'first', 'Semester 1', 'semester 1', 'Semester1', 'semester1', 'Sem 1', 'sem 1', '1', '01', 'S1', 's1'];
|
||||||
|
} elseif ($normalized === 'spring' || $normalized === 'second' || str_contains($normalized, 'spring') || str_contains($normalized, '2')) {
|
||||||
|
$values = ['Spring', 'spring', 'Second', 'second', 'Semester 2', 'semester 2', 'Semester2', 'semester2', 'Sem 2', 'sem 2', '2', '02', 'S2', 's2'];
|
||||||
|
} else {
|
||||||
|
$values = [$raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[] = $raw;
|
||||||
|
|
||||||
|
return array_values(array_unique($values));
|
||||||
|
}
|
||||||
|
|
||||||
protected function touchAcknowledgement(
|
protected function touchAcknowledgement(
|
||||||
int $parentId,
|
int $parentId,
|
||||||
int $studentId,
|
int $studentId,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\TeacherClassModel;
|
|||||||
use App\Models\AdminNotificationSubjectModel;
|
use App\Models\AdminNotificationSubjectModel;
|
||||||
use App\Models\NotificationModel;
|
use App\Models\NotificationModel;
|
||||||
use App\Models\UserNotificationModel;
|
use App\Models\UserNotificationModel;
|
||||||
|
use App\Services\SemesterRangeService;
|
||||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||||
|
|
||||||
class PrintRequests extends BaseController
|
class PrintRequests extends BaseController
|
||||||
@@ -46,54 +47,76 @@ class PrintRequests extends BaseController
|
|||||||
public function teacher_index()
|
public function teacher_index()
|
||||||
{
|
{
|
||||||
$teacher_id = session()->get('user_id');
|
$teacher_id = session()->get('user_id');
|
||||||
|
$context = $this->resolveSchoolYearContext();
|
||||||
|
$schoolYear = $context->yearName();
|
||||||
|
|
||||||
$data['print_requests'] = $this->printRequestModel
|
$printRequestsQuery = $this->printRequestModel
|
||||||
|
->distinct()
|
||||||
->select('print_requests.*, admins.firstname as admin_firstname, admins.lastname as admin_lastname')
|
->select('print_requests.*, admins.firstname as admin_firstname, admins.lastname as admin_lastname')
|
||||||
->join('users as admins', 'admins.id = print_requests.admin_id', 'left')
|
->join('users as admins', 'admins.id = print_requests.admin_id', 'left')
|
||||||
->where('print_requests.teacher_id', $teacher_id)
|
->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left')
|
||||||
|
->where('print_requests.teacher_id', $teacher_id);
|
||||||
|
$this->applyPrintRequestSchoolYearScope($printRequestsQuery, $schoolYear);
|
||||||
|
$data['print_requests'] = $printRequestsQuery
|
||||||
|
->orderBy('print_requests.required_by', 'DESC')
|
||||||
|
->orderBy('print_requests.id', 'DESC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
$teacher_classes = $this->teacherClassModel->getClassByTeacherId($teacher_id);
|
$teacher_classes = $this->teacherClassModel->getClassAssignmentsByUserId((int) $teacher_id, $schoolYear);
|
||||||
$data['class_id'] = !empty($teacher_classes) ? $teacher_classes[0]['class_section_id'] : null;
|
$data['class_id'] = !empty($teacher_classes) ? $teacher_classes[0]['class_section_id'] : null;
|
||||||
$dateOptions = $this->buildRequiredByOptions();
|
$dateOptions = $this->buildRequiredByOptions();
|
||||||
$data['sundays'] = $dateOptions['sundays'];
|
$data['sundays'] = $dateOptions['sundays'];
|
||||||
$data['times'] = $dateOptions['times'];
|
$data['times'] = $dateOptions['times'];
|
||||||
|
$data['isSchoolYearReadonly'] = $context->isReadonly();
|
||||||
|
$data['printRequestToken'] = $this->issuePrintRequestToken('print');
|
||||||
|
$data['copyRequestToken'] = $this->issuePrintRequestToken('copy');
|
||||||
|
|
||||||
return view('print_requests/teacher_index', $data);
|
return view('print_requests/teacher_index', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function admin_index()
|
public function admin_index()
|
||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$context = $this->resolveSchoolYearContext();
|
||||||
$query = $db->query("
|
$schoolYear = $context->yearName();
|
||||||
SELECT
|
|
||||||
pr.*,
|
$printRequestsQuery = $this->printRequestModel
|
||||||
|
->distinct()
|
||||||
|
->select('
|
||||||
|
print_requests.*,
|
||||||
u.firstname,
|
u.firstname,
|
||||||
u.lastname,
|
u.lastname,
|
||||||
cs.class_section_name,
|
cs.class_section_name,
|
||||||
admins.firstname AS admin_firstname,
|
admins.firstname AS admin_firstname,
|
||||||
admins.lastname AS admin_lastname
|
admins.lastname AS admin_lastname
|
||||||
FROM print_requests pr
|
')
|
||||||
LEFT JOIN users u ON u.id = pr.teacher_id
|
->join('users u', 'u.id = print_requests.teacher_id', 'left')
|
||||||
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id
|
->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left')
|
||||||
LEFT JOIN users admins ON admins.id = pr.admin_id
|
->join('users admins', 'admins.id = print_requests.admin_id', 'left');
|
||||||
ORDER BY
|
|
||||||
|
$this->applyPrintRequestSchoolYearScope($printRequestsQuery, $schoolYear);
|
||||||
|
|
||||||
|
$data['print_requests'] = $printRequestsQuery
|
||||||
|
->orderBy("
|
||||||
CASE
|
CASE
|
||||||
WHEN pr.status = 'not_assigned' THEN 1
|
WHEN print_requests.status = 'not_assigned' THEN 1
|
||||||
WHEN pr.status = 'assigned' THEN 2
|
WHEN print_requests.status = 'assigned' THEN 2
|
||||||
WHEN pr.status = 'done' THEN 3
|
WHEN print_requests.status = 'done' THEN 3
|
||||||
WHEN pr.status = 'delivered' THEN 4
|
WHEN print_requests.status = 'delivered' THEN 4
|
||||||
ELSE 5
|
ELSE 5
|
||||||
END ASC,
|
END
|
||||||
pr.required_by ASC
|
", 'ASC', false)
|
||||||
");
|
->orderBy('print_requests.required_by', 'ASC')
|
||||||
$data['print_requests'] = $query->getResultArray();
|
->findAll();
|
||||||
|
$data['isSchoolYearReadonly'] = $context->isReadonly();
|
||||||
|
|
||||||
return view('print_requests/admin_index', $data);
|
return view('print_requests/admin_index', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
|
$context = $this->resolveSchoolYearContext();
|
||||||
|
$this->assertSchoolYearWritable($context);
|
||||||
|
$schoolYear = $context->yearName();
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
'file' => 'uploaded[file]|max_size[file,5120]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]',
|
'file' => 'uploaded[file]|max_size[file,5120]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]',
|
||||||
'page_selection' => 'permit_empty|regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]',
|
'page_selection' => 'permit_empty|regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]',
|
||||||
@@ -106,6 +129,10 @@ class PrintRequests extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->consumePrintRequestToken('print')) {
|
||||||
|
return redirect()->to('teacher/print-requests')->with('error', 'This print request was already submitted. Please refresh the page before submitting another request.');
|
||||||
|
}
|
||||||
|
|
||||||
$teacher_id = session()->get('user_id');
|
$teacher_id = session()->get('user_id');
|
||||||
|
|
||||||
$file = $this->request->getFile('file');
|
$file = $this->request->getFile('file');
|
||||||
@@ -125,7 +152,7 @@ class PrintRequests extends BaseController
|
|||||||
'required_by' => $this->request->getPost('required_by'),
|
'required_by' => $this->request->getPost('required_by'),
|
||||||
'pickup_method' => $this->request->getPost('pickup_method'),
|
'pickup_method' => $this->request->getPost('pickup_method'),
|
||||||
'status' => 'not_assigned',
|
'status' => 'not_assigned',
|
||||||
];
|
] + $this->printRequestSchoolYearData($schoolYear);
|
||||||
|
|
||||||
$printRequestId = (int) $this->printRequestModel->insert($data, true);
|
$printRequestId = (int) $this->printRequestModel->insert($data, true);
|
||||||
if ($printRequestId > 0) {
|
if ($printRequestId > 0) {
|
||||||
@@ -144,6 +171,7 @@ class PrintRequests extends BaseController
|
|||||||
|
|
||||||
// Case 1: Admin status update
|
// Case 1: Admin status update
|
||||||
if ($this->request->getPost('status')) {
|
if ($this->request->getPost('status')) {
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
$user_id = session()->get('user_id');
|
$user_id = session()->get('user_id');
|
||||||
$current_status = $request['status'];
|
$current_status = $request['status'];
|
||||||
$new_status = $this->request->getPost('status');
|
$new_status = $this->request->getPost('status');
|
||||||
@@ -175,6 +203,7 @@ class PrintRequests extends BaseController
|
|||||||
|
|
||||||
// Case 2: Teacher edit
|
// Case 2: Teacher edit
|
||||||
if ($this->request->getPost('num_copies')) {
|
if ($this->request->getPost('num_copies')) {
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
$user_id = session()->get('user_id');
|
$user_id = session()->get('user_id');
|
||||||
if ($request['teacher_id'] != $user_id) {
|
if ($request['teacher_id'] != $user_id) {
|
||||||
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to edit this request.');
|
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to edit this request.');
|
||||||
@@ -230,6 +259,7 @@ class PrintRequests extends BaseController
|
|||||||
|
|
||||||
public function delete($id)
|
public function delete($id)
|
||||||
{
|
{
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
$teacher_id = session()->get('user_id');
|
$teacher_id = session()->get('user_id');
|
||||||
$request = $this->printRequestModel->find($id);
|
$request = $this->printRequestModel->find($id);
|
||||||
|
|
||||||
@@ -260,6 +290,9 @@ class PrintRequests extends BaseController
|
|||||||
public function copy($id)
|
public function copy($id)
|
||||||
{
|
{
|
||||||
$teacher_id = session()->get('user_id');
|
$teacher_id = session()->get('user_id');
|
||||||
|
$context = $this->resolveSchoolYearContext();
|
||||||
|
$this->assertSchoolYearWritable($context);
|
||||||
|
$schoolYear = $context->yearName();
|
||||||
$request = $this->printRequestModel->find($id);
|
$request = $this->printRequestModel->find($id);
|
||||||
|
|
||||||
if (!$request) {
|
if (!$request) {
|
||||||
@@ -296,7 +329,7 @@ class PrintRequests extends BaseController
|
|||||||
'required_by' => $request['required_by'],
|
'required_by' => $request['required_by'],
|
||||||
'pickup_method' => $request['pickup_method'],
|
'pickup_method' => $request['pickup_method'],
|
||||||
'status' => 'not_assigned',
|
'status' => 'not_assigned',
|
||||||
];
|
] + $this->printRequestSchoolYearData($schoolYear);
|
||||||
|
|
||||||
$copiedId = (int) $this->printRequestModel->insert($data, true);
|
$copiedId = (int) $this->printRequestModel->insert($data, true);
|
||||||
if ($copiedId > 0) {
|
if ($copiedId > 0) {
|
||||||
@@ -308,6 +341,9 @@ class PrintRequests extends BaseController
|
|||||||
|
|
||||||
public function createCopy()
|
public function createCopy()
|
||||||
{
|
{
|
||||||
|
$context = $this->resolveSchoolYearContext();
|
||||||
|
$this->assertSchoolYearWritable($context);
|
||||||
|
$schoolYear = $context->yearName();
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
'num_copies' => 'required|integer|greater_than[0]',
|
'num_copies' => 'required|integer|greater_than[0]',
|
||||||
'required_by' => 'required|valid_date',
|
'required_by' => 'required|valid_date',
|
||||||
@@ -318,6 +354,10 @@ class PrintRequests extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->consumePrintRequestToken('copy')) {
|
||||||
|
return redirect()->to('teacher/print-requests')->with('error', 'This copy request was already submitted. Please refresh the page before submitting another request.');
|
||||||
|
}
|
||||||
|
|
||||||
$teacher_id = session()->get('user_id');
|
$teacher_id = session()->get('user_id');
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@@ -329,7 +369,7 @@ class PrintRequests extends BaseController
|
|||||||
'required_by' => $this->request->getPost('required_by'),
|
'required_by' => $this->request->getPost('required_by'),
|
||||||
'pickup_method' => $this->request->getPost('pickup_method'),
|
'pickup_method' => $this->request->getPost('pickup_method'),
|
||||||
'status' => 'not_assigned',
|
'status' => 'not_assigned',
|
||||||
];
|
] + $this->printRequestSchoolYearData($schoolYear);
|
||||||
|
|
||||||
$printRequestId = (int) $this->printRequestModel->insert($data, true);
|
$printRequestId = (int) $this->printRequestModel->insert($data, true);
|
||||||
if ($printRequestId > 0) {
|
if ($printRequestId > 0) {
|
||||||
@@ -374,6 +414,143 @@ class PrintRequests extends BaseController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function issuePrintRequestToken(string $type): string
|
||||||
|
{
|
||||||
|
$key = $this->printRequestTokenSessionKey($type);
|
||||||
|
$tokens = session()->get($key);
|
||||||
|
|
||||||
|
if (! is_array($tokens)) {
|
||||||
|
$tokens = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$tokens[$token] = time();
|
||||||
|
|
||||||
|
if (count($tokens) > 20) {
|
||||||
|
asort($tokens);
|
||||||
|
$tokens = array_slice($tokens, -20, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->set($key, $tokens);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function consumePrintRequestToken(string $type): bool
|
||||||
|
{
|
||||||
|
$token = (string) $this->request->getPost('request_token');
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $this->printRequestTokenSessionKey($type);
|
||||||
|
$tokens = session()->get($key);
|
||||||
|
|
||||||
|
if (! is_array($tokens) || ! array_key_exists($token, $tokens)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($tokens[$token]);
|
||||||
|
session()->set($key, $tokens);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printRequestTokenSessionKey(string $type): string
|
||||||
|
{
|
||||||
|
return 'print_request_' . $type . '_tokens';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyPrintRequestSchoolYearScope($query, string $schoolYear): void
|
||||||
|
{
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasPrintRequestYear = $this->printRequestsHaveSchoolYearColumn();
|
||||||
|
$hasClassSectionYear = db_connect()->fieldExists('school_year', 'classSection');
|
||||||
|
$range = (new SemesterRangeService($this->configModel))->getSchoolYearRange($schoolYear);
|
||||||
|
[$rangeStart, $rangeEnd] = $range;
|
||||||
|
|
||||||
|
if ($hasPrintRequestYear && $hasClassSectionYear) {
|
||||||
|
$query->groupStart()
|
||||||
|
->where('print_requests.school_year', $schoolYear)
|
||||||
|
->orGroupStart()
|
||||||
|
->groupStart()
|
||||||
|
->where('print_requests.school_year IS NULL', null, false)
|
||||||
|
->orWhere('print_requests.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->where('cs.school_year', $schoolYear)
|
||||||
|
->groupEnd();
|
||||||
|
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
$query
|
||||||
|
->orGroupStart()
|
||||||
|
->groupStart()
|
||||||
|
->where('print_requests.school_year IS NULL', null, false)
|
||||||
|
->orWhere('print_requests.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->groupStart()
|
||||||
|
->where('cs.school_year IS NULL', null, false)
|
||||||
|
->orWhere('cs.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->where('print_requests.required_by >=', $rangeStart . ' 00:00:00')
|
||||||
|
->where('print_requests.required_by <=', $rangeEnd . ' 23:59:59')
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasPrintRequestYear && $hasClassSectionYear) {
|
||||||
|
$query->where('cs.school_year', $schoolYear);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||||
|
if ($hasPrintRequestYear) {
|
||||||
|
$query
|
||||||
|
->groupStart()
|
||||||
|
->where('print_requests.school_year', $schoolYear)
|
||||||
|
->orGroupStart()
|
||||||
|
->groupStart()
|
||||||
|
->where('print_requests.school_year IS NULL', null, false)
|
||||||
|
->orWhere('print_requests.school_year', '')
|
||||||
|
->groupEnd()
|
||||||
|
->where('print_requests.required_by >=', $rangeStart . ' 00:00:00')
|
||||||
|
->where('print_requests.required_by <=', $rangeEnd . ' 23:59:59')
|
||||||
|
->groupEnd()
|
||||||
|
->groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query
|
||||||
|
->where('print_requests.required_by >=', $rangeStart . ' 00:00:00')
|
||||||
|
->where('print_requests.required_by <=', $rangeEnd . ' 23:59:59');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasPrintRequestYear) {
|
||||||
|
$query->where('print_requests.school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printRequestSchoolYearData(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if ($schoolYear === '' || ! $this->printRequestsHaveSchoolYearColumn()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['school_year' => $schoolYear];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printRequestsHaveSchoolYearColumn(): bool
|
||||||
|
{
|
||||||
|
return db_connect()->fieldExists('school_year', 'print_requests');
|
||||||
|
}
|
||||||
|
|
||||||
public function serveFile(string $filename, string $mode = 'inline')
|
public function serveFile(string $filename, string $mode = 'inline')
|
||||||
{
|
{
|
||||||
$safeName = basename(trim($filename));
|
$safeName = basename(trim($filename));
|
||||||
@@ -503,7 +680,7 @@ class PrintRequests extends BaseController
|
|||||||
'action_url' => $isCopyRequest ? '' : $actionUrl,
|
'action_url' => $isCopyRequest ? '' : $actionUrl,
|
||||||
'scheduled_at' => utc_now(),
|
'scheduled_at' => utc_now(),
|
||||||
'school_year' => $this->configModel->getConfig('school_year'),
|
'school_year' => $this->configModel->getConfig('school_year'),
|
||||||
'semester' => $this->configModel->getConfig('semester'),
|
'semester' => getSemester(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$notificationFields = $db->getFieldNames('notifications');
|
$notificationFields = $db->getFieldNames('notifications');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use App\Models\StudentModel;
|
|||||||
use App\Models\TeacherModel;
|
use App\Models\TeacherModel;
|
||||||
use App\Models\ClassSectionModel;
|
use App\Models\ClassSectionModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Support\Enrollment\EnrollmentEligibility;
|
||||||
use Config\Database;
|
use Config\Database;
|
||||||
|
|
||||||
class AssignmentController extends BaseController
|
class AssignmentController extends BaseController
|
||||||
@@ -33,7 +34,7 @@ class AssignmentController extends BaseController
|
|||||||
$this->teacherClassModel = new TeacherClassModel();
|
$this->teacherClassModel = new TeacherClassModel();
|
||||||
$this->studentClassModel = new StudentClassModel();
|
$this->studentClassModel = new StudentClassModel();
|
||||||
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -46,7 +47,35 @@ class AssignmentController extends BaseController
|
|||||||
|
|
||||||
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
|
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
|
||||||
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
$year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
|
||||||
|
if ($year === '') {
|
||||||
|
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
|
||||||
|
$this->repairMissingEnrollmentClassAssignments($year);
|
||||||
|
$distributedSectionIds = array_values(array_unique(array_merge(
|
||||||
|
$this->distributedClassSectionIds($year),
|
||||||
|
$this->enrollmentAssignedClassSectionIds($year)
|
||||||
|
)));
|
||||||
|
|
||||||
|
$classSectionsAll = [];
|
||||||
|
if (!empty($distributedSectionIds)) {
|
||||||
|
$classSectionsAll = $this->classSectionModel
|
||||||
|
->select('id, class_section_id, class_section_name, school_year')
|
||||||
|
->where('school_year', $year)
|
||||||
|
->whereIn('class_section_id', $distributedSectionIds)
|
||||||
|
->orderBy('class_section_name', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$classSectionById = [];
|
||||||
|
foreach ($classSectionsAll as $section) {
|
||||||
|
$sectionId = (int)($section['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$classSectionById[$sectionId] = $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$tcQ = $this->teacherClassModel;
|
$tcQ = $this->teacherClassModel;
|
||||||
if ($year !== '') {
|
if ($year !== '') {
|
||||||
@@ -71,20 +100,26 @@ class AssignmentController extends BaseController
|
|||||||
$studentsBySection[$sc['class_section_id']][] = $sc;
|
$studentsBySection[$sc['class_section_id']][] = $sc;
|
||||||
}
|
}
|
||||||
|
|
||||||
$allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)));
|
$allSectionIds = array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection)));
|
||||||
|
$distributedSectionSet = array_fill_keys($distributedSectionIds, true);
|
||||||
|
|
||||||
foreach ($allSectionIds as $classSectionId) {
|
foreach ($allSectionIds as $classSectionId) {
|
||||||
|
if (!isset($distributedSectionSet[(int)$classSectionId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
|
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
|
||||||
$studentClasses = $studentsBySection[$classSectionId] ?? [];
|
$studentClasses = $studentsBySection[$classSectionId] ?? [];
|
||||||
|
|
||||||
$hasTeacher = !empty($teacherClasses);
|
$hasTeacher = !empty($teacherClasses);
|
||||||
$hasStudents = !empty($studentClasses);
|
$hasStudents = !empty($studentClasses);
|
||||||
|
$hasClassSection = isset($classSectionById[(int)$classSectionId]);
|
||||||
|
|
||||||
if (!$hasTeacher && !$hasStudents) {
|
if (!$hasClassSection && !$hasTeacher && !$hasStudents) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
$classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||||
|
|
||||||
$mainTeachers = [];
|
$mainTeachers = [];
|
||||||
$teacherAssistants = [];
|
$teacherAssistants = [];
|
||||||
@@ -143,11 +178,13 @@ class AssignmentController extends BaseController
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$calculatedAge = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $year);
|
||||||
|
|
||||||
$students[] = [
|
$students[] = [
|
||||||
'id' => (int)$student['id'],
|
'id' => (int)$student['id'],
|
||||||
'firstname' => esc($student['firstname']),
|
'firstname' => esc($student['firstname']),
|
||||||
'lastname' => esc($student['lastname']),
|
'lastname' => esc($student['lastname']),
|
||||||
'age' => esc($student['age']),
|
'age' => esc((string)($calculatedAge ?? ($student['age'] ?? ''))),
|
||||||
'gender' => esc($student['gender']),
|
'gender' => esc($student['gender']),
|
||||||
'registration_grade' => esc($student['registration_grade']),
|
'registration_grade' => esc($student['registration_grade']),
|
||||||
'photo_consent' => esc($student['photo_consent'] ? 'Yes' : 'No'),
|
'photo_consent' => esc($student['photo_consent'] ? 'Yes' : 'No'),
|
||||||
@@ -172,14 +209,21 @@ class AssignmentController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$schoolYearsList = [];
|
$schoolYearsList = [];
|
||||||
try {
|
try {
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$yearsQuery = $db->table('teacher_class')
|
$yearsQuery = $db->table('classSection')
|
||||||
|
->select('DISTINCT school_year', false)
|
||||||
|
->where('school_year IS NOT NULL', null, false)
|
||||||
|
->orderBy('school_year', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
$studentYearsQuery = $db->table('student_class')
|
||||||
->select('DISTINCT school_year', false)
|
->select('DISTINCT school_year', false)
|
||||||
->where('school_year IS NOT NULL', null, false)
|
->where('school_year IS NOT NULL', null, false)
|
||||||
->orderBy('school_year', 'DESC')
|
->orderBy('school_year', 'DESC')
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
$yearsQuery = array_merge($yearsQuery, $studentYearsQuery);
|
||||||
foreach ($yearsQuery as $row) {
|
foreach ($yearsQuery as $row) {
|
||||||
$val = (string)($row['school_year'] ?? '');
|
$val = (string)($row['school_year'] ?? '');
|
||||||
if ($val !== '' && !in_array($val, $schoolYearsList, true)) {
|
if ($val !== '' && !in_array($val, $schoolYearsList, true)) {
|
||||||
@@ -189,12 +233,12 @@ class AssignmentController extends BaseController
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// ignore fallback below
|
// ignore fallback below
|
||||||
}
|
}
|
||||||
if (empty($schoolYearsList) && $this->schoolYear !== null && $this->schoolYear !== '') {
|
if ($this->schoolYear !== null && $this->schoolYear !== '' && !in_array((string)$this->schoolYear, $schoolYearsList, true)) {
|
||||||
$schoolYearsList[] = (string)$this->schoolYear;
|
array_unshift($schoolYearsList, (string)$this->schoolYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort sections
|
// Sort sections
|
||||||
usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name']));
|
usort($data['classSections'], fn($a, $b) => strnatcasecmp((string) $a['class_section_name'], (string) $b['class_section_name']));
|
||||||
|
|
||||||
$data['schoolYears'] = $schoolYearsList;
|
$data['schoolYears'] = $schoolYearsList;
|
||||||
$data['schoolYear'] = $year;
|
$data['schoolYear'] = $year;
|
||||||
@@ -204,6 +248,477 @@ class AssignmentController extends BaseController
|
|||||||
return view('administrator/class_assignment', $data);
|
return view('administrator/class_assignment', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function distributedClassSectionIds(string $year): array
|
||||||
|
{
|
||||||
|
$year = trim($year);
|
||||||
|
if ($year === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::connect();
|
||||||
|
if (! $db->tableExists('student_section_distribution_drafts')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseRows = $db->table('classSection')
|
||||||
|
->select('class_id, class_section_id, class_section_name')
|
||||||
|
->where('school_year', $year)
|
||||||
|
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||||
|
->orderBy('class_id', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$draftRows = $db->table('student_section_distribution_drafts d')
|
||||||
|
->select('d.class_id, d.class_section_id, COALESCE(cs.class_section_name, d.class_section_id) AS class_section_name', false)
|
||||||
|
->join(
|
||||||
|
'classSection cs',
|
||||||
|
'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year',
|
||||||
|
'left',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
->where('d.school_year', $year)
|
||||||
|
->where('d.class_section_id >', 0)
|
||||||
|
->whereIn('status', ['pending', 'applied'])
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$draftsByClassId = [];
|
||||||
|
foreach ($draftRows as $row) {
|
||||||
|
$classId = (int)($row['class_id'] ?? 0);
|
||||||
|
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||||
|
if ($classId <= 0 || $sectionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draftsByClassId[$classId][$sectionId] = [
|
||||||
|
'class_section_id' => $sectionId,
|
||||||
|
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = [];
|
||||||
|
$seenClassIds = [];
|
||||||
|
foreach ($baseRows as $row) {
|
||||||
|
$classId = (int)($row['class_id'] ?? 0);
|
||||||
|
$baseSectionId = (int)($row['class_section_id'] ?? 0);
|
||||||
|
$baseName = trim((string)($row['class_section_name'] ?? ''));
|
||||||
|
if ($classId <= 0 || $baseSectionId <= 0 || $baseName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($baseName);
|
||||||
|
$isAutoDistributeBase = $normalized === 'youth'
|
||||||
|
|| (ctype_digit($normalized) && (int)$normalized >= 1 && (int)$normalized <= 10);
|
||||||
|
if (!$isAutoDistributeBase) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenClassIds[$classId] = true;
|
||||||
|
$classDrafts = $draftsByClassId[$classId] ?? [];
|
||||||
|
$hasBaseDraft = false;
|
||||||
|
$letteredDraftIds = [];
|
||||||
|
foreach ($classDrafts as $draft) {
|
||||||
|
$draftSectionId = (int)($draft['class_section_id'] ?? 0);
|
||||||
|
$draftName = trim((string)($draft['class_section_name'] ?? ''));
|
||||||
|
if ($draftSectionId === $baseSectionId || $draftName === '' || strpos($draftName, '-') === false) {
|
||||||
|
$hasBaseDraft = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$letteredDraftIds[] = $draftSectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasBaseDraft || empty($letteredDraftIds)) {
|
||||||
|
$allowed[] = $baseSectionId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($letteredDraftIds as $draftSectionId) {
|
||||||
|
$allowed[] = $draftSectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($draftsByClassId as $classId => $classDrafts) {
|
||||||
|
if (isset($seenClassIds[$classId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($classDrafts as $draft) {
|
||||||
|
$sectionId = (int)($draft['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$allowed[] = $sectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter(
|
||||||
|
$allowed,
|
||||||
|
static fn(int $sectionId): bool => $sectionId > 0
|
||||||
|
)));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'distributedClassSectionIds failed: ' . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enrollmentAssignedClassSectionIds(string $year): array
|
||||||
|
{
|
||||||
|
$year = trim($year);
|
||||||
|
if ($year === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::connect();
|
||||||
|
$allowedStatuses = ['admission under review', 'review & decision', 'payment pending', 'enrolled'];
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
if ($db->tableExists('enrollments')) {
|
||||||
|
$builder = $db->table('enrollments e')
|
||||||
|
->select('e.class_section_id')
|
||||||
|
->join('students s', 's.id = e.student_id', 'inner')
|
||||||
|
->where('e.school_year', $year)
|
||||||
|
->whereIn('e.enrollment_status', $allowedStatuses)
|
||||||
|
->where('e.class_section_id IS NOT NULL', null, false)
|
||||||
|
->where('e.class_section_id >', 0);
|
||||||
|
|
||||||
|
if ($db->fieldExists('is_active', 'students')) {
|
||||||
|
$builder->where('s.is_active', 1);
|
||||||
|
}
|
||||||
|
if ($db->fieldExists('is_withdrawn', 'enrollments')) {
|
||||||
|
$builder->groupStart()
|
||||||
|
->where('e.is_withdrawn', 0)
|
||||||
|
->orWhere('e.is_withdrawn', null)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($builder->groupBy('e.class_section_id')->get()->getResultArray() as $row) {
|
||||||
|
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$ids[] = $sectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->tableExists('student_class') && $db->tableExists('enrollments')) {
|
||||||
|
$builder = $db->table('student_class sc')
|
||||||
|
->select('sc.class_section_id')
|
||||||
|
->join(
|
||||||
|
'enrollments e',
|
||||||
|
'e.student_id = sc.student_id AND e.school_year = sc.school_year',
|
||||||
|
'inner',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
->join('students s', 's.id = sc.student_id', 'inner')
|
||||||
|
->where('sc.school_year', $year)
|
||||||
|
->whereIn('e.enrollment_status', $allowedStatuses)
|
||||||
|
->where('sc.class_section_id IS NOT NULL', null, false)
|
||||||
|
->where('sc.class_section_id >', 0);
|
||||||
|
|
||||||
|
if ($db->fieldExists('is_active', 'students')) {
|
||||||
|
$builder->where('s.is_active', 1);
|
||||||
|
}
|
||||||
|
if ($db->fieldExists('is_event_only', 'student_class')) {
|
||||||
|
$builder->groupStart()
|
||||||
|
->where('sc.is_event_only', 0)
|
||||||
|
->orWhere('sc.is_event_only', null)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
if ($db->fieldExists('is_withdrawn', 'enrollments')) {
|
||||||
|
$builder->groupStart()
|
||||||
|
->where('e.is_withdrawn', 0)
|
||||||
|
->orWhere('e.is_withdrawn', null)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($builder->groupBy('sc.class_section_id')->get()->getResultArray() as $row) {
|
||||||
|
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$ids[] = $sectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'enrollmentAssignedClassSectionIds failed: ' . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void
|
||||||
|
{
|
||||||
|
if ($year === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::connect();
|
||||||
|
if (! $db->tableExists('student_section_distribution_drafts')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $db->table('student_section_distribution_drafts d')
|
||||||
|
->select('d.id, d.student_id, d.class_section_id')
|
||||||
|
->join(
|
||||||
|
'enrollments e',
|
||||||
|
'e.student_id = d.student_id AND e.school_year = d.school_year',
|
||||||
|
'inner'
|
||||||
|
)
|
||||||
|
->where('d.school_year', $year)
|
||||||
|
->where('d.status', 'pending')
|
||||||
|
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||||
|
->groupBy('d.id, d.student_id, d.class_section_id')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = utc_now();
|
||||||
|
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||||
|
|
||||||
|
$db->transStart();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$studentId = (int)($row['student_id'] ?? 0);
|
||||||
|
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||||
|
if ($studentId <= 0 || $sectionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $db->table('student_class')
|
||||||
|
->select('id')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $year)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'class_section_id' => $sectionId,
|
||||||
|
'school_year' => $year,
|
||||||
|
'updated_by' => $updatedBy,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$db->table('student_class')
|
||||||
|
->where('id', (int)$existing['id'])
|
||||||
|
->update($payload);
|
||||||
|
} else {
|
||||||
|
$payload['created_at'] = $now;
|
||||||
|
$db->table('student_class')->insert($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->table('enrollments')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $year)
|
||||||
|
->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||||
|
->update([
|
||||||
|
'class_section_id' => $sectionId,
|
||||||
|
'assigned_class_section_id' => $sectionId,
|
||||||
|
'placement_status' => 'automatic_distribution_applied',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->table('student_section_distribution_drafts')
|
||||||
|
->where('id', (int)$row['id'])
|
||||||
|
->update([
|
||||||
|
'status' => 'applied',
|
||||||
|
'applied_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$db->transComplete();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'applyPendingDistributionDraftsForEnrolledStudents failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repairMissingEnrollmentClassAssignments(string $year): void
|
||||||
|
{
|
||||||
|
if ($year === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::connect();
|
||||||
|
$this->ensureClassSectionsForYear($db, $year);
|
||||||
|
if (! $db->tableExists('enrollments') || ! $db->tableExists('student_class')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousYear = $this->previousSchoolYearName($year);
|
||||||
|
if ($previousYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $db->table('enrollments e')
|
||||||
|
->select('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id AS student_class_id')
|
||||||
|
->join('student_class sc', 'sc.student_id = e.student_id AND sc.school_year = e.school_year', 'left')
|
||||||
|
->where('e.school_year', $year)
|
||||||
|
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||||
|
->groupStart()
|
||||||
|
->where('e.class_section_id', null)
|
||||||
|
->orWhere('e.class_section_id', 0)
|
||||||
|
->orWhere('sc.id', null)
|
||||||
|
->groupEnd()
|
||||||
|
->groupBy('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$studentId = (int)($row['student_id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin');
|
||||||
|
if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0);
|
||||||
|
if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) {
|
||||||
|
$base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year);
|
||||||
|
$targetSectionId = (int)($base['class_section_id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetSectionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$placementStatus = match ((string)($evaluation['placement_status'] ?? '')) {
|
||||||
|
'automatic_distribution_pending' => 'base_section_pending_distribution',
|
||||||
|
'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'],
|
||||||
|
default => 'manual_class_assigned',
|
||||||
|
};
|
||||||
|
|
||||||
|
$existing = $db->table('student_class')
|
||||||
|
->select('id')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $year)
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$studentClassPayload = [
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'class_section_id' => $targetSectionId,
|
||||||
|
'school_year' => $year,
|
||||||
|
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
$db->table('student_class')->where('id', (int)$existing['id'])->update($studentClassPayload);
|
||||||
|
} else {
|
||||||
|
$studentClassPayload['created_at'] = utc_now();
|
||||||
|
$db->table('student_class')->insert($studentClassPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->table('enrollments')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $year)
|
||||||
|
->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||||
|
->update([
|
||||||
|
'class_section_id' => $targetSectionId,
|
||||||
|
'assigned_class_section_id' => $targetSectionId,
|
||||||
|
'placement_status' => $placementStatus,
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'repairMissingEnrollmentClassAssignments failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseSectionForClassYear(int $classId, string $schoolYear): ?array
|
||||||
|
{
|
||||||
|
$db = Database::connect();
|
||||||
|
$this->ensureClassSectionsForYear($db, $schoolYear);
|
||||||
|
if ($classId <= 0 || $schoolYear === '' || ! $db->tableExists('classSection')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $db->table('classSection')
|
||||||
|
->select('class_section_id, class_section_name, class_id')
|
||||||
|
->where('class_id', $classId)
|
||||||
|
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||||
|
->orderBy('id', 'ASC')
|
||||||
|
->limit(1);
|
||||||
|
|
||||||
|
if ($db->fieldExists('school_year', 'classSection')) {
|
||||||
|
$builder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $builder->get()->getRowArray();
|
||||||
|
|
||||||
|
return $row !== null && (int)($row['class_section_id'] ?? 0) > 0 ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureClassSectionsForYear($db, string $targetSchoolYear): void
|
||||||
|
{
|
||||||
|
$targetSchoolYear = trim($targetSchoolYear);
|
||||||
|
if ($targetSchoolYear === '' || ! $db->tableExists('classSection') || ! $db->fieldExists('school_year', 'classSection')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||||
|
if ($sourceSchoolYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceRows = $db->table('classSection')
|
||||||
|
->select('class_id, class_section_id, class_section_name')
|
||||||
|
->where('school_year', $sourceSchoolYear)
|
||||||
|
->orderBy('id', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$now = utc_now();
|
||||||
|
foreach ($sourceRows as $row) {
|
||||||
|
$classSectionId = (int)($row['class_section_id'] ?? 0);
|
||||||
|
if ($classSectionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $db->table('classSection')
|
||||||
|
->where('school_year', $targetSchoolYear)
|
||||||
|
->where('class_section_id', $classSectionId)
|
||||||
|
->countAllResults();
|
||||||
|
if ($exists > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->table('classSection')->insert([
|
||||||
|
'class_id' => (int)($row['class_id'] ?? 0),
|
||||||
|
'class_section_id' => $classSectionId,
|
||||||
|
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
||||||
|
'school_year' => $targetSchoolYear,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
$schoolYear = trim($schoolYear);
|
||||||
|
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function save()
|
public function save()
|
||||||
@@ -226,8 +741,46 @@ class AssignmentController extends BaseController
|
|||||||
// API: JSON payload for Classes List page
|
// API: JSON payload for Classes List page
|
||||||
public function classAssignmentData()
|
public function classAssignmentData()
|
||||||
{
|
{
|
||||||
$teacherClassesAll = $this->teacherClassModel->findAll();
|
$year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
|
||||||
$studentClassesAll = $this->studentClassModel->findAll();
|
if ($year === '') {
|
||||||
|
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||||
|
}
|
||||||
|
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
|
||||||
|
$this->repairMissingEnrollmentClassAssignments($year);
|
||||||
|
$distributedSectionIds = array_values(array_unique(array_merge(
|
||||||
|
$this->distributedClassSectionIds($year),
|
||||||
|
$this->enrollmentAssignedClassSectionIds($year)
|
||||||
|
)));
|
||||||
|
|
||||||
|
$classSectionsAll = [];
|
||||||
|
if (!empty($distributedSectionIds)) {
|
||||||
|
$classSectionsAll = $this->classSectionModel
|
||||||
|
->select('id, class_section_id, class_section_name, school_year')
|
||||||
|
->where('school_year', $year)
|
||||||
|
->whereIn('class_section_id', $distributedSectionIds)
|
||||||
|
->orderBy('class_section_name', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$classSectionById = [];
|
||||||
|
foreach ($classSectionsAll as $section) {
|
||||||
|
$sectionId = (int)($section['class_section_id'] ?? 0);
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$classSectionById[$sectionId] = $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tcQ = $this->teacherClassModel;
|
||||||
|
if ($year !== '') {
|
||||||
|
$tcQ = $tcQ->where('school_year', $year);
|
||||||
|
}
|
||||||
|
$teacherClassesAll = $tcQ->findAll();
|
||||||
|
|
||||||
|
$scQ = $this->studentClassModel->active();
|
||||||
|
if ($year !== '') {
|
||||||
|
$scQ = $scQ->where('student_class.school_year', $year);
|
||||||
|
}
|
||||||
|
$studentClassesAll = $scQ->findAll();
|
||||||
|
|
||||||
// Group by section
|
// Group by section
|
||||||
$teacherBySection = [];
|
$teacherBySection = [];
|
||||||
@@ -241,16 +794,22 @@ class AssignmentController extends BaseController
|
|||||||
if ($secId) $studentsBySection[$secId][] = $sc;
|
if ($secId) $studentsBySection[$secId][] = $sc;
|
||||||
}
|
}
|
||||||
|
|
||||||
$allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))));
|
$allSectionIds = array_values(array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection))));
|
||||||
|
$distributedSectionSet = array_fill_keys($distributedSectionIds, true);
|
||||||
|
|
||||||
$classSections = [];
|
$classSections = [];
|
||||||
|
|
||||||
foreach ($allSectionIds as $classSectionId) {
|
foreach ($allSectionIds as $classSectionId) {
|
||||||
|
if (!isset($distributedSectionSet[(int)$classSectionId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$hasTeacher = !empty($teacherBySection[$classSectionId]);
|
$hasTeacher = !empty($teacherBySection[$classSectionId]);
|
||||||
$hasStudents = !empty($studentsBySection[$classSectionId]);
|
$hasStudents = !empty($studentsBySection[$classSectionId]);
|
||||||
if (!$hasTeacher && !$hasStudents) continue;
|
$hasClassSection = isset($classSectionById[(int)$classSectionId]);
|
||||||
|
if (!$hasClassSection && !$hasTeacher && !$hasStudents) continue;
|
||||||
|
|
||||||
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
$classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||||
|
|
||||||
$mainTeachers = [];
|
$mainTeachers = [];
|
||||||
$teacherAssistants = [];
|
$teacherAssistants = [];
|
||||||
@@ -284,12 +843,18 @@ class AssignmentController extends BaseController
|
|||||||
|
|
||||||
// Load students for the section
|
// Load students for the section
|
||||||
$students = [];
|
$students = [];
|
||||||
foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) {
|
$seenStudentIds = [];
|
||||||
|
foreach ($studentsBySection[$classSectionId] ?? [] as $studentClass) {
|
||||||
|
$sid = (int)($studentClass['student_id'] ?? 0);
|
||||||
|
if ($sid <= 0 || isset($seenStudentIds[$sid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$stu = $this->studentModel
|
$stu = $this->studentModel
|
||||||
->where('id', (int)$studentClass['student_id'])
|
->where('id', $sid)
|
||||||
->where('is_active', 1)
|
->where('is_active', 1)
|
||||||
->first();
|
->first();
|
||||||
if (!$stu) continue;
|
if (!$stu) continue;
|
||||||
|
|
||||||
$students[] = [
|
$students[] = [
|
||||||
'id' => (int)$stu['id'],
|
'id' => (int)$stu['id'],
|
||||||
'firstname' => (string)($stu['firstname'] ?? ''),
|
'firstname' => (string)($stu['firstname'] ?? ''),
|
||||||
@@ -301,6 +866,7 @@ class AssignmentController extends BaseController
|
|||||||
'tuition_paid' => (bool)($stu['tuition_paid'] ?? false),
|
'tuition_paid' => (bool)($stu['tuition_paid'] ?? false),
|
||||||
'school_id' => (string)($stu['school_id'] ?? ''),
|
'school_id' => (string)($stu['school_id'] ?? ''),
|
||||||
];
|
];
|
||||||
|
$seenStudentIds[$sid] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$classSections[] = [
|
$classSections[] = [
|
||||||
@@ -316,7 +882,7 @@ class AssignmentController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by class_section_name
|
// Sort by class_section_name
|
||||||
usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
|
usort($classSections, fn($a, $b) => strnatcasecmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
|
||||||
|
|
||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
'classSections' => $classSections,
|
'classSections' => $classSections,
|
||||||
|
|||||||
@@ -63,12 +63,21 @@ class AttendanceController extends Controller
|
|||||||
$this->calendarModel = model(CalendarModel::class);
|
$this->calendarModel = model(CalendarModel::class);
|
||||||
$this->userRoleModel = new UserRoleModel();
|
$this->userRoleModel = new UserRoleModel();
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
$this->semesterScoreService = service('semesterScoreService');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->currentSchoolYearName();
|
||||||
$this->enableAttendance = $this->configModel->getConfig('enable_attendance');
|
$this->enableAttendance = $this->configModel->getConfig('enable_attendance');
|
||||||
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYearName(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: filter + grid for a single date/section.
|
* GET: filter + grid for a single date/section.
|
||||||
@@ -258,9 +267,9 @@ public function showUpdateAttendanceForm()
|
|||||||
$currentSunday = $sundayDates[2];
|
$currentSunday = $sundayDates[2];
|
||||||
|
|
||||||
// Students roster for this section/year/semester (try section code, then PK fallback)
|
// Students roster for this section/year/semester (try section code, then PK fallback)
|
||||||
$students = $this->studentModel->getByClassAndYear($class_section_id, $schoolYear, $semester);
|
$students = $this->studentModel->getActiveByClassAndYear($class_section_id, $schoolYear, $semester);
|
||||||
if (empty($students) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
|
if (empty($students) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
|
||||||
$students = $this->studentModel->getByClassAndYear($classSectionPk, $schoolYear, $semester);
|
$students = $this->studentModel->getActiveByClassAndYear($classSectionPk, $schoolYear, $semester);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build student attendance rows
|
// Build student attendance rows
|
||||||
@@ -525,19 +534,19 @@ public function showUpdateAttendanceForm()
|
|||||||
// If there are no assignments yet for the requested semester (e.g., Spring roster not populated),
|
// If there are no assignments yet for the requested semester (e.g., Spring roster not populated),
|
||||||
// fall back to the school year only so the roster still renders and can be used for the new term.
|
// fall back to the school year only so the roster still renders and can be used for the new term.
|
||||||
$classSections = $this->classSectionModel
|
$classSections = $this->classSectionModel
|
||||||
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
|
->select('MIN(classSection.id) AS id, classSection.class_id, classSection.class_section_id, classSection.class_section_name', false)
|
||||||
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
|
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
|
||||||
->where('sc.school_year', $termYear)
|
->where('sc.school_year', $termYear)
|
||||||
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
|
->groupBy(['classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
$useRosterFallback = false;
|
$useRosterFallback = false;
|
||||||
if (empty($classSections) && $termYear !== '') {
|
if (empty($classSections) && $termYear !== '') {
|
||||||
$classSections = $this->classSectionModel
|
$classSections = $this->classSectionModel
|
||||||
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
|
->select('MIN(classSection.id) AS id, classSection.class_id, classSection.class_section_id, classSection.class_section_name', false)
|
||||||
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
|
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
|
||||||
->where('sc.school_year', $termYear)
|
->where('sc.school_year', $termYear)
|
||||||
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
|
->groupBy(['classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
|
||||||
->findAll();
|
->findAll();
|
||||||
$useRosterFallback = !empty($classSections);
|
$useRosterFallback = !empty($classSections);
|
||||||
}
|
}
|
||||||
@@ -579,15 +588,22 @@ public function showUpdateAttendanceForm()
|
|||||||
}
|
}
|
||||||
|
|
||||||
$hasRoster = false;
|
$hasRoster = false;
|
||||||
|
$seenStudentIds = [];
|
||||||
|
|
||||||
foreach ($students as $sc) {
|
foreach ($students as $sc) {
|
||||||
$studentId = (int)$sc['student_id'];
|
$studentId = (int)$sc['student_id'];
|
||||||
|
if ($studentId <= 0 || isset($seenStudentIds[$studentId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenStudentIds[$studentId] = true;
|
||||||
|
|
||||||
$student = $this->studentModel
|
$student = $this->studentModel
|
||||||
->select('id, firstname, lastname, school_id')
|
->select('id, firstname, lastname, school_id, is_active')
|
||||||
->where('id', $studentId)
|
->where('id', $studentId)
|
||||||
->where('is_active', 1)
|
|
||||||
->first();
|
->first();
|
||||||
if (!$student) continue;
|
if (!$student) continue;
|
||||||
|
$student['enrollment_status'] = $sc['enrollment_status'] ?? '';
|
||||||
|
$student['is_withdrawn'] = (int)($sc['is_withdrawn'] ?? 0);
|
||||||
|
|
||||||
$studentsBySection[$secCode][] = $student;
|
$studentsBySection[$secCode][] = $student;
|
||||||
$hasRoster = true;
|
$hasRoster = true;
|
||||||
@@ -1012,9 +1028,11 @@ public function showUpdateAttendanceForm()
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$student = $this->studentModel
|
$student = $this->studentModel
|
||||||
->select('id, firstname, lastname, school_id')
|
->select('id, firstname, lastname, school_id, is_active')
|
||||||
->find($studentId);
|
->find($studentId);
|
||||||
if (!$student) continue;
|
if (!$student) continue;
|
||||||
|
$student['enrollment_status'] = $sc['enrollment_status'] ?? '';
|
||||||
|
$student['is_withdrawn'] = (int)($sc['is_withdrawn'] ?? 0);
|
||||||
|
|
||||||
$studentsBySection[$secCode][] = $student;
|
$studentsBySection[$secCode][] = $student;
|
||||||
$hasRoster = true;
|
$hasRoster = true;
|
||||||
@@ -1588,7 +1606,7 @@ public function showUpdateAttendanceForm()
|
|||||||
|
|
||||||
// optional score updates (never bubble)
|
// optional score updates (never bubble)
|
||||||
try {
|
try {
|
||||||
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId(
|
$studentUserInfo = $this->studentModel->getActiveStudentInfoByClassSectionId(
|
||||||
$classSectionId,
|
$classSectionId,
|
||||||
$semester,
|
$semester,
|
||||||
$schoolYear
|
$schoolYear
|
||||||
@@ -1707,6 +1725,19 @@ public function showUpdateAttendanceForm()
|
|||||||
$semester = (string)($this->semester ?? '');
|
$semester = (string)($this->semester ?? '');
|
||||||
$schoolYear = (string)($this->schoolYear ?? '');
|
$schoolYear = (string)($this->schoolYear ?? '');
|
||||||
|
|
||||||
|
$activeStudents = $this->studentModel->getActiveByClassAndYear($classSectionId, $schoolYear, $semester);
|
||||||
|
$activeStudentIds = array_fill_keys(array_map(static fn(array $row): int => (int)($row['id'] ?? 0), $activeStudents), true);
|
||||||
|
$attendanceData = array_values(array_filter($attendanceData, static function ($row) use ($activeStudentIds): bool {
|
||||||
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
|
return $sid > 0 && isset($activeStudentIds[$sid]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (empty($attendanceData)) {
|
||||||
|
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
|
||||||
|
->with('status', 'error')
|
||||||
|
->with('message', 'No active student attendance data provided.');
|
||||||
|
}
|
||||||
|
|
||||||
// Existing attendance rows for today (used to lock parent/admin submissions)
|
// Existing attendance rows for today (used to lock parent/admin submissions)
|
||||||
$existingRows = $this->attendanceDataModel
|
$existingRows = $this->attendanceDataModel
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
@@ -2012,7 +2043,7 @@ public function showUpdateAttendanceForm()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
|
$studentUserInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
|
||||||
$this->semesterScoreService->updateScoresForStudents($studentUserInfo);
|
$this->semesterScoreService->updateScoresForStudents($studentUserInfo);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class AttendanceTrackingController extends BaseController
|
|||||||
$this->db = \Config\Database::connect();
|
$this->db = \Config\Database::connect();
|
||||||
$this->notificationModel = new ParentNotificationModel();
|
$this->notificationModel = new ParentNotificationModel();
|
||||||
$this->attendanceEmailTemplateModel = new AttendanceEmailTemplateModel();
|
$this->attendanceEmailTemplateModel = new AttendanceEmailTemplateModel();
|
||||||
$this->semester = (string) $this->configModel->getConfig('semester');
|
$this->semester = (string) getSemester();
|
||||||
$this->schoolYear = (string) $this->configModel->getConfig('school_year');
|
$this->schoolYear = (string) $this->configModel->getConfig('school_year');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,8 @@ class AttendanceTrackingController extends BaseController
|
|||||||
$syParam = $this->request->getGet('school_year');
|
$syParam = $this->request->getGet('school_year');
|
||||||
$semParam = $this->request->getGet('semester');
|
$semParam = $this->request->getGet('semester');
|
||||||
$schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear;
|
$schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear;
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null; // semester filter disabled
|
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||||
|
$semesterWasRequested = is_string($semParam) && $semParam !== '';
|
||||||
|
|
||||||
$debugInfo = [
|
$debugInfo = [
|
||||||
'school_year_param' => $schoolYear,
|
'school_year_param' => $schoolYear,
|
||||||
@@ -70,28 +71,6 @@ class AttendanceTrackingController extends BaseController
|
|||||||
->findAll();
|
->findAll();
|
||||||
$debugInfo['class_students'] = count($classStudents);
|
$debugInfo['class_students'] = count($classStudents);
|
||||||
|
|
||||||
// Fallback: if the configured school_year has no class assignments, look up the latest term
|
|
||||||
if (empty($classStudents)) {
|
|
||||||
$latestTerm = $this->db->table('student_class')
|
|
||||||
->select('school_year, semester')
|
|
||||||
->orderBy('id', 'DESC')
|
|
||||||
->limit(1)
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
|
|
||||||
if ($latestTerm && !empty($latestTerm['school_year'])) {
|
|
||||||
$schoolYear = (string)$latestTerm['school_year'];
|
|
||||||
if (!empty($latestTerm['semester'])) {
|
|
||||||
$semester = (string)$latestTerm['semester'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$classStudents = $this->studentClassModel
|
|
||||||
->active()
|
|
||||||
->where('student_class.school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather candidate student identifiers (numeric IDs and code-like IDs)
|
// Gather candidate student identifiers (numeric IDs and code-like IDs)
|
||||||
$studentIds = [];
|
$studentIds = [];
|
||||||
$studentCodes = [];
|
$studentCodes = [];
|
||||||
@@ -347,7 +326,8 @@ class AttendanceTrackingController extends BaseController
|
|||||||
'semester' => $semester,
|
'semester' => $semester,
|
||||||
'student_ids' => $studentIds,
|
'student_ids' => $studentIds,
|
||||||
]);
|
]);
|
||||||
// Second chance: detect the latest attendance_data term for these students
|
// Second chance: detect the latest attendance_data term only when the user has not
|
||||||
|
// requested a semester and the date-based current semester is unavailable.
|
||||||
$latestAttendanceTerm = $this->db->table('attendance_data')
|
$latestAttendanceTerm = $this->db->table('attendance_data')
|
||||||
->select('school_year, semester, date')
|
->select('school_year, semester, date')
|
||||||
->whereIn('student_id', $studentIds)
|
->whereIn('student_id', $studentIds)
|
||||||
@@ -356,7 +336,7 @@ class AttendanceTrackingController extends BaseController
|
|||||||
->get()
|
->get()
|
||||||
->getRowArray();
|
->getRowArray();
|
||||||
|
|
||||||
if ($latestAttendanceTerm) {
|
if ($latestAttendanceTerm && !$semesterWasRequested && trim($semester) === '') {
|
||||||
$schoolYear = !empty($latestAttendanceTerm['school_year'])
|
$schoolYear = !empty($latestAttendanceTerm['school_year'])
|
||||||
? (string)$latestAttendanceTerm['school_year']
|
? (string)$latestAttendanceTerm['school_year']
|
||||||
: $schoolYear;
|
: $schoolYear;
|
||||||
@@ -2162,7 +2142,7 @@ class AttendanceTrackingController extends BaseController
|
|||||||
$wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody);
|
$wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody);
|
||||||
|
|
||||||
// Resolve term (prefer controller properties if set)
|
// Resolve term (prefer controller properties if set)
|
||||||
$semester = $this->semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester');
|
$semester = $this->semester ?? getSemester();
|
||||||
$schoolYear = $this->schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year');
|
$schoolYear = $this->schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class AuthorizedUsersController extends ResourceController
|
|||||||
protected $userModel;
|
protected $userModel;
|
||||||
protected $authorizedUserModel;
|
protected $authorizedUserModel;
|
||||||
|
|
||||||
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->userModel = new UserModel();
|
$this->userModel = new UserModel();
|
||||||
$this->authorizedUserModel = new AuthorizedUserModel();
|
$this->authorizedUserModel = new AuthorizedUserModel();
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ class BadgesController extends PrintablesBaseController
|
|||||||
$roleResolved = $postedRole !== null ? $postedRole : $resolveRole($info);
|
$roleResolved = $postedRole !== null ? $postedRole : $resolveRole($info);
|
||||||
$roleResolved = $formatRole((string)$roleResolved);
|
$roleResolved = $formatRole((string)$roleResolved);
|
||||||
$info['role_resolved'] = $roleResolved;
|
$info['role_resolved'] = $roleResolved;
|
||||||
|
if ($postedRole !== null && trim((string)$postedRole) !== '') {
|
||||||
|
$info['roles_raw'] = $roleResolved;
|
||||||
|
$info['role_name_raw'] = $roleResolved;
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer posted class name if provided (keeps what the user saw)
|
// Prefer posted class name if provided (keeps what the user saw)
|
||||||
if (!empty($classesMap[$userId])) {
|
if (!empty($classesMap[$userId])) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class CertificateController extends BaseController
|
|||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||||
->where('s.is_active', 1)
|
->where('s.is_active', 1)
|
||||||
->where('sc.school_year', $schoolYear)
|
->where('sc.school_year', $schoolYear)
|
||||||
|
->groupBy('s.id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name')
|
||||||
->orderBy('s.firstname', 'ASC')
|
->orderBy('s.firstname', 'ASC')
|
||||||
->orderBy('s.lastname', 'ASC')
|
->orderBy('s.lastname', 'ASC')
|
||||||
->get()
|
->get()
|
||||||
@@ -105,10 +106,18 @@ class CertificateController extends BaseController
|
|||||||
// ── Build per-class buckets and stats ──────────────────────────────────
|
// ── Build per-class buckets and stats ──────────────────────────────────
|
||||||
$studentsByClass = [];
|
$studentsByClass = [];
|
||||||
$statsPerClass = [];
|
$statsPerClass = [];
|
||||||
|
$seenEnrollments = [];
|
||||||
|
|
||||||
foreach ($allEnrolled as $row) {
|
foreach ($allEnrolled as $row) {
|
||||||
$sid = (int)$row['student_id'];
|
$sid = (int)$row['student_id'];
|
||||||
$csid = (int)$row['class_section_id'];
|
$csid = (int)$row['class_section_id'];
|
||||||
|
$enrollmentKey = $csid . ':' . $sid;
|
||||||
|
|
||||||
|
if (isset($seenEnrollments[$enrollmentKey])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenEnrollments[$enrollmentKey] = true;
|
||||||
|
|
||||||
if (!isset($statsPerClass[$csid])) {
|
if (!isset($statsPerClass[$csid])) {
|
||||||
$statsPerClass[$csid] = [
|
$statsPerClass[$csid] = [
|
||||||
@@ -293,7 +302,7 @@ class CertificateController extends BaseController
|
|||||||
->with('error', 'Please select at least one student.');
|
->with('error', 'Please select at least one student.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$studentIds = array_filter(array_map('intval', $studentIds));
|
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
|
||||||
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
|
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
|
||||||
|
|
||||||
if (empty($studentIds)) {
|
if (empty($studentIds)) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class ClassController extends BaseController
|
|||||||
|
|
||||||
protected $db;
|
protected $db;
|
||||||
|
|
||||||
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->db = \Config\Database::connect();
|
$this->db = \Config\Database::connect();
|
||||||
$this->classsectionModel = new ClassSectionModel();
|
$this->classsectionModel = new ClassSectionModel();
|
||||||
@@ -29,7 +29,7 @@ class ClassController extends BaseController
|
|||||||
$this->configModel = new ConfigurationModel();
|
$this->configModel = new ConfigurationModel();
|
||||||
|
|
||||||
// Get the semester from the configuration table
|
// Get the semester from the configuration table
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ class ClassPrepController extends PrintablesBaseController
|
|||||||
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||||
|
|
||||||
$b = $this->db->table('student_class sc')
|
$b = $this->db->table('student_class sc')
|
||||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
->select('sc.student_id, s.firstname, s.lastname, COALESCE(sys.is_new, s.is_new, 1) AS is_new, cs.class_section_name AS grade_label', false)
|
||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
->join('students s', 's.id = sc.student_id', 'inner')
|
||||||
|
->join('student_year_status sys', 'sys.student_id = s.id AND sys.school_year = sc.school_year', 'left')
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||||
->join('classes c', 'c.id = cs.class_id', 'inner')
|
->join('classes c', 'c.id = cs.class_id', 'inner')
|
||||||
->where('sc.school_year', $schoolYear);
|
->where('sc.school_year', $schoolYear);
|
||||||
@@ -122,14 +123,9 @@ class ClassPrepController extends PrintablesBaseController
|
|||||||
|
|
||||||
// --- Pull roster: student_class -> classSection -> classes -> students ---
|
// --- Pull roster: student_class -> classSection -> classes -> students ---
|
||||||
$builder = $this->db->table('student_class sc')
|
$builder = $this->db->table('student_class sc')
|
||||||
->select([
|
->select('sc.student_id, s.firstname, s.lastname, COALESCE(sys.is_new, s.is_new, 1) AS is_new, cs.class_section_name AS grade_label', false)
|
||||||
'sc.student_id',
|
|
||||||
's.firstname',
|
|
||||||
's.lastname',
|
|
||||||
's.is_new',
|
|
||||||
'cs.class_section_name AS grade_label',
|
|
||||||
])
|
|
||||||
->join('students s', 's.id = sc.student_id')
|
->join('students s', 's.id = sc.student_id')
|
||||||
|
->join('student_year_status sys', 'sys.student_id = s.id AND sys.school_year = sc.school_year', 'left')
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||||
->join('classes c', 'c.id = cs.class_id')
|
->join('classes c', 'c.id = cs.class_id')
|
||||||
->where('sc.school_year', $schoolYear);
|
->where('sc.school_year', $schoolYear);
|
||||||
@@ -217,8 +213,9 @@ class ClassPrepController extends PrintablesBaseController
|
|||||||
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||||
|
|
||||||
$b = $this->db->table('student_class sc')
|
$b = $this->db->table('student_class sc')
|
||||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
->select('sc.student_id, s.firstname, s.lastname, COALESCE(sys.is_new, s.is_new, 1) AS is_new, cs.class_section_name AS grade_label', false)
|
||||||
->join('students s', 's.id = sc.student_id')
|
->join('students s', 's.id = sc.student_id')
|
||||||
|
->join('student_year_status sys', 'sys.student_id = s.id AND sys.school_year = sc.school_year', 'left')
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||||
->join('classes c', 'c.id = cs.class_id')
|
->join('classes c', 'c.id = cs.class_id')
|
||||||
->where('sc.school_year', $schoolYear)
|
->where('sc.school_year', $schoolYear)
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ class ClassPreparationController extends BaseController
|
|||||||
protected $schoolYear;
|
protected $schoolYear;
|
||||||
protected $semester;
|
protected $semester;
|
||||||
protected $adjustmentModel;
|
protected $adjustmentModel;
|
||||||
/** cache for roster presence per term */
|
/** Cache for roster presence per school year. */
|
||||||
private array $rosterPresenceCache = [];
|
private array $rosterPresenceCache = [];
|
||||||
|
/** Cache table-column checks to avoid repeated schema queries. */
|
||||||
|
private array $columnExistsCache = [];
|
||||||
// Inside ClassPreparationController (class scope, not inside a method)
|
// Inside ClassPreparationController (class scope, not inside a method)
|
||||||
private array $allowedPrepCategories = [
|
private array $allowedPrepCategories = [
|
||||||
'Grade Box',
|
'Grade Box',
|
||||||
@@ -48,7 +50,7 @@ class ClassPreparationController extends BaseController
|
|||||||
$this->db = \Config\Database::connect();
|
$this->db = \Config\Database::connect();
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
@@ -57,7 +59,7 @@ class ClassPreparationController extends BaseController
|
|||||||
$semParam = $this->request->getGet('semester');
|
$semParam = $this->request->getGet('semester');
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||||
$allowed = $this->allowedPrepCategories;
|
$allowed = $this->allowedPrepCategories;
|
||||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
$hasRoster = $this->hasRosterForSchoolYear($schoolYear);
|
||||||
|
|
||||||
// 1) Get student count per class-section (distinct students, correct semester)
|
// 1) Get student count per class-section (distinct students, correct semester)
|
||||||
$scQ = $this->db->table('student_class sc')
|
$scQ = $this->db->table('student_class sc')
|
||||||
@@ -65,13 +67,12 @@ class ClassPreparationController extends BaseController
|
|||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
->join('students s', 's.id = sc.student_id', 'inner')
|
||||||
->where('s.is_active', 1)
|
->where('s.is_active', 1)
|
||||||
->where('sc.school_year', $schoolYear);
|
->where('sc.school_year', $schoolYear);
|
||||||
if ($limitToSemester && $semester !== '') {
|
$classSections = $hasRoster
|
||||||
$scQ->where('sc.semester', $semester);
|
? $scQ->groupBy('sc.class_section_id')->get()->getResultArray()
|
||||||
}
|
: [];
|
||||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
|
||||||
|
|
||||||
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
|
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
|
||||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $allowed);
|
||||||
|
|
||||||
// Seed totals with allowed categories for stable ordering
|
// Seed totals with allowed categories for stable ordering
|
||||||
$requiredTotals = array_fill_keys($allowed, 0);
|
$requiredTotals = array_fill_keys($allowed, 0);
|
||||||
@@ -85,14 +86,10 @@ class ClassPreparationController extends BaseController
|
|||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||||
|
|
||||||
// Calculate required items (whitelist inside)
|
// Calculate required items (whitelist inside)
|
||||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId, $schoolYear);
|
||||||
|
|
||||||
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
|
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
|
||||||
$rawAdjustments = $this->adjustmentModel
|
$rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear);
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||||
|
|
||||||
@@ -117,11 +114,7 @@ class ClassPreparationController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5) Compare with last snapshot; do not save here — only on print or explicit API
|
// 5) Compare with last snapshot; do not save here — only on print or explicit API
|
||||||
$oldSnap = $this->prepLogModel
|
$oldSnap = $this->getLatestPrepSnapshot((string) $classSectionId, (string) $schoolYear);
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
||||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||||
@@ -175,7 +168,7 @@ class ClassPreparationController extends BaseController
|
|||||||
return (int)($row['cnt'] ?? 0);
|
return (int)($row['cnt'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
|
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId, ?string $schoolYear = null): array
|
||||||
{
|
{
|
||||||
// 1) Stable keys: your whitelist, all zero to start
|
// 1) Stable keys: your whitelist, all zero to start
|
||||||
$allowed = $this->allowedPrepCategories;
|
$allowed = $this->allowedPrepCategories;
|
||||||
@@ -192,7 +185,7 @@ class ClassPreparationController extends BaseController
|
|||||||
|
|
||||||
// 3) Rules
|
// 3) Rules
|
||||||
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
||||||
$teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear);
|
$teacherCount = $this->getTeacherCountForSection($classSectionId, $schoolYear ?? $this->schoolYear);
|
||||||
|
|
||||||
foreach ($categories as $cat) {
|
foreach ($categories as $cat) {
|
||||||
$name = $cat['name']; // e.g., 'Small Table'
|
$name = $cat['name']; // e.g., 'Small Table'
|
||||||
@@ -253,24 +246,35 @@ class ClassPreparationController extends BaseController
|
|||||||
$adjustments = $data['adjustments'] ?? [];
|
$adjustments = $data['adjustments'] ?? [];
|
||||||
|
|
||||||
foreach ($adjustments as $itemName => $adjustment) {
|
foreach ($adjustments as $itemName => $adjustment) {
|
||||||
$row = $this->adjustmentModel
|
$adjustmentQuery = $this->adjustmentModel
|
||||||
->where('class_section_id', $sectionId)
|
->where('class_section_id', $sectionId)
|
||||||
->where('school_year', $schoolYear)
|
->where('item_name', $itemName);
|
||||||
->where('item_name', $itemName)
|
|
||||||
->first();
|
$adjustmentTable = $this->adjustmentModel->getTable();
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && $this->tableHasColumn($adjustmentTable, 'school_year')) {
|
||||||
|
$adjustmentQuery->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $adjustmentQuery->first();
|
||||||
|
|
||||||
if ($row) {
|
if ($row) {
|
||||||
// Update
|
// Update
|
||||||
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
|
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
|
||||||
} else {
|
} else {
|
||||||
// Insert
|
// Insert
|
||||||
$this->adjustmentModel->insert([
|
$insertData = [
|
||||||
'class_section_id' => $sectionId,
|
'class_section_id' => $sectionId,
|
||||||
'item_name' => $itemName,
|
'item_name' => $itemName,
|
||||||
'adjustment' => (int)$adjustment,
|
'adjustment' => (int) $adjustment,
|
||||||
'school_year' => $schoolYear,
|
|
||||||
'adjustable' => 1,
|
'adjustable' => 1,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && $this->tableHasColumn($adjustmentTable, 'school_year')) {
|
||||||
|
$insertData['school_year'] = $schoolYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->adjustmentModel->insert($insertData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,10 +296,6 @@ class ClassPreparationController extends BaseController
|
|||||||
|
|
||||||
public function print($classSectionId, $schoolYear)
|
public function print($classSectionId, $schoolYear)
|
||||||
{
|
{
|
||||||
$semParam = $this->request->getGet('semester');
|
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester;
|
|
||||||
$limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester);
|
|
||||||
|
|
||||||
// Friendly label (if you have this helper)
|
// Friendly label (if you have this helper)
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||||
|
|
||||||
@@ -306,15 +306,12 @@ class ClassPreparationController extends BaseController
|
|||||||
->where('s.is_active', 1)
|
->where('s.is_active', 1)
|
||||||
->where('sc.class_section_id', $classSectionId)
|
->where('sc.class_section_id', $classSectionId)
|
||||||
->where('sc.school_year', $schoolYear);
|
->where('sc.school_year', $schoolYear);
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$studentQ->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
$studentRow = $studentQ->get()->getRowArray();
|
$studentRow = $studentQ->get()->getRowArray();
|
||||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||||
|
|
||||||
// Live calc + adjustments
|
// Live calc + adjustments
|
||||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId, (string)$schoolYear);
|
||||||
|
|
||||||
$rawAdjustments = $this->adjustmentModel
|
$rawAdjustments = $this->adjustmentModel
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
@@ -328,13 +325,15 @@ class ClassPreparationController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Record a snapshot at print time as the new baseline
|
// Record a snapshot at print time as the new baseline
|
||||||
$this->prepLogModel->insert([
|
$this->prepLogModel->insert(
|
||||||
'class_section_id' => (string)$classSectionId,
|
$this->buildPrepLogData(
|
||||||
'class_section' => $className,
|
(string) $classSectionId,
|
||||||
'school_year' => $schoolYear,
|
(string) $className,
|
||||||
'prep_data' => json_encode($items),
|
(string) $schoolYear,
|
||||||
'created_at' => utc_now(),
|
$items,
|
||||||
]);
|
utc_now()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Return PRINT view (HTML) that auto-opens the print dialog
|
// Return PRINT view (HTML) that auto-opens the print dialog
|
||||||
return view('class_prep/print', [
|
return view('class_prep/print', [
|
||||||
@@ -356,7 +355,7 @@ class ClassPreparationController extends BaseController
|
|||||||
$semParam = $this->request->getGet('semester');
|
$semParam = $this->request->getGet('semester');
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||||
$allowed = $this->allowedPrepCategories;
|
$allowed = $this->allowedPrepCategories;
|
||||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
$hasRoster = $this->hasRosterForSchoolYear($schoolYear);
|
||||||
|
|
||||||
// Student counts
|
// Student counts
|
||||||
$scQ = $this->db->table('student_class sc')
|
$scQ = $this->db->table('student_class sc')
|
||||||
@@ -364,13 +363,12 @@ class ClassPreparationController extends BaseController
|
|||||||
->join('students s', 's.id = sc.student_id', 'inner')
|
->join('students s', 's.id = sc.student_id', 'inner')
|
||||||
->where('s.is_active', 1)
|
->where('s.is_active', 1)
|
||||||
->where('sc.school_year', $schoolYear);
|
->where('sc.school_year', $schoolYear);
|
||||||
if ($limitToSemester && $semester !== '') {
|
$classSections = $hasRoster
|
||||||
$scQ->where('sc.semester', $semester);
|
? $scQ->groupBy('sc.class_section_id')->get()->getResultArray()
|
||||||
}
|
: [];
|
||||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
|
||||||
|
|
||||||
// Build inventory availability maps
|
// Build inventory availability maps
|
||||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $allowed);
|
||||||
|
|
||||||
$requiredTotals = array_fill_keys($allowed, 0);
|
$requiredTotals = array_fill_keys($allowed, 0);
|
||||||
$results = [];
|
$results = [];
|
||||||
@@ -381,12 +379,8 @@ class ClassPreparationController extends BaseController
|
|||||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
$classLevel = $this->getClassLevelBySection($classSectionId);
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||||
|
|
||||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId, $schoolYear);
|
||||||
$rawAdjustments = $this->adjustmentModel
|
$rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear);
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||||
foreach ($rawAdjustments as $a) {
|
foreach ($rawAdjustments as $a) {
|
||||||
$item = $a['item_name'];
|
$item = $a['item_name'];
|
||||||
@@ -399,11 +393,7 @@ class ClassPreparationController extends BaseController
|
|||||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldSnap = $this->prepLogModel
|
$oldSnap = $this->getLatestPrepSnapshot((string) $classSectionId, (string) $schoolYear);
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->first();
|
|
||||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
||||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||||
|
|
||||||
@@ -446,7 +436,6 @@ class ClassPreparationController extends BaseController
|
|||||||
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
|
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
|
||||||
$semParam = $this->request->getPost('semester');
|
$semParam = $this->request->getPost('semester');
|
||||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
|
||||||
$ids = $this->request->getPost('class_section_ids') ?? [];
|
$ids = $this->request->getPost('class_section_ids') ?? [];
|
||||||
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
||||||
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
|
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
|
||||||
@@ -460,21 +449,14 @@ class ClassPreparationController extends BaseController
|
|||||||
->where('s.is_active', 1)
|
->where('s.is_active', 1)
|
||||||
->where('sc.class_section_id', $classSectionId)
|
->where('sc.class_section_id', $classSectionId)
|
||||||
->where('sc.school_year', $schoolYear);
|
->where('sc.school_year', $schoolYear);
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$studentQ->where('sc.semester', $semester);
|
|
||||||
}
|
|
||||||
$studentRow = $studentQ->get()->getRowArray();
|
$studentRow = $studentQ->get()->getRowArray();
|
||||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||||
|
|
||||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId, (string)$schoolYear);
|
||||||
|
|
||||||
$rawAdjustments = $this->adjustmentModel
|
$rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear);
|
||||||
->where('class_section_id', $classSectionId)
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->where('adjustable', 1)
|
|
||||||
->findAll();
|
|
||||||
foreach ($rawAdjustments as $a) {
|
foreach ($rawAdjustments as $a) {
|
||||||
$item = $a['item_name'];
|
$item = $a['item_name'];
|
||||||
$delta = (int)$a['adjustment'];
|
$delta = (int)$a['adjustment'];
|
||||||
@@ -482,13 +464,15 @@ class ClassPreparationController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->prepLogModel->insert([
|
$this->prepLogModel->insert(
|
||||||
'class_section_id' => (string)$classSectionId,
|
$this->buildPrepLogData(
|
||||||
'class_section' => $className,
|
(string) $classSectionId,
|
||||||
'school_year' => $schoolYear,
|
(string) $className,
|
||||||
'prep_data' => json_encode($items),
|
(string) $schoolYear,
|
||||||
'created_at' => $now,
|
$items,
|
||||||
]);
|
$now
|
||||||
|
)
|
||||||
|
);
|
||||||
$count++;
|
$count++;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// ignore and continue
|
// ignore and continue
|
||||||
@@ -528,12 +512,12 @@ class ClassPreparationController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if student_class has any rows for the given school year.
|
* Returns true when student_class contains at least one assignment
|
||||||
* The semester argument is ignored because assignments are tracked per year.
|
* for the selected school year.
|
||||||
*/
|
*/
|
||||||
private function hasRosterForTerm(string $schoolYear, string $semester): bool
|
private function hasRosterForSchoolYear(string $schoolYear): bool
|
||||||
{
|
{
|
||||||
$year = trim((string)$schoolYear);
|
$year = trim($schoolYear);
|
||||||
if ($year === '') {
|
if ($year === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -542,72 +526,163 @@ class ClassPreparationController extends BaseController
|
|||||||
return $this->rosterPresenceCache[$year];
|
return $this->rosterPresenceCache[$year];
|
||||||
}
|
}
|
||||||
|
|
||||||
$cnt = $this->db->table('student_class')
|
$exists = $this->db->table('student_class')
|
||||||
->where('school_year', $year)
|
->where('school_year', $year)
|
||||||
->countAllResults();
|
->limit(1)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
$this->rosterPresenceCache[$year] = $cnt > 0;
|
$this->rosterPresenceCache[$year] = $exists;
|
||||||
return $this->rosterPresenceCache[$year];
|
|
||||||
|
return $exists;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hasRosterForSemester(string $schoolYear, string $semester): bool
|
private function tableHasColumn(string $table, string $column): bool
|
||||||
{
|
{
|
||||||
$year = trim((string)$schoolYear);
|
$key = $table . '.' . $column;
|
||||||
$sem = trim((string)$semester);
|
|
||||||
if ($year === '' || $sem === '') {
|
if (!array_key_exists($key, $this->columnExistsCache)) {
|
||||||
return false;
|
$this->columnExistsCache[$key] = $this->db->fieldExists($column, $table);
|
||||||
}
|
}
|
||||||
|
|
||||||
$key = sprintf('sem:%s:%s', $year, $sem);
|
return $this->columnExistsCache[$key];
|
||||||
if (array_key_exists($key, $this->rosterPresenceCache)) {
|
|
||||||
return $this->rosterPresenceCache[$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnt = $this->db->table('student_class')
|
|
||||||
->where('school_year', $year)
|
|
||||||
->where('semester', $sem)
|
|
||||||
->countAllResults();
|
|
||||||
|
|
||||||
$this->rosterPresenceCache[$key] = $cnt > 0;
|
|
||||||
return $this->rosterPresenceCache[$key];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
|
private function getAdjustments(string $classSectionId, string $schoolYear): array
|
||||||
|
{
|
||||||
|
$query = $this->adjustmentModel
|
||||||
|
->where('class_section_id', $classSectionId)
|
||||||
|
->where('adjustable', 1);
|
||||||
|
|
||||||
|
$table = $this->adjustmentModel->getTable();
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) {
|
||||||
|
$query->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLatestPrepSnapshot(string $classSectionId, string $schoolYear): ?array
|
||||||
|
{
|
||||||
|
$query = $this->prepLogModel
|
||||||
|
->where('class_section_id', $classSectionId);
|
||||||
|
|
||||||
|
$table = $this->prepLogModel->getTable();
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) {
|
||||||
|
$query->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPrepLogData(
|
||||||
|
string $classSectionId,
|
||||||
|
string $className,
|
||||||
|
string $schoolYear,
|
||||||
|
array $items,
|
||||||
|
string $createdAt
|
||||||
|
): array {
|
||||||
|
$data = [
|
||||||
|
'class_section_id' => $classSectionId,
|
||||||
|
'class_section' => $className,
|
||||||
|
'prep_data' => json_encode($items),
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
$table = $this->prepLogModel->getTable();
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) {
|
||||||
|
$data['school_year'] = $schoolYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInventoryAvailability(string $schoolYear, string $semester, array $allowed): array
|
||||||
{
|
{
|
||||||
$inventoryMap = array_fill_keys($allowed, 0);
|
$inventoryMap = array_fill_keys($allowed, 0);
|
||||||
|
|
||||||
|
$hasName = $this->tableHasColumn('inventory_items', 'name');
|
||||||
|
|
||||||
$joinRows = $this->db->table('inventory_items ii')
|
$joinRows = $this->db->table('inventory_items ii')
|
||||||
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
|
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition` = "good" THEN ii.quantity ELSE 0 END), 0) AS available', false)
|
||||||
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
|
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
|
||||||
->where('ii.type', 'classroom')
|
->where('ii.type', 'classroom')
|
||||||
->where('ii.school_year', $schoolYear)
|
|
||||||
->whereIn('ic.name', $allowed);
|
->whereIn('ic.name', $allowed);
|
||||||
if ($limitToSemester && $semester !== '') {
|
|
||||||
$joinRows->where('ii.semester', $semester);
|
|
||||||
}
|
|
||||||
$joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray();
|
|
||||||
|
|
||||||
foreach ($joinRows as $r) {
|
$periodClauses = ['im.item_id = ii.id'];
|
||||||
$name = (string)($r['item_name'] ?? '');
|
if ($schoolYear !== '') {
|
||||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
$periodClauses[] = 'im.school_year = ' . $this->db->escape($schoolYear);
|
||||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
}
|
||||||
|
if ($semester !== '') {
|
||||||
|
$periodClauses[] = 'im.semester = ' . $this->db->escape($semester);
|
||||||
|
}
|
||||||
|
if (count($periodClauses) > 1) {
|
||||||
|
$joinRows->where(
|
||||||
|
'EXISTS (SELECT 1 FROM inventory_movements im WHERE ' . implode(' AND ', $periodClauses) . ')',
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$joinRows = $joinRows
|
||||||
|
->groupBy('ic.name')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($joinRows as $row) {
|
||||||
|
$name = (string) ($row['item_name'] ?? '');
|
||||||
|
|
||||||
|
if ($name !== '' && array_key_exists($name, $inventoryMap)) {
|
||||||
|
$inventoryMap[$name] = max(
|
||||||
|
$inventoryMap[$name],
|
||||||
|
(int) ($row['available'] ?? 0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$nameRows = $this->db->table('inventory_items')
|
/*
|
||||||
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
|
* Some older schemas store the item name directly on inventory_items.
|
||||||
->where('type', 'classroom')
|
* Only run this fallback when that column actually exists.
|
||||||
->where('school_year', $schoolYear)
|
*/
|
||||||
->whereIn('name', $allowed);
|
if ($hasName) {
|
||||||
if ($limitToSemester && $semester !== '') {
|
$nameRows = $this->db->table('inventory_items ii_name')
|
||||||
$nameRows->where('semester', $semester);
|
->select('ii_name.name AS item_name, COALESCE(SUM(CASE WHEN ii_name.good_qty IS NOT NULL THEN ii_name.good_qty WHEN ii_name.`condition` = "good" THEN ii_name.quantity ELSE 0 END), 0) AS available', false)
|
||||||
}
|
->where('ii_name.type', 'classroom')
|
||||||
$nameRows = $nameRows->groupBy('name')->get()->getResultArray();
|
->whereIn('ii_name.name', $allowed);
|
||||||
|
|
||||||
foreach ($nameRows as $r) {
|
$fallbackPeriodClauses = ['im.item_id = ii_name.id'];
|
||||||
$name = (string)($r['item_name'] ?? '');
|
if ($schoolYear !== '') {
|
||||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
$fallbackPeriodClauses[] = 'im.school_year = ' . $this->db->escape($schoolYear);
|
||||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
}
|
||||||
|
if ($semester !== '') {
|
||||||
|
$fallbackPeriodClauses[] = 'im.semester = ' . $this->db->escape($semester);
|
||||||
|
}
|
||||||
|
if (count($fallbackPeriodClauses) > 1) {
|
||||||
|
$nameRows->where(
|
||||||
|
'EXISTS (SELECT 1 FROM inventory_movements im WHERE ' . implode(' AND ', $fallbackPeriodClauses) . ')',
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameRows = $nameRows
|
||||||
|
->groupBy('ii_name.name')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($nameRows as $row) {
|
||||||
|
$name = (string) ($row['item_name'] ?? '');
|
||||||
|
|
||||||
|
if ($name !== '' && array_key_exists($name, $inventoryMap)) {
|
||||||
|
$inventoryMap[$name] = max(
|
||||||
|
$inventoryMap[$name],
|
||||||
|
(int) ($row['available'] ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ class CompetitionScoresController extends BaseController
|
|||||||
{
|
{
|
||||||
$userId = (int) (session()->get('user_id') ?? 0);
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
$semester = (string) (getSemester() ?? '');
|
||||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||||
$userId,
|
$userId,
|
||||||
$schoolYear,
|
$schoolYear,
|
||||||
@@ -386,12 +386,14 @@ class CompetitionScoresController extends BaseController
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = $this->db->table($this->classStudentTable)
|
$builder = $this->db->table($this->classStudentTable . ' cs')
|
||||||
->select('COUNT(*) AS total')
|
->select('COUNT(*) AS total')
|
||||||
->where('class_section_id', $classSectionId);
|
->join('students s', 's.id = cs.student_id', 'inner')
|
||||||
|
->where('cs.class_section_id', $classSectionId)
|
||||||
|
->where('s.is_active', 1);
|
||||||
|
|
||||||
if ($schoolYear && $this->db->fieldExists('school_year', $this->classStudentTable)) {
|
if ($schoolYear && $this->db->fieldExists('school_year', $this->classStudentTable)) {
|
||||||
$builder->where('school_year', $schoolYear);
|
$builder->where('cs.school_year', $schoolYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = $builder->get()->getRowArray();
|
$row = $builder->get()->getRowArray();
|
||||||
@@ -407,7 +409,8 @@ class CompetitionScoresController extends BaseController
|
|||||||
$builder = $this->db->table('students s')
|
$builder = $this->db->table('students s')
|
||||||
->select('s.id, s.school_id, s.firstname, s.lastname')
|
->select('s.id, s.school_id, s.firstname, s.lastname')
|
||||||
->join($this->classStudentTable . ' cs', 'cs.student_id = s.id', 'inner')
|
->join($this->classStudentTable . ' cs', 'cs.student_id = s.id', 'inner')
|
||||||
->where('cs.class_section_id', $classSectionId);
|
->where('cs.class_section_id', $classSectionId)
|
||||||
|
->where('s.is_active', 1);
|
||||||
|
|
||||||
$hasSchoolYear = $this->db->fieldExists('school_year', $this->classStudentTable);
|
$hasSchoolYear = $this->db->fieldExists('school_year', $this->classStudentTable);
|
||||||
if ($hasSchoolYear && !empty($competition['school_year'])) {
|
if ($hasSchoolYear && !empty($competition['school_year'])) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Controllers\BaseController;
|
|||||||
|
|
||||||
class ContactController extends BaseController
|
class ContactController extends BaseController
|
||||||
{
|
{
|
||||||
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
|
public function __construct()
|
||||||
{
|
{
|
||||||
helper('form'); // Load the form helper
|
helper('form'); // Load the form helper
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ class DiscountController extends BaseController
|
|||||||
$this->classSectionModel = new ClassSectionModel();
|
$this->classSectionModel = new ClassSectionModel();
|
||||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->activeSchoolYearName();
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyVoucher()
|
public function applyVoucher()
|
||||||
{
|
{
|
||||||
|
$this->schoolYear = $this->activeSchoolYearName();
|
||||||
|
|
||||||
if (strtolower($this->request->getMethod()) === 'post') {
|
if (strtolower($this->request->getMethod()) === 'post') {
|
||||||
$voucherId = $this->request->getPost('voucher_id');
|
$voucherId = $this->request->getPost('voucher_id');
|
||||||
$parentIds = $this->request->getPost('parent_ids') ?? [];
|
$parentIds = $this->request->getPost('parent_ids') ?? [];
|
||||||
@@ -108,9 +110,41 @@ class DiscountController extends BaseController
|
|||||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
// Collect invoice IDs that end up fully covered by the voucher in this run
|
||||||
$fullyCoveredInvoiceIds = [];
|
$fullyCoveredInvoiceIds = [];
|
||||||
$touchedInvoiceIds = [];
|
$touchedInvoiceIds = [];
|
||||||
|
$pendingEvents = [];
|
||||||
$appliedCount = 0;
|
$appliedCount = 0;
|
||||||
|
|
||||||
$this->db->transStart();
|
$this->db->transBegin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lockedVoucher = $this->db->query('SELECT * FROM discount_vouchers WHERE id = ? FOR UPDATE', [(int)$voucherId])->getRowArray();
|
||||||
|
if (!$lockedVoucher) {
|
||||||
|
throw new \RuntimeException('Voucher not found.');
|
||||||
|
}
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
if ((int)($lockedVoucher['is_active'] ?? 0) !== 1) {
|
||||||
|
throw new \RuntimeException('Voucher is inactive.');
|
||||||
|
}
|
||||||
|
if (!empty($lockedVoucher['valid_from']) && (string)$lockedVoucher['valid_from'] > $today) {
|
||||||
|
throw new \RuntimeException('Voucher is not active yet.');
|
||||||
|
}
|
||||||
|
if (!empty($lockedVoucher['valid_until']) && (string)$lockedVoucher['valid_until'] < $today) {
|
||||||
|
throw new \RuntimeException('Voucher is expired.');
|
||||||
|
}
|
||||||
|
if (!empty($lockedVoucher['school_year']) && (string)$lockedVoucher['school_year'] !== (string)$this->schoolYear) {
|
||||||
|
throw new \RuntimeException('Voucher is not valid for this school year.');
|
||||||
|
}
|
||||||
|
if (!empty($lockedVoucher['semester']) && (string)$lockedVoucher['semester'] !== (string)$this->semester) {
|
||||||
|
throw new \RuntimeException('Voucher is not valid for this semester.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$voucher = $lockedVoucher;
|
||||||
|
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
||||||
|
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||||
|
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
||||||
|
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||||
|
if ($remainingUses <= 0) {
|
||||||
|
throw new \RuntimeException('This voucher has reached its maximum allowed uses.');
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($parentIds as $parentId) {
|
foreach ($parentIds as $parentId) {
|
||||||
// Fetch invoices for this parent & school year
|
// Fetch invoices for this parent & school year
|
||||||
@@ -125,10 +159,17 @@ class DiscountController extends BaseController
|
|||||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||||
|
|
||||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
|
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
|
||||||
|
$this->db->query('SELECT id FROM discount_usages WHERE invoice_id = ? FOR UPDATE', [(int)$invoice['id']])->getResultArray();
|
||||||
|
|
||||||
// Snapshot current balance BEFORE applying
|
// Snapshot current balance BEFORE applying
|
||||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
$ledgerBefore = $this->invoiceLedgerService->calculateInvoice((int)$invoice['id']);
|
||||||
if ($initialPreBalance <= 0) {
|
$initialPreBalance = (float)($ledgerBefore['balance'] ?? 0);
|
||||||
|
$eligibleBaseCents = max(
|
||||||
|
0,
|
||||||
|
(int)($ledgerBefore['discount_eligible_base_cents'] ?? 0)
|
||||||
|
- (int)($ledgerBefore['applied_discount_cents'] ?? 0)
|
||||||
|
);
|
||||||
|
if ($eligibleBaseCents <= 0) {
|
||||||
log_message(
|
log_message(
|
||||||
'error',
|
'error',
|
||||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
||||||
@@ -168,11 +209,14 @@ class DiscountController extends BaseController
|
|||||||
|
|
||||||
// Calculate discount
|
// Calculate discount
|
||||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
? round(($eligibleBaseCents / 100 * (float)$voucher['discount_value']) / 100, 2)
|
||||||
: (float) $voucher['discount_value'];
|
: (float) $voucher['discount_value'];
|
||||||
|
|
||||||
// Cap by CURRENT invoice balance snapshot
|
// Cap by CURRENT invoice balance snapshot
|
||||||
$discount = min($rawDiscount, $initialPreBalance);
|
$discount = min($rawDiscount, $initialPreBalance);
|
||||||
|
$requestedDiscountCents = max(0, (int)round($rawDiscount * 100));
|
||||||
|
$appliedDiscountCents = min($requestedDiscountCents, $eligibleBaseCents);
|
||||||
|
$discount = $appliedDiscountCents / 100;
|
||||||
|
|
||||||
// Nothing to do if no discount
|
// Nothing to do if no discount
|
||||||
if ($discount <= 0) {
|
if ($discount <= 0) {
|
||||||
@@ -193,7 +237,7 @@ class DiscountController extends BaseController
|
|||||||
|
|
||||||
// Insert discount usage
|
// Insert discount usage
|
||||||
$now = utc_now();
|
$now = utc_now();
|
||||||
$this->db->table('discount_usages')->insert([
|
$usagePayload = [
|
||||||
'voucher_id' => $voucherId,
|
'voucher_id' => $voucherId,
|
||||||
'invoice_id' => $invoice['id'],
|
'invoice_id' => $invoice['id'],
|
||||||
'parent_id' => $parentId,
|
'parent_id' => $parentId,
|
||||||
@@ -204,17 +248,30 @@ class DiscountController extends BaseController
|
|||||||
'used_at' => $now,
|
'used_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
];
|
||||||
|
if ($this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
||||||
|
$usagePayload['requested_discount_cents'] = $requestedDiscountCents;
|
||||||
|
$usagePayload['eligible_base_cents'] = $eligibleBaseCents;
|
||||||
|
$usagePayload['eligible_base_before_cents'] = $eligibleBaseCents;
|
||||||
|
$usagePayload['applied_discount_cents'] = $appliedDiscountCents;
|
||||||
|
$usagePayload['application_order'] = $this->nextDiscountApplicationOrder((int)$invoice['id']);
|
||||||
|
}
|
||||||
|
if (!$this->db->table('discount_usages')->insert($usagePayload)) {
|
||||||
|
throw new \RuntimeException('Discount usage could not be recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
||||||
$postBalance = (float) ($ledger['balance'] ?? 0.0);
|
$postBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||||
$currentBalance = $postBalance;
|
$currentBalance = $postBalance;
|
||||||
|
|
||||||
// Increment voucher usage
|
// Increment voucher usage
|
||||||
$this->db->table('discount_vouchers')
|
$updatedVoucher = $this->db->table('discount_vouchers')
|
||||||
->where('id', $voucherId)
|
->where('id', $voucherId)
|
||||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
||||||
->update();
|
->update();
|
||||||
|
if (!$updatedVoucher) {
|
||||||
|
throw new \RuntimeException('Voucher usage could not be updated.');
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare and trigger payment event
|
// Prepare and trigger payment event
|
||||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||||
@@ -228,7 +285,7 @@ class DiscountController extends BaseController
|
|||||||
$initialPreBalance, // pre-payment snapshot
|
$initialPreBalance, // pre-payment snapshot
|
||||||
$postBalance // computed post-payment
|
$postBalance // computed post-payment
|
||||||
);
|
);
|
||||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
$pendingEvents[] = [$eventData, $studentData];
|
||||||
|
|
||||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
$touchedInvoiceIds[] = (int) $invoice['id'];
|
||||||
$appliedCount++;
|
$appliedCount++;
|
||||||
@@ -248,20 +305,27 @@ class DiscountController extends BaseController
|
|||||||
|
|
||||||
// Deactivate if we just hit the cap
|
// Deactivate if we just hit the cap
|
||||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||||
$this->db->table('discount_vouchers')
|
$deactivated = $this->db->table('discount_vouchers')
|
||||||
->where('id', $voucherId)
|
->where('id', $voucherId)
|
||||||
->update([
|
->update([
|
||||||
'is_active' => 0,
|
'is_active' => 0,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
if (!$deactivated) {
|
||||||
|
throw new \RuntimeException('Voucher could not be deactivated.');
|
||||||
|
}
|
||||||
break 2;
|
break 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->db->transComplete();
|
if ($this->db->transStatus() === false) {
|
||||||
|
throw new \RuntimeException('Voucher transaction failed.');
|
||||||
if ($this->db->transStatus() === false) {
|
}
|
||||||
|
$this->db->transCommit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
log_message('error', 'Voucher application failed: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +351,10 @@ class DiscountController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($pendingEvents as [$eventData, $studentData]) {
|
||||||
|
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
||||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||||
try {
|
try {
|
||||||
@@ -336,7 +404,10 @@ class DiscountController extends BaseController
|
|||||||
unset($parent);
|
unset($parent);
|
||||||
|
|
||||||
return view('discounts/apply_voucher', [
|
return view('discounts/apply_voucher', [
|
||||||
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
|
'vouchers' => $this->voucherModel
|
||||||
|
->where('is_active', 1)
|
||||||
|
->where('school_year', $this->schoolYear)
|
||||||
|
->findAll(),
|
||||||
'parents' => $parents,
|
'parents' => $parents,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -398,7 +469,7 @@ class DiscountController extends BaseController
|
|||||||
->orWhere('enrollment_status', 'Payment pending')
|
->orWhere('enrollment_status', 'Payment pending')
|
||||||
->orWhere('enrollment_status', 'Payment Pending')
|
->orWhere('enrollment_status', 'Payment Pending')
|
||||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
||||||
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
|
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false)
|
||||||
->groupEnd();
|
->groupEnd();
|
||||||
|
|
||||||
if (!empty($paidEnrollmentIds)) {
|
if (!empty($paidEnrollmentIds)) {
|
||||||
@@ -420,21 +491,31 @@ class DiscountController extends BaseController
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Perform update with same filters
|
$rowsToUpdate = $db->table('enrollments')
|
||||||
$builder = $db->table('enrollments');
|
->whereIn('id', $toUpdateIds)
|
||||||
$builder->set('enrollment_status', 'enrolled')
|
->get()
|
||||||
// Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false)
|
->getResultArray();
|
||||||
->set('enrollment_date', local_date(utc_now(), 'Y-m-d'))
|
$statusService = \Config\Services::enrollmentStatus(false);
|
||||||
->whereIn('id', $toUpdateIds);
|
|
||||||
|
|
||||||
if ($builder->update() === false) {
|
foreach ($rowsToUpdate as $row) {
|
||||||
$err = $db->error();
|
$statusService->upsertStatus([
|
||||||
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
|
'id' => (int) $row['id'],
|
||||||
|
'student_id' => (int) $row['student_id'],
|
||||||
|
'parent_id' => (int) $row['parent_id'],
|
||||||
|
'school_year' => (string) $row['school_year'],
|
||||||
|
'semester' => (string) ($row['semester'] ?? ''),
|
||||||
|
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
||||||
|
'enrollment_status' => 'enrolled',
|
||||||
|
'admission_status' => 'accepted',
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
], (int) (session()->get('user_id') ?? 0) ?: null, 'discount_payment_completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
$affected = $db->affectedRows();
|
$affected = count($rowsToUpdate);
|
||||||
$db->transCommit();
|
$db->transCommit();
|
||||||
|
|
||||||
|
$this->triggerStudentEnrolledNotification($parentId, $rowsToUpdate);
|
||||||
|
|
||||||
log_message(
|
log_message(
|
||||||
'info',
|
'info',
|
||||||
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
||||||
@@ -455,16 +536,73 @@ class DiscountController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function triggerStudentEnrolledNotification(int $parentId, array $enrollmentRows): void
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $enrollmentRows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$parent = $this->userModel->find($parentId) ?? [];
|
||||||
|
$studentIds = array_values(array_unique(array_filter(
|
||||||
|
array_map(static fn (array $row): int => (int) ($row['student_id'] ?? 0), $enrollmentRows),
|
||||||
|
static fn (int $studentId): bool => $studentId > 0
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($studentIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$students = [];
|
||||||
|
foreach ($this->db->table('students')->whereIn('id', $studentIds)->get()->getResultArray() as $student) {
|
||||||
|
$studentId = (int) ($student['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$students[] = [
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $studentId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($students === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events::trigger('studentEnrolled', [
|
||||||
|
'user_id' => $parent['id'] ?? $parentId,
|
||||||
|
'email' => $parent['email'] ?? null,
|
||||||
|
'firstname' => $parent['firstname'] ?? '',
|
||||||
|
'lastname' => $parent['lastname'] ?? '',
|
||||||
|
'school_year' => (string) $this->schoolYear,
|
||||||
|
'portalLink' => base_url('/login'),
|
||||||
|
], $students);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Enrollment confirmation notification after discount failed for parent {parent_id}: {error}', [
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function listVouchers()
|
public function listVouchers()
|
||||||
{
|
{
|
||||||
|
$this->schoolYear = $this->currentSchoolYearName();
|
||||||
|
|
||||||
|
$vouchers = $this->voucherModel
|
||||||
|
->where('school_year', $this->schoolYear)
|
||||||
|
->orderBy('code', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
$vouchers = $this->voucherModel->findAll();
|
|
||||||
return view('discounts/list', ['vouchers' => $vouchers]);
|
return view('discounts/list', ['vouchers' => $vouchers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createVoucher()
|
public function createVoucher()
|
||||||
{
|
{
|
||||||
|
$this->schoolYear = $this->activeSchoolYearName();
|
||||||
|
|
||||||
if (strtolower($this->request->getMethod()) === 'post') {
|
if (strtolower($this->request->getMethod()) === 'post') {
|
||||||
|
|
||||||
// -------- Gather & normalize inputs --------
|
// -------- Gather & normalize inputs --------
|
||||||
@@ -540,6 +678,7 @@ class DiscountController extends BaseController
|
|||||||
'valid_until' => $validUntil,
|
'valid_until' => $validUntil,
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
'description' => $description, // <-- NEW
|
'description' => $description, // <-- NEW
|
||||||
|
'school_year' => $this->schoolYear,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->voucherModel->save($data)) {
|
if ($this->voucherModel->save($data)) {
|
||||||
@@ -558,7 +697,11 @@ class DiscountController extends BaseController
|
|||||||
|
|
||||||
public function editVoucher($id)
|
public function editVoucher($id)
|
||||||
{
|
{
|
||||||
$voucher = $this->voucherModel->find($id);
|
$this->schoolYear = $this->activeSchoolYearName();
|
||||||
|
|
||||||
|
$voucher = $this->voucherModel
|
||||||
|
->where('school_year', $this->schoolYear)
|
||||||
|
->find($id);
|
||||||
|
|
||||||
if (!$voucher) {
|
if (!$voucher) {
|
||||||
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
|
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
|
||||||
@@ -574,6 +717,7 @@ class DiscountController extends BaseController
|
|||||||
'valid_from' => $this->request->getPost('valid_from') ?: null,
|
'valid_from' => $this->request->getPost('valid_from') ?: null,
|
||||||
'valid_until' => $this->request->getPost('valid_until') ?: null,
|
'valid_until' => $this->request->getPost('valid_until') ?: null,
|
||||||
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
|
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
|
||||||
|
'school_year' => $this->schoolYear,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->voucherModel->save($data);
|
$this->voucherModel->save($data);
|
||||||
@@ -583,6 +727,28 @@ class DiscountController extends BaseController
|
|||||||
return view('discounts/edit', ['voucher' => $voucher]);
|
return view('discounts/edit', ['voucher' => $voucher]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function activeSchoolYearName(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->active()->yearName();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function currentSchoolYearName(?string $fallback = null): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->resolve($this->request)->yearName();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
if ($fallback !== null && $fallback !== '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
|
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
|
||||||
*/
|
*/
|
||||||
@@ -632,6 +798,17 @@ class DiscountController extends BaseController
|
|||||||
return 999;
|
return 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function nextDiscountApplicationOrder(int $invoiceId): int
|
||||||
|
{
|
||||||
|
$row = $this->db->table('discount_usages')
|
||||||
|
->select('COALESCE(MAX(application_order),0) + 1 AS next_order', false)
|
||||||
|
->where('invoice_id', $invoiceId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return max(1, (int)($row['next_order'] ?? 1));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
||||||
*
|
*
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ use App\Models\ClassSectionModel;
|
|||||||
use App\Models\ParentModel;
|
use App\Models\ParentModel;
|
||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Models\CalendarModel;
|
use App\Models\CalendarModel;
|
||||||
|
use App\Libraries\FinancialStatus;
|
||||||
|
use App\Libraries\InvoiceLedgerService;
|
||||||
use App\Services\EmailService;
|
use App\Services\EmailService;
|
||||||
use Config\Database;
|
use Config\Database;
|
||||||
use App\Controllers\View\InvoiceController;
|
use App\Controllers\View\InvoiceController;
|
||||||
@@ -42,6 +44,7 @@ class EventController extends ResourceController
|
|||||||
protected $semester;
|
protected $semester;
|
||||||
protected $categories;
|
protected $categories;
|
||||||
protected $enrollmentModel;
|
protected $enrollmentModel;
|
||||||
|
protected $invoiceLedgerService;
|
||||||
private ?bool $eventChargesHasCreatedBy = null;
|
private ?bool $eventChargesHasCreatedBy = null;
|
||||||
private ?bool $eventChargesHasWaiverSigned = null;
|
private ?bool $eventChargesHasWaiverSigned = null;
|
||||||
|
|
||||||
@@ -61,9 +64,10 @@ class EventController extends ResourceController
|
|||||||
$this->enrollmentModel = new EnrollmentModel();
|
$this->enrollmentModel = new EnrollmentModel();
|
||||||
$this->parentModel = new ParentModel();
|
$this->parentModel = new ParentModel();
|
||||||
$this->emailService = new EmailService();
|
$this->emailService = new EmailService();
|
||||||
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->currentSchoolYearName();
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->categories = [
|
$this->categories = [
|
||||||
'workshops',
|
'workshops',
|
||||||
'orientations',
|
'orientations',
|
||||||
@@ -88,6 +92,22 @@ class EventController extends ResourceController
|
|||||||
return $this->eventChargesHasCreatedBy;
|
return $this->eventChargesHasCreatedBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||||
|
{
|
||||||
|
service('schoolYearWriteGuard')->assertWritable(
|
||||||
|
service('schoolYearContext')->forYearName($schoolYear)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYearName(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function eventChargesSupportsWaiverSigned(): bool
|
private function eventChargesSupportsWaiverSigned(): bool
|
||||||
{
|
{
|
||||||
if ($this->eventChargesHasWaiverSigned !== null) {
|
if ($this->eventChargesHasWaiverSigned !== null) {
|
||||||
@@ -313,23 +333,23 @@ class EventController extends ResourceController
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$eventModel = new EventModel();
|
|
||||||
|
|
||||||
$today = local_date(utc_now(), 'Y-m-d');
|
$today = local_date(utc_now(), 'Y-m-d');
|
||||||
|
$schoolYear = $this->currentSchoolYearName();
|
||||||
|
|
||||||
// Fetch all events
|
$events = $this->eventModel
|
||||||
$events = $eventModel
|
->where('school_year', $schoolYear)
|
||||||
->orderBy('created_at', 'DESC')
|
->orderBy('created_at', 'DESC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
// Fetch active events (not expired)
|
$activeEventCount = $this->eventModel
|
||||||
$activeEventCount = $eventModel
|
->where('school_year', $schoolYear)
|
||||||
->where('expiration_date >=', $today)
|
->where('expiration_date >=', $today)
|
||||||
->countAllResults();
|
->countAllResults();
|
||||||
|
|
||||||
return view('administrator/events/event_list', [
|
return view('administrator/events/event_list', [
|
||||||
'events' => $events,
|
'events' => $events,
|
||||||
'activeEventCount' => $activeEventCount
|
'activeEventCount' => $activeEventCount,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,6 +946,7 @@ class EventController extends ResourceController
|
|||||||
if (!$charge) {
|
if (!$charge) {
|
||||||
return redirect()->to($returnTo)->with('error', 'Charge not found.');
|
return redirect()->to($returnTo)->with('error', 'Charge not found.');
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
|
||||||
|
|
||||||
$paymentId = (int)($charge['event_payment_id'] ?? 0);
|
$paymentId = (int)($charge['event_payment_id'] ?? 0);
|
||||||
if ($paymentId > 0) {
|
if ($paymentId > 0) {
|
||||||
@@ -976,6 +997,7 @@ class EventController extends ResourceController
|
|||||||
if (!$charge) {
|
if (!$charge) {
|
||||||
return redirect()->to($returnTo)->with('error', 'Charge not found.');
|
return redirect()->to($returnTo)->with('error', 'Charge not found.');
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
|
||||||
|
|
||||||
$signed = $this->request->getPost('waiver_signed') === '1';
|
$signed = $this->request->getPost('waiver_signed') === '1';
|
||||||
|
|
||||||
@@ -1040,77 +1062,11 @@ class EventController extends ResourceController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
|
||||||
if (!$invoice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
|
||||||
|
|
||||||
$paidSum = 0.0;
|
|
||||||
try {
|
try {
|
||||||
$qb = $db->table('payments')
|
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->where('paid_amount >', 0);
|
|
||||||
|
|
||||||
if ($db->fieldExists('status', 'payments')) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->whereNotIn('status', $exclude)
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($db->fieldExists('is_void', 'payments')) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $qb->get()->getRowArray();
|
|
||||||
$paidSum = (float)($row['tot'] ?? 0.0);
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
log_message('error', 'Failed to recalculate invoice ledger for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
$discountSum = 0.0;
|
|
||||||
try {
|
|
||||||
$row = $db->table('discount_usages')
|
|
||||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
$discountSum = (float)($row['tot'] ?? 0.0);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$refundSum = 0.0;
|
|
||||||
try {
|
|
||||||
$row = $db->table('refunds')
|
|
||||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $invoiceId)
|
|
||||||
->whereIn('status', ['Partial', 'Paid'])
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
$refundSum = (float)($row['tot'] ?? 0.0);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = (float)($invoice['total_amount'] ?? 0.0);
|
|
||||||
$newBalance = round($total - $discountSum - $refundSum - $paidSum, 2);
|
|
||||||
$newStatus = ($newBalance <= 0.00001)
|
|
||||||
? 'Paid'
|
|
||||||
: (($paidSum > 0) ? 'Partially Paid' : 'Unpaid');
|
|
||||||
|
|
||||||
$this->invoiceModel->update($invoiceId, [
|
|
||||||
'paid_amount' => $paidSum,
|
|
||||||
'balance' => $newBalance,
|
|
||||||
'status' => $newStatus,
|
|
||||||
'updated_at' => utc_now(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
|
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
|
||||||
@@ -1123,6 +1079,7 @@ class EventController extends ResourceController
|
|||||||
if (!$charge) {
|
if (!$charge) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
|
||||||
|
|
||||||
$event = $this->eventModel->find($charge['event_id']);
|
$event = $this->eventModel->find($charge['event_id']);
|
||||||
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
|
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
|
||||||
@@ -1161,12 +1118,10 @@ class EventController extends ResourceController
|
|||||||
$eventAmount,
|
$eventAmount,
|
||||||
$paymentSchoolYear,
|
$paymentSchoolYear,
|
||||||
$paymentSemester,
|
$paymentSemester,
|
||||||
(float)($invoice['total_amount'] ?? 0.0),
|
(float)($invoice['total_amount'] ?? 0.0)
|
||||||
(float)($invoice['paid_amount'] ?? 0.0),
|
|
||||||
(float)($invoice['balance'] ?? 0.0)
|
|
||||||
) ?? 0);
|
) ?? 0);
|
||||||
} elseif (!$isPaid && $paymentId > 0) {
|
} elseif (!$isPaid && $paymentId > 0) {
|
||||||
$this->paymentModel->delete($paymentId);
|
$this->voidPayment($paymentId, 'Event charge marked unpaid.');
|
||||||
$paymentId = 0;
|
$paymentId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,42 +1195,32 @@ class EventController extends ResourceController
|
|||||||
float $amount,
|
float $amount,
|
||||||
string $schoolYear,
|
string $schoolYear,
|
||||||
?string $semester,
|
?string $semester,
|
||||||
float $invoiceTotal = 0.0,
|
float $invoiceTotal = 0.0
|
||||||
float $invoicePaid = 0.0,
|
|
||||||
float $invoiceBalance = 0.0
|
|
||||||
): ?int
|
): ?int
|
||||||
{
|
{
|
||||||
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
|
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$monthSemester = $semester ?: $this->semester;
|
$row = $this->paymentModel->db->table('payments')
|
||||||
$newPaid = (float)$invoicePaid + $amount;
|
->select('COALESCE(MAX(installment_seq), 0) + 1 AS next_seq', false)
|
||||||
$newBalance = (float)$invoiceBalance - $amount;
|
|
||||||
$paymentStatus = ($newBalance <= 0.00001)
|
|
||||||
? 'Paid'
|
|
||||||
: (($newPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
|
||||||
|
|
||||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
|
||||||
$priorCount = $this->paymentModel
|
|
||||||
->where('invoice_id', $invoiceId)
|
->where('invoice_id', $invoiceId)
|
||||||
->where('paid_amount >', 0)
|
->get()
|
||||||
->whereNotIn('status', $exclude)
|
->getRowArray();
|
||||||
->countAllResults();
|
$installmentSeq = (int)($row['next_seq'] ?? 1);
|
||||||
$installmentSeq = $priorCount + 1;
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'parent_id' => $parentId,
|
'parent_id' => $parentId,
|
||||||
'invoice_id' => $invoiceId,
|
'invoice_id' => $invoiceId,
|
||||||
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
|
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
|
||||||
'paid_amount' => $amount,
|
'paid_amount' => $amount,
|
||||||
'balance' => $newBalance,
|
'balance' => null,
|
||||||
'number_of_installments' => $installmentSeq,
|
'number_of_installments' => $installmentSeq,
|
||||||
|
'installment_seq' => $installmentSeq,
|
||||||
'payment_method' => 'cash',
|
'payment_method' => 'cash',
|
||||||
'payment_date' => utc_now(),
|
'payment_date' => utc_now(),
|
||||||
'school_year' => $schoolYear,
|
'school_year' => $schoolYear,
|
||||||
'semester' => $monthSemester,
|
'status' => FinancialStatus::PAYMENT_RECORDED,
|
||||||
'status' => $paymentStatus,
|
|
||||||
'transaction_id' => $this->paymentModel->generateNewTransactionId(),
|
'transaction_id' => $this->paymentModel->generateNewTransactionId(),
|
||||||
'updated_by' => session()->get('user_id'),
|
'updated_by' => session()->get('user_id'),
|
||||||
];
|
];
|
||||||
@@ -1288,6 +1233,33 @@ class EventController extends ResourceController
|
|||||||
return (int)$this->paymentModel->getInsertID();
|
return (int)$this->paymentModel->getInsertID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function voidPayment(int $paymentId, string $reason): bool
|
||||||
|
{
|
||||||
|
if ($paymentId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'status' => FinancialStatus::PAYMENT_VOIDED,
|
||||||
|
'updated_by' => session()->get('user_id'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->paymentModel->db->fieldExists('is_void', 'payments')) {
|
||||||
|
$data['is_void'] = 1;
|
||||||
|
}
|
||||||
|
if ($this->paymentModel->db->fieldExists('voided_at', 'payments')) {
|
||||||
|
$data['voided_at'] = utc_now();
|
||||||
|
}
|
||||||
|
if ($this->paymentModel->db->fieldExists('voided_by', 'payments')) {
|
||||||
|
$data['voided_by'] = session()->get('user_id');
|
||||||
|
}
|
||||||
|
if ($this->paymentModel->db->fieldExists('void_reason', 'payments')) {
|
||||||
|
$data['void_reason'] = $reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $this->paymentModel->update($paymentId, $data);
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeExternalName(?string $value): string
|
private function normalizeExternalName(?string $value): string
|
||||||
{
|
{
|
||||||
$raw = trim((string)$value);
|
$raw = trim((string)$value);
|
||||||
@@ -1317,13 +1289,9 @@ class EventController extends ResourceController
|
|||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
foreach ($invoices as $invoice) {
|
foreach ($invoices as $invoice) {
|
||||||
$status = strtolower(trim($invoice['status'] ?? ''));
|
$invoiceId = (int)($invoice['id'] ?? 0);
|
||||||
$balance = (float)($invoice['balance'] ?? 0.0);
|
if ($invoiceId > 0) {
|
||||||
|
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||||
if ($balance <= 0.00001 && $status !== 'paid') {
|
|
||||||
$this->invoiceModel->update($invoice['id'], ['status' => 'Paid']);
|
|
||||||
} elseif ($status === 'paid' && $balance > 0) {
|
|
||||||
$this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class ExamDraftController extends BaseController
|
|||||||
$this->db = Database::connect();
|
$this->db = Database::connect();
|
||||||
|
|
||||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
$this->semester = (string) (getSemester() ?? '');
|
||||||
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
|
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
|
||||||
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
|
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
|
||||||
$this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type');
|
$this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type');
|
||||||
@@ -91,6 +91,9 @@ class ExamDraftController extends BaseController
|
|||||||
|
|
||||||
public function teacherIndex()
|
public function teacherIndex()
|
||||||
{
|
{
|
||||||
|
$this->syncAcademicContext();
|
||||||
|
$context = $this->resolveSchoolYearContext();
|
||||||
|
|
||||||
$teacherId = (int) (session()->get('user_id') ?? 0);
|
$teacherId = (int) (session()->get('user_id') ?? 0);
|
||||||
if ($teacherId <= 0) {
|
if ($teacherId <= 0) {
|
||||||
return redirect()->to('/login');
|
return redirect()->to('/login');
|
||||||
@@ -175,11 +178,15 @@ class ExamDraftController extends BaseController
|
|||||||
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
|
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
|
||||||
'validation' => $validation,
|
'validation' => $validation,
|
||||||
'printableDraftIds' => $printableIds,
|
'printableDraftIds' => $printableIds,
|
||||||
|
'isSchoolYearReadonly' => $context->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function teacherStore()
|
public function teacherStore()
|
||||||
{
|
{
|
||||||
|
$this->syncAcademicContext();
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
|
|
||||||
$teacherId = (int) (session()->get('user_id') ?? 0);
|
$teacherId = (int) (session()->get('user_id') ?? 0);
|
||||||
if ($teacherId <= 0) {
|
if ($teacherId <= 0) {
|
||||||
return redirect()->to('/login');
|
return redirect()->to('/login');
|
||||||
@@ -193,10 +200,13 @@ class ExamDraftController extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
|
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignment = $this->teacherClassModel
|
$assignmentQuery = $this->teacherClassModel
|
||||||
->where('teacher_id', $teacherId)
|
->where('teacher_id', $teacherId)
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId);
|
||||||
->first();
|
if ($this->schoolYear !== '') {
|
||||||
|
$assignmentQuery->where('school_year', $this->schoolYear);
|
||||||
|
}
|
||||||
|
$assignment = $assignmentQuery->first();
|
||||||
|
|
||||||
if (empty($assignment)) {
|
if (empty($assignment)) {
|
||||||
return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.');
|
return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.');
|
||||||
@@ -785,6 +795,8 @@ class ExamDraftController extends BaseController
|
|||||||
|
|
||||||
public function teacherStatusFeed()
|
public function teacherStatusFeed()
|
||||||
{
|
{
|
||||||
|
$this->syncAcademicContext();
|
||||||
|
|
||||||
$teacherId = (int) (session()->get('user_id') ?? 0);
|
$teacherId = (int) (session()->get('user_id') ?? 0);
|
||||||
if ($teacherId <= 0) {
|
if ($teacherId <= 0) {
|
||||||
return $this->response->setStatusCode(401);
|
return $this->response->setStatusCode(401);
|
||||||
@@ -848,6 +860,8 @@ class ExamDraftController extends BaseController
|
|||||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left');
|
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left');
|
||||||
|
|
||||||
|
$this->applyExamDraftYearScope($query);
|
||||||
|
|
||||||
if (empty($classSectionIds)) {
|
if (empty($classSectionIds)) {
|
||||||
return $query->where('exam_drafts.' . $this->authorIdColumn, $teacherId);
|
return $query->where('exam_drafts.' . $this->authorIdColumn, $teacherId);
|
||||||
}
|
}
|
||||||
@@ -859,19 +873,25 @@ class ExamDraftController extends BaseController
|
|||||||
->where('exam_drafts.' . $this->authorIdColumn . ' !=', $teacherId)
|
->where('exam_drafts.' . $this->authorIdColumn . ' !=', $teacherId)
|
||||||
->where('exam_drafts.status !=', 'draft');
|
->where('exam_drafts.status !=', 'draft');
|
||||||
|
|
||||||
if ($this->schoolYear !== '') {
|
|
||||||
$query->where('exam_drafts.school_year', $this->schoolYear);
|
|
||||||
}
|
|
||||||
if ($this->semester !== '') {
|
|
||||||
$query->where('exam_drafts.semester', $this->semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->groupEnd()
|
$query->groupEnd()
|
||||||
->groupEnd();
|
->groupEnd();
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function syncAcademicContext(): void
|
||||||
|
{
|
||||||
|
$this->schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
$this->semester = (string) (getSemester() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyExamDraftYearScope($query): void
|
||||||
|
{
|
||||||
|
if ($this->schoolYear !== '') {
|
||||||
|
$query->where('exam_drafts.school_year', $this->schoolYear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function attachTeacherDraftContext(array $row, int $viewerId): array
|
private function attachTeacherDraftContext(array $row, int $viewerId): array
|
||||||
{
|
{
|
||||||
$isOwn = $this->draftTeacherId($row) === $viewerId;
|
$isOwn = $this->draftTeacherId($row) === $viewerId;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controllers\View;
|
namespace App\Controllers\View;
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
|
use App\Libraries\FinancialAttachmentService;
|
||||||
use App\Models\ExpenseModel;
|
use App\Models\ExpenseModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
@@ -12,6 +13,7 @@ class ExpenseController extends BaseController
|
|||||||
protected $expenseModel;
|
protected $expenseModel;
|
||||||
protected $userModel;
|
protected $userModel;
|
||||||
protected $configModel;
|
protected $configModel;
|
||||||
|
protected FinancialAttachmentService $financialAttachmentService;
|
||||||
protected $schoolYear;
|
protected $schoolYear;
|
||||||
protected $semester;
|
protected $semester;
|
||||||
protected $retailors;
|
protected $retailors;
|
||||||
@@ -21,9 +23,10 @@ class ExpenseController extends BaseController
|
|||||||
$this->expenseModel = new ExpenseModel();
|
$this->expenseModel = new ExpenseModel();
|
||||||
$this->userModel = new UserModel();
|
$this->userModel = new UserModel();
|
||||||
$this->configModel = new ConfigurationModel();
|
$this->configModel = new ConfigurationModel();
|
||||||
|
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
|
|
||||||
// Default list of common retailors; adjust as needed
|
// Default list of common retailors; adjust as needed
|
||||||
$this->retailors = [
|
$this->retailors = [
|
||||||
@@ -93,6 +96,8 @@ class ExpenseController extends BaseController
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
|
||||||
$expenses = $this->expenseModel
|
$expenses = $this->expenseModel
|
||||||
->select("
|
->select("
|
||||||
expenses.*,
|
expenses.*,
|
||||||
@@ -101,6 +106,7 @@ class ExpenseController extends BaseController
|
|||||||
")
|
")
|
||||||
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
||||||
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
||||||
|
->where('expenses.school_year', $schoolYear)
|
||||||
->orderBy('expenses.created_at', 'DESC')
|
->orderBy('expenses.created_at', 'DESC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
@@ -112,7 +118,10 @@ class ExpenseController extends BaseController
|
|||||||
return $row;
|
return $row;
|
||||||
}, $expenses);
|
}, $expenses);
|
||||||
|
|
||||||
return view('expenses/index', ['expenses' => $expenses]);
|
return view('expenses/index', [
|
||||||
|
'expenses' => $expenses,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
@@ -135,19 +144,19 @@ class ExpenseController extends BaseController
|
|||||||
// Optional extra fields
|
// Optional extra fields
|
||||||
'retailor' => 'permit_empty|max_length[255]',
|
'retailor' => 'permit_empty|max_length[255]',
|
||||||
'date_of_purchase' => 'permit_empty',
|
'date_of_purchase' => 'permit_empty',
|
||||||
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
// allow JPG/JPEG/PNG and PDF up to 2MB
|
||||||
'receipt' => 'uploaded[receipt]'
|
'receipt' => 'uploaded[receipt]'
|
||||||
. '|max_size[receipt,2048]'
|
. '|max_size[receipt,5120]'
|
||||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
. '|ext_in[receipt,jpg,jpeg,png,pdf]'
|
||||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,application/pdf]',
|
||||||
];
|
];
|
||||||
|
|
||||||
$messages = [
|
$messages = [
|
||||||
'receipt' => [
|
'receipt' => [
|
||||||
'uploaded' => 'Receipt file is required.',
|
'uploaded' => 'Receipt file is required.',
|
||||||
'max_size' => 'Maximum file size is 2MB.',
|
'max_size' => 'Maximum file size is 5MB.',
|
||||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
|
||||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -170,24 +179,32 @@ class ExpenseController extends BaseController
|
|||||||
$purchasedById = (int) $purchasedById;
|
$purchasedById = (int) $purchasedById;
|
||||||
|
|
||||||
// School context
|
// School context
|
||||||
$schoolYear = $this->schoolYear ?: date('Y');
|
$schoolYear = (string)$this->schoolYear;
|
||||||
|
if (!preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Invalid school year configuration. Expected YYYY-YYYY.');
|
||||||
|
}
|
||||||
$semester = $this->semester ?: 'Fall';
|
$semester = $this->semester ?: 'Fall';
|
||||||
|
|
||||||
// Handle upload: store under writable/uploads/receipts and save only the filename
|
$stagedReceipt = null;
|
||||||
$receiptName = null;
|
try {
|
||||||
$file = $this->request->getFile('receipt');
|
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile(
|
||||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
$this->request->getFile('receipt'),
|
||||||
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
'receipts'
|
||||||
$receiptName = basename($stored);
|
);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = $isDonation ? 'approved' : 'pending';
|
$status = $isDonation ? 'approved' : 'pending';
|
||||||
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
||||||
|
|
||||||
$this->expenseModel->insert([
|
$db = \Config\Database::connect();
|
||||||
|
$db->transBegin();
|
||||||
|
try {
|
||||||
|
$expenseId = (int)$this->expenseModel->insert([
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'receipt_path' => $receiptName, // filename only
|
'receipt_path' => null,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
'retailor' => ($retailor !== '') ? $retailor : null,
|
'retailor' => ($retailor !== '') ? $retailor : null,
|
||||||
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
||||||
@@ -198,7 +215,33 @@ class ExpenseController extends BaseController
|
|||||||
'approved_by' => $isDonation ? $userId : null,
|
'approved_by' => $isDonation ? $userId : null,
|
||||||
'school_year' => $schoolYear,
|
'school_year' => $schoolYear,
|
||||||
'semester' => $semester,
|
'semester' => $semester,
|
||||||
]);
|
]);
|
||||||
|
if ($expenseId <= 0) {
|
||||||
|
throw new \RuntimeException('Expense insert failed.');
|
||||||
|
}
|
||||||
|
if ($db->transStatus() === false) {
|
||||||
|
throw new \RuntimeException('Expense transaction failed.');
|
||||||
|
}
|
||||||
|
$db->transCommit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||||
|
log_message('error', 'Expense creation failed: ' . $e->getMessage());
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Expense could not be saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stagedReceipt !== null) {
|
||||||
|
try {
|
||||||
|
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
|
||||||
|
if (!$this->expenseModel->update($expenseId, ['receipt_path' => $receiptName])) {
|
||||||
|
throw new \RuntimeException('Expense receipt update failed.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||||
|
log_message('critical', 'Expense receipt incomplete for expense #' . $expenseId . ': ' . $e->getMessage());
|
||||||
|
return redirect()->to('/expenses/index')->with('error', 'Expense saved, but receipt could not be finalized. Operations must review.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
||||||
}
|
}
|
||||||
@@ -217,18 +260,37 @@ class ExpenseController extends BaseController
|
|||||||
return $this->response->setJSON(['error' => 'Invalid data']);
|
return $this->response->setJSON(['error' => 'Invalid data']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expense = $this->expenseModel->find($id);
|
$db = \Config\Database::connect();
|
||||||
if (!$expense) {
|
$db->transBegin();
|
||||||
log_message('error', 'Expense not found for ID ' . $id);
|
try {
|
||||||
return $this->response->setJSON(['error' => 'Expense not found']);
|
$expense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||||
}
|
if (!$expense) {
|
||||||
|
throw new \RuntimeException('Expense not found');
|
||||||
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||||
|
$db->query(
|
||||||
|
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||||
|
[$id]
|
||||||
|
)->getResultArray();
|
||||||
|
if ($this->hasActiveReimbursement($expense)) {
|
||||||
|
throw new \RuntimeException('Expense status cannot change after reimbursement without reversal.');
|
||||||
|
}
|
||||||
|
|
||||||
$success = $this->expenseModel->update($id, [
|
$success = $this->expenseModel->update($id, [
|
||||||
'status' => $status,
|
'status' => $status === 'denied' ? 'rejected' : $status,
|
||||||
'status_reason' => $reason,
|
'status_reason' => $reason,
|
||||||
'approved_by' => $userId,
|
'approved_by' => $status === 'approved' ? $userId : null,
|
||||||
'updated_by' => $userId
|
'updated_by' => $userId
|
||||||
]);
|
]);
|
||||||
|
if (!$success || $db->transStatus() === false) {
|
||||||
|
throw new \RuntimeException('Update failed');
|
||||||
|
}
|
||||||
|
$db->transCommit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
|
log_message('error', 'Expense status update failed for ID ' . $id . ': ' . $e->getMessage());
|
||||||
|
return $this->response->setJSON(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$success) {
|
if (!$success) {
|
||||||
log_message('error', 'Expense update failed for ID ' . $id);
|
log_message('error', 'Expense update failed for ID ' . $id);
|
||||||
@@ -260,6 +322,10 @@ class ExpenseController extends BaseController
|
|||||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->hasActiveReimbursement($expense)) {
|
||||||
|
return redirect()->back()->with('error', 'Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
|
||||||
|
}
|
||||||
|
|
||||||
// same user list you use in create()
|
// same user list you use in create()
|
||||||
$users = $this->staffUsers();
|
$users = $this->staffUsers();
|
||||||
|
|
||||||
@@ -279,6 +345,7 @@ class ExpenseController extends BaseController
|
|||||||
if (!$expense) {
|
if (!$expense) {
|
||||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||||
|
|
||||||
// Base rules
|
// Base rules
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -308,14 +375,13 @@ class ExpenseController extends BaseController
|
|||||||
$isDonation = ($category === 'Donation');
|
$isDonation = ($category === 'Donation');
|
||||||
$userId = (int) (session()->get('user_id') ?? 0);
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
|
||||||
// Keep old receipt unless replaced or removed
|
$stagedReceipt = null;
|
||||||
$receiptName = $expense['receipt_path'];
|
|
||||||
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
||||||
$stored = $file->store('receipts');
|
try {
|
||||||
$receiptName = basename($stored);
|
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile($file, 'receipts');
|
||||||
}
|
} catch (\RuntimeException $e) {
|
||||||
if ($this->request->getPost('remove_receipt') === '1') {
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
$receiptName = null;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$updateData = [
|
$updateData = [
|
||||||
@@ -325,9 +391,11 @@ class ExpenseController extends BaseController
|
|||||||
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
||||||
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
||||||
'purchased_by' => $purchasedById,
|
'purchased_by' => $purchasedById,
|
||||||
'receipt_path' => $receiptName,
|
|
||||||
'updated_by' => $userId,
|
'updated_by' => $userId,
|
||||||
];
|
];
|
||||||
|
if ($this->request->getPost('remove_receipt') === '1') {
|
||||||
|
$updateData['receipt_path'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
if ($isDonation) {
|
if ($isDonation) {
|
||||||
$updateData['status'] = 'approved';
|
$updateData['status'] = 'approved';
|
||||||
@@ -335,14 +403,89 @@ class ExpenseController extends BaseController
|
|||||||
$updateData['approved_by'] = $userId ?: null;
|
$updateData['approved_by'] = $userId ?: null;
|
||||||
$updateData['reimbursement_id'] = null;
|
$updateData['reimbursement_id'] = null;
|
||||||
} elseif (($expense['category'] ?? '') === 'Donation') {
|
} elseif (($expense['category'] ?? '') === 'Donation') {
|
||||||
// Moving a donation back to a reimbursable category: clear the marker.
|
// Moving a donation back to a reimbursable category must re-enter approval.
|
||||||
$updateData['status_reason'] = null;
|
$updateData['status_reason'] = null;
|
||||||
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
$updateData['approved_by'] = null;
|
||||||
$updateData['status'] = $expense['status'] ?? 'pending';
|
$updateData['status'] = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->expenseModel->update($id, $updateData);
|
$db = \Config\Database::connect();
|
||||||
|
$db->transBegin();
|
||||||
|
try {
|
||||||
|
$lockedExpense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||||
|
if (!$lockedExpense) {
|
||||||
|
throw new \RuntimeException('Expense not found.');
|
||||||
|
}
|
||||||
|
$activeReimbursements = $db->query(
|
||||||
|
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||||
|
[$id]
|
||||||
|
)->getResultArray();
|
||||||
|
if ($activeReimbursements !== []) {
|
||||||
|
$protectedChanged = (
|
||||||
|
(float)$lockedExpense['amount'] !== (float)$updateData['amount']
|
||||||
|
|| (string)$lockedExpense['category'] !== (string)$updateData['category']
|
||||||
|
|| (int)$lockedExpense['purchased_by'] !== (int)$updateData['purchased_by']
|
||||||
|
|| array_key_exists('receipt_path', $updateData)
|
||||||
|
);
|
||||||
|
if ($protectedChanged) {
|
||||||
|
throw new \RuntimeException('Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->expenseModel->update($id, $updateData) || $db->transStatus() === false) {
|
||||||
|
throw new \RuntimeException('Expense update failed.');
|
||||||
|
}
|
||||||
|
$db->transCommit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stagedReceipt !== null) {
|
||||||
|
try {
|
||||||
|
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
|
||||||
|
if (!$this->expenseModel->update($id, ['receipt_path' => $receiptName])) {
|
||||||
|
throw new \RuntimeException('Expense receipt update failed.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||||
|
log_message('critical', 'Expense receipt replacement incomplete for expense #' . $id . ': ' . $e->getMessage());
|
||||||
|
return redirect()->to('/expenses/index')->with('error', 'Expense updated, but receipt could not be finalized. Operations must review.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasActiveReimbursement(array $expense): bool
|
||||||
|
{
|
||||||
|
$expenseId = (int) ($expense['id'] ?? 0);
|
||||||
|
if ($expenseId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($expense['reimbursement_id'])) {
|
||||||
|
$row = \Config\Database::connect()
|
||||||
|
->table('reimbursements')
|
||||||
|
->select('id')
|
||||||
|
->where('id', (int) $expense['reimbursement_id'])
|
||||||
|
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if ($row) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = \Config\Database::connect()
|
||||||
|
->table('reimbursements')
|
||||||
|
->select('id')
|
||||||
|
->where('expense_id', $expenseId)
|
||||||
|
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return $row !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Controllers\View;
|
|||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
use App\Libraries\FinancialStatus;
|
use App\Libraries\FinancialStatus;
|
||||||
|
use App\Libraries\InvoiceAdjustmentService;
|
||||||
use App\Libraries\InvoiceLedgerService;
|
use App\Libraries\InvoiceLedgerService;
|
||||||
use App\Models\AdditionalChargeModel;
|
use App\Models\AdditionalChargeModel;
|
||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
@@ -31,6 +32,7 @@ class ExtraChargesController extends BaseController
|
|||||||
protected $enableAttendance;
|
protected $enableAttendance;
|
||||||
protected $attendanceDayModel;
|
protected $attendanceDayModel;
|
||||||
protected $invoiceLedgerService;
|
protected $invoiceLedgerService;
|
||||||
|
protected InvoiceAdjustmentService $invoiceAdjustmentService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -40,9 +42,10 @@ class ExtraChargesController extends BaseController
|
|||||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||||
$this->userModel = new UserModel();
|
$this->userModel = new UserModel();
|
||||||
$this->invoiceModel = new InvoiceModel();
|
$this->invoiceModel = new InvoiceModel();
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||||
|
$this->invoiceAdjustmentService = new InvoiceAdjustmentService($this->db);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
@@ -54,14 +57,29 @@ class ExtraChargesController extends BaseController
|
|||||||
/** Render HTML management page */
|
/** Render HTML management page */
|
||||||
public function page()
|
public function page()
|
||||||
{
|
{
|
||||||
|
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||||
|
$query = $this->request->getGet();
|
||||||
|
$hasLegacyTermFilter = false;
|
||||||
|
foreach ($legacyTermKeys as $key) {
|
||||||
|
if (array_key_exists($key, $query)) {
|
||||||
|
unset($query[$key]);
|
||||||
|
$hasLegacyTermFilter = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($hasLegacyTermFilter) {
|
||||||
|
$target = site_url('admin/charges') . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||||
|
return redirect()->to($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
$schoolYear = $schoolYearContext->yearName();
|
||||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||||
$status = $this->request->getGet('status') ?: null;
|
$status = $this->request->getGet('status') ?: null;
|
||||||
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
if ($parentId > 0) {
|
if ($parentId > 0) {
|
||||||
$rows = $this->additionalChargeModel
|
$rows = $this->additionalChargeModel
|
||||||
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
|
->byParentTerm($parentId, $schoolYear, $this->semester, $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load ALL parents for current view
|
// Load ALL parents for current view
|
||||||
@@ -85,7 +103,7 @@ class ExtraChargesController extends BaseController
|
|||||||
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
|
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
|
||||||
$invoicesByParent = [];
|
$invoicesByParent = [];
|
||||||
if (!empty($parentIds)) {
|
if (!empty($parentIds)) {
|
||||||
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
|
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $schoolYear);
|
||||||
foreach ($all as $inv) {
|
foreach ($all as $inv) {
|
||||||
$pid = (int)$inv['parent_id'];
|
$pid = (int)$inv['parent_id'];
|
||||||
$invoicesByParent[$pid][] = [
|
$invoicesByParent[$pid][] = [
|
||||||
@@ -107,7 +125,7 @@ class ExtraChargesController extends BaseController
|
|||||||
|
|
||||||
// ✅ Always pull all charges for the selected year & current semester (all parents)
|
// ✅ Always pull all charges for the selected year & current semester (all parents)
|
||||||
$rows = $this->additionalChargeModel->listAllForTerm(
|
$rows = $this->additionalChargeModel->listAllForTerm(
|
||||||
$yearSelect,
|
$schoolYear,
|
||||||
$this->semester,
|
$this->semester,
|
||||||
$status,
|
$status,
|
||||||
$q,
|
$q,
|
||||||
@@ -115,38 +133,6 @@ class ExtraChargesController extends BaseController
|
|||||||
);
|
);
|
||||||
$pager = $this->additionalChargeModel->pager;
|
$pager = $this->additionalChargeModel->pager;
|
||||||
|
|
||||||
// Build school year options from data (additional_charges + invoices)
|
|
||||||
$schoolYears = [];
|
|
||||||
try {
|
|
||||||
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
|
|
||||||
->where('school_year IS NOT NULL', null, false)
|
|
||||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
|
||||||
foreach ($q1 as $r) {
|
|
||||||
$val = (string)($r['school_year'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
try {
|
|
||||||
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
|
|
||||||
->where('school_year IS NOT NULL', null, false)
|
|
||||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
|
||||||
foreach ($q2 as $r) {
|
|
||||||
$val = (string)($r['school_year'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
|
|
||||||
// fallback: generate recent years around configured schoolYear
|
|
||||||
$schoolYears[] = $this->schoolYear;
|
|
||||||
// Optionally add previous/next
|
|
||||||
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
|
|
||||||
$start = (int)$start;
|
|
||||||
for ($i = 1; $i <= 3; $i++) {
|
|
||||||
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rsort($schoolYears);
|
|
||||||
|
|
||||||
return view('payment/extra_charges', [
|
return view('payment/extra_charges', [
|
||||||
'q' => $q,
|
'q' => $q,
|
||||||
'pager' => $pager,
|
'pager' => $pager,
|
||||||
@@ -157,9 +143,9 @@ class ExtraChargesController extends BaseController
|
|||||||
'parentId' => $parentId,
|
'parentId' => $parentId,
|
||||||
'selectedParentLabel' => $selectedParentLabel,
|
'selectedParentLabel' => $selectedParentLabel,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'schoolYear' => $yearSelect,
|
'schoolYear' => $schoolYear,
|
||||||
'schoolYears' => $schoolYears,
|
|
||||||
'semester' => $this->semester,
|
'semester' => $this->semester,
|
||||||
|
'isSchoolYearReadonly' => $schoolYearContext->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,16 +219,19 @@ class ExtraChargesController extends BaseController
|
|||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||||
return redirect()->back()->with('error', 'Charge not found.');
|
return redirect()->back()->with('error', 'Charge not found.');
|
||||||
}
|
}
|
||||||
|
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||||
|
$message = 'Applied charges are immutable. Void and create an adjustment instead.';
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $message, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||||
|
return redirect()->back()->with('error', $message);
|
||||||
|
}
|
||||||
|
|
||||||
$data = $this->request->getPost();
|
$data = $this->request->getPost();
|
||||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
$newAmount = isset($data['amount']) ? round(abs((float)$data['amount']), 2) : round(abs((float)$row['amount']), 2);
|
||||||
$delta = $newAmount - (float)$row['amount'];
|
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
|
||||||
// Update the charge first
|
$updated = $this->additionalChargeModel->update($id, [
|
||||||
$this->additionalChargeModel->update($id, [
|
|
||||||
'title' => trim($data['title'] ?? $row['title']),
|
'title' => trim($data['title'] ?? $row['title']),
|
||||||
'description' => trim($data['description'] ?? $row['description']),
|
'description' => trim($data['description'] ?? $row['description']),
|
||||||
'amount' => $newAmount,
|
'amount' => $newAmount,
|
||||||
@@ -250,6 +239,11 @@ class ExtraChargesController extends BaseController
|
|||||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||||
// keep status as-is
|
// keep status as-is
|
||||||
]);
|
]);
|
||||||
|
if (!$updated) {
|
||||||
|
$db->transRollback();
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||||
|
return redirect()->back()->with('error', 'Failed to update charge.');
|
||||||
|
}
|
||||||
|
|
||||||
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) {
|
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) {
|
||||||
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]);
|
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]);
|
||||||
@@ -273,7 +267,7 @@ class ExtraChargesController extends BaseController
|
|||||||
public function invoicesForParent()
|
public function invoicesForParent()
|
||||||
{
|
{
|
||||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
$schoolYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||||
|
|
||||||
$rows = ($parentId > 0)
|
$rows = ($parentId > 0)
|
||||||
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
||||||
@@ -304,6 +298,9 @@ class ExtraChargesController extends BaseController
|
|||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
$this->assertSchoolYearWritable($schoolYearContext);
|
||||||
|
$schoolYear = $schoolYearContext->yearName();
|
||||||
$data = $this->request->getPost();
|
$data = $this->request->getPost();
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -328,22 +325,30 @@ class ExtraChargesController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
||||||
|
$invoice = null;
|
||||||
|
if ($invoiceId !== null) {
|
||||||
|
$invoice = $this->invoiceModel->find($invoiceId);
|
||||||
|
if (!$invoice) {
|
||||||
|
$msg = 'Invoice not found for charge.';
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||||
|
return redirect()->back()->withInput()->with('error', $msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
$chargeType = (string)$data['charge_type'];
|
$chargeType = (string)$data['charge_type'];
|
||||||
|
|
||||||
$amountAbs = round(abs((float)$data['amount']), 2);
|
$amountAbs = round(abs((float)$data['amount']), 2);
|
||||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
'parent_id' => $invoice ? (int)$invoice['parent_id'] : (int)$data['parent_id'],
|
||||||
'invoice_id' => $invoiceId,
|
'invoice_id' => $invoiceId,
|
||||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
'school_year' => $invoice ? (string)$invoice['school_year'] : $schoolYear,
|
||||||
'semester' => $data['semester'] ?? $this->semester,
|
'semester' => $invoice ? (string)($invoice['semester'] ?? $this->semester) : (string)$this->semester,
|
||||||
'charge_type' => $chargeType,
|
'charge_type' => $chargeType,
|
||||||
'title' => trim($data['title']),
|
'title' => trim($data['title']),
|
||||||
'description' => trim($data['description'] ?? ''),
|
'description' => trim($data['description'] ?? ''),
|
||||||
'amount' => $signedAmount,
|
'amount' => $amountAbs,
|
||||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||||
'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||||
];
|
];
|
||||||
@@ -351,22 +356,22 @@ class ExtraChargesController extends BaseController
|
|||||||
$this->db->transStart();
|
$this->db->transStart();
|
||||||
|
|
||||||
// BEFORE
|
// BEFORE
|
||||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||||
|
|
||||||
// Insert charge
|
// Insert charge
|
||||||
$this->additionalChargeModel->insert($payload);
|
$chargeId = (int)$this->additionalChargeModel->insert($payload);
|
||||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
if ($chargeId <= 0) {
|
||||||
|
$this->db->transRollback();
|
||||||
if ($invoiceId) {
|
$msg = 'Failed to save charge.';
|
||||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
return redirect()->back()->withInput()->with('error', $msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AFTER
|
// AFTER
|
||||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||||
|
|
||||||
// Parent USER (not parent table)
|
// Parent USER (not parent table)
|
||||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
$parentUser = $this->userModel->getUserInfoById($payload['parent_id']);
|
||||||
|
|
||||||
$this->db->transComplete();
|
$this->db->transComplete();
|
||||||
|
|
||||||
@@ -413,7 +418,7 @@ class ExtraChargesController extends BaseController
|
|||||||
'charge_title' => $payload['title'],
|
'charge_title' => $payload['title'],
|
||||||
'charge_desc' => $payload['description'],
|
'charge_desc' => $payload['description'],
|
||||||
'charge_type' => $payload['charge_type'], // add|deduct
|
'charge_type' => $payload['charge_type'], // add|deduct
|
||||||
'amount_signed' => $signedAmount,
|
'amount_signed' => $chargeType === 'add' ? $amountAbs : -$amountAbs,
|
||||||
'amount_abs' => $amountAbs,
|
'amount_abs' => $amountAbs,
|
||||||
'due_date' => $payload['due_date'],
|
'due_date' => $payload['due_date'],
|
||||||
'created_at' => $payload['created_at'],
|
'created_at' => $payload['created_at'],
|
||||||
@@ -436,7 +441,7 @@ class ExtraChargesController extends BaseController
|
|||||||
'ok' => true,
|
'ok' => true,
|
||||||
'id' => $chargeId,
|
'id' => $chargeId,
|
||||||
'invoice_id' => $invoiceId,
|
'invoice_id' => $invoiceId,
|
||||||
'parent_id' => (int)$data['parent_id'],
|
'parent_id' => $payload['parent_id'],
|
||||||
'csrf_token' => csrf_token(),
|
'csrf_token' => csrf_token(),
|
||||||
'csrf_hash' => csrf_hash(),
|
'csrf_hash' => csrf_hash(),
|
||||||
]);
|
]);
|
||||||
@@ -444,6 +449,61 @@ class ExtraChargesController extends BaseController
|
|||||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function approve($id)
|
||||||
|
{
|
||||||
|
$row = $this->additionalChargeModel->find((int)$id);
|
||||||
|
if (!$row) {
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||||
|
return redirect()->back()->with('error', 'Charge not found.');
|
||||||
|
}
|
||||||
|
if (($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_PENDING) {
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only pending charges can be approved']);
|
||||||
|
return redirect()->back()->with('error', 'Only pending charges can be approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->additionalChargeModel->update((int)$id, ['status' => FinancialStatus::ADDITIONAL_CHARGE_APPROVED])) {
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to approve charge']);
|
||||||
|
return redirect()->back()->with('error', 'Failed to approve charge.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||||
|
return redirect()->back()->with('status', 'Charge approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply($id)
|
||||||
|
{
|
||||||
|
$row = $this->additionalChargeModel->find((int)$id);
|
||||||
|
if (!$row) {
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||||
|
return redirect()->back()->with('error', 'Charge not found.');
|
||||||
|
}
|
||||||
|
if ((string)($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only approved charges can be applied']);
|
||||||
|
return redirect()->back()->with('error', 'Only approved charges can be applied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||||
|
if ($invoiceId <= 0) {
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge must reference an invoice before application']);
|
||||||
|
return redirect()->back()->with('error', 'Charge must reference an invoice before application.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->invoiceAdjustmentService->applyAdditionalCharge(
|
||||||
|
(int)$id,
|
||||||
|
$invoiceId,
|
||||||
|
(int)(session()->get('user_id') ?? 0)
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Additional charge apply failed: ' . $e->getMessage());
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||||
|
return redirect()->back()->with('status', 'Charge applied.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||||
*/
|
*/
|
||||||
@@ -455,27 +515,36 @@ class ExtraChargesController extends BaseController
|
|||||||
return redirect()->back()->with('error', 'Charge not found.');
|
return redirect()->back()->with('error', 'Charge not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
|
||||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
|
||||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
|
||||||
$status = (string)($row['status'] ?? 'pending');
|
$status = (string)($row['status'] ?? 'pending');
|
||||||
|
$reason = trim((string)($this->request->getPost('reason') ?? 'Voided by staff'));
|
||||||
|
|
||||||
$this->db->transStart();
|
try {
|
||||||
|
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||||
$this->additionalChargeModel->update((int)$id, [
|
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
(int)$id,
|
||||||
]);
|
$reason,
|
||||||
|
(int)(session()->get('user_id') ?? 0)
|
||||||
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && $invoiceId > 0) {
|
);
|
||||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
} else {
|
||||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
$this->db->transBegin();
|
||||||
}
|
$this->additionalChargeModel->update((int)$id, [
|
||||||
|
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
||||||
$this->db->transComplete();
|
'voided_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||||
|
'voided_at' => utc_now(),
|
||||||
if (!$this->db->transStatus()) {
|
'void_reason' => $reason,
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
|
]);
|
||||||
return redirect()->back()->with('error', 'Failed to void charge.');
|
if (!$this->db->transStatus()) {
|
||||||
|
throw new \RuntimeException('Failed to void charge.');
|
||||||
|
}
|
||||||
|
$this->db->transCommit();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($this->db->transStatus() === false) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
}
|
||||||
|
log_message('error', 'Additional charge void failed: ' . $e->getMessage());
|
||||||
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||||
@@ -483,7 +552,7 @@ class ExtraChargesController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
* Reverse a previously applied charge with an immutable reversing invoice line.
|
||||||
*/
|
*/
|
||||||
public function reverse($id)
|
public function reverse($id)
|
||||||
{
|
{
|
||||||
@@ -494,40 +563,35 @@ class ExtraChargesController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
|
||||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
|
||||||
$status = (string)($row['status'] ?? 'pending');
|
$status = (string)($row['status'] ?? 'pending');
|
||||||
|
|
||||||
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0 || $amountAbs <= 0) {
|
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0) {
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
||||||
return redirect()->back()->with('error', 'Nothing to reverse.');
|
return redirect()->back()->with('error', 'Nothing to reverse.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->db->transStart();
|
$reason = trim((string)($this->request->getPost('reason') ?? 'Reversed by staff'));
|
||||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
try {
|
||||||
|
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||||
$this->additionalChargeModel->update((int)$id, [
|
(int)$id,
|
||||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
$reason,
|
||||||
'invoice_id' => null,
|
(int)(session()->get('user_id') ?? 0)
|
||||||
]);
|
);
|
||||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Additional charge reverse failed: ' . $e->getMessage());
|
||||||
$this->db->transComplete();
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
if (!$this->db->transStatus()) {
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
|
|
||||||
return redirect()->back()->with('error', 'Failed to reverse charge.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
return redirect()->back()->with('status', 'Charge reversed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JSON: list charges for the current term (with optional filters). */
|
/** JSON: list charges for the current term (with optional filters). */
|
||||||
public function apiList()
|
public function apiList()
|
||||||
{
|
{
|
||||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||||
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
|
$sem = (string)$this->semester;
|
||||||
$status = $this->request->getGet('status') ?: null;
|
$status = $this->request->getGet('status') ?: null;
|
||||||
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
|
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
|
||||||
$per = (int)($this->request->getGet('per_page') ?? 50);
|
$per = (int)($this->request->getGet('per_page') ?? 50);
|
||||||
|
|||||||
@@ -254,7 +254,41 @@ class FamilyAdminController extends BaseController
|
|||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[$studentId]
|
[$studentId]
|
||||||
)->getRowArray();
|
)->getRowArray();
|
||||||
if (!empty($row['id'])) $familyId = (int) $row['id'];
|
if (!empty($row['id'])) {
|
||||||
|
$familyId = (int) $row['id'];
|
||||||
|
} else {
|
||||||
|
// Legacy/newly-created students can have students.parent_id set
|
||||||
|
// before the normalized family_students row is created.
|
||||||
|
$row = $db->query(
|
||||||
|
"SELECT f.id
|
||||||
|
FROM students s
|
||||||
|
JOIN family_guardians fg ON fg.user_id = s.parent_id
|
||||||
|
JOIN families f ON f.id = fg.family_id
|
||||||
|
WHERE s.id = ?
|
||||||
|
ORDER BY fg.is_primary DESC, f.household_name
|
||||||
|
LIMIT 1",
|
||||||
|
[$studentId]
|
||||||
|
)->getRowArray();
|
||||||
|
|
||||||
|
if (!empty($row['id'])) {
|
||||||
|
$familyId = (int) $row['id'];
|
||||||
|
} else {
|
||||||
|
$row = $db->query(
|
||||||
|
"SELECT f.id
|
||||||
|
FROM students s
|
||||||
|
JOIN families f ON f.family_code = CONCAT('FAM-', s.parent_id)
|
||||||
|
WHERE s.id = ?
|
||||||
|
AND s.parent_id IS NOT NULL
|
||||||
|
ORDER BY f.household_name
|
||||||
|
LIMIT 1",
|
||||||
|
[$studentId]
|
||||||
|
)->getRowArray();
|
||||||
|
|
||||||
|
if (!empty($row['id'])) {
|
||||||
|
$familyId = (int) $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} elseif ($guardianId) {
|
} elseif ($guardianId) {
|
||||||
// 1) Try via guardians link
|
// 1) Try via guardians link
|
||||||
$row = $db->query(
|
$row = $db->query(
|
||||||
@@ -330,6 +364,24 @@ class FamilyAdminController extends BaseController
|
|||||||
ORDER BY s.lastname, s.firstname",
|
ORDER BY s.lastname, s.firstname",
|
||||||
[$familyId]
|
[$familyId]
|
||||||
)->getResultArray();
|
)->getResultArray();
|
||||||
|
|
||||||
|
if ($studentId > 0) {
|
||||||
|
$studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows);
|
||||||
|
if (!in_array($studentId, $studentIds, true)) {
|
||||||
|
$selectedStudent = $db->query(
|
||||||
|
"SELECT id, firstname, lastname
|
||||||
|
FROM students
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1",
|
||||||
|
[$studentId]
|
||||||
|
)->getRowArray();
|
||||||
|
|
||||||
|
if ($selectedStudent) {
|
||||||
|
$studentsRows[] = $selectedStudent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($studentsRows)) {
|
if (!empty($studentsRows)) {
|
||||||
foreach ($studentsRows as &$sr) {
|
foreach ($studentsRows as &$sr) {
|
||||||
$sid = (int) ($sr['id'] ?? 0);
|
$sid = (int) ($sr['id'] ?? 0);
|
||||||
@@ -361,7 +413,7 @@ class FamilyAdminController extends BaseController
|
|||||||
if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
|
if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
|
||||||
}
|
}
|
||||||
$ecRows = $db->table('emergency_contacts')
|
$ecRows = $db->table('emergency_contacts')
|
||||||
->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester, created_at, updated_at')
|
->select('id, parent_id, emergency_contact_name, relation, cellphone, email, created_at, updated_at')
|
||||||
->whereIn('parent_id', $parentIds)
|
->whereIn('parent_id', $parentIds)
|
||||||
->orderBy('updated_at', 'DESC')
|
->orderBy('updated_at', 'DESC')
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ class FilesController extends Controller
|
|||||||
throw PageNotFoundException::forPageNotFound();
|
throw PageNotFoundException::forPageNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->canViewEarlyDismissalSignature($name)) {
|
||||||
|
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
|
||||||
|
}
|
||||||
|
|
||||||
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
|
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
|
||||||
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
|
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
@@ -200,7 +204,7 @@ class FilesController extends Controller
|
|||||||
->setHeader('Content-Length', (string) $size)
|
->setHeader('Content-Length', (string) $size)
|
||||||
->setHeader('ETag', $etag)
|
->setHeader('ETag', $etag)
|
||||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
->setHeader('Cache-Control', 'private, no-store')
|
||||||
->setBody(file_get_contents($path));
|
->setBody(file_get_contents($path));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +437,35 @@ class FilesController extends Controller
|
|||||||
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
|
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canViewEarlyDismissalSignature(string $name): bool
|
||||||
|
{
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
|
||||||
|
$activeRole = strtolower((string) (session()->get('role') ?? ''));
|
||||||
|
if ($activeRole !== '' && ! in_array($activeRole, $roles, true)) {
|
||||||
|
$roles[] = $activeRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['administrator', 'administrative staff', 'principal', 'admin', 'teacher', 'teacher_assistant'] as $role) {
|
||||||
|
if (in_array($role, $roles, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = \Config\Database::connect()
|
||||||
|
->table('early_dismissal_signatures')
|
||||||
|
->select('uploaded_by')
|
||||||
|
->where('filename', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return $row !== null && (int) ($row['uploaded_by'] ?? 0) === $userId;
|
||||||
|
}
|
||||||
|
|
||||||
private function expenseRecordForFile(string $name): ?array
|
private function expenseRecordForFile(string $name): ?array
|
||||||
{
|
{
|
||||||
return \Config\Database::connect()
|
return \Config\Database::connect()
|
||||||
@@ -489,7 +522,7 @@ class FilesController extends Controller
|
|||||||
$roles[] = $activeRole;
|
$roles[] = $activeRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
|
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant', 'head fa', 'head of fa', 'head_of_fa', 'financial_contributor'] as $role) {
|
||||||
if (in_array($role, $roles, true)) {
|
if (in_array($role, $roles, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class FinalController extends BaseController
|
|||||||
$this->studentModel = new StudentModel();
|
$this->studentModel = new StudentModel();
|
||||||
$this->teacherClassModel = new TeacherClassModel();
|
$this->teacherClassModel = new TeacherClassModel();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
$this->gradingLockModel = new GradingLockModel();
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||||
}
|
}
|
||||||
@@ -131,6 +131,7 @@ class FinalController extends BaseController
|
|||||||
)
|
)
|
||||||
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
|
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
|
||||||
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
|
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
|
||||||
|
->where('s.is_active', 1)
|
||||||
->orderBy('s.lastname', 'ASC')
|
->orderBy('s.lastname', 'ASC')
|
||||||
->orderBy('s.firstname', 'ASC');
|
->orderBy('s.firstname', 'ASC');
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,115 @@ use App\Models\RefundModel;
|
|||||||
use App\Models\ExpenseModel;
|
use App\Models\ExpenseModel;
|
||||||
use App\Models\ReimbursementModel;
|
use App\Models\ReimbursementModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
|
use App\Libraries\FinancialStatus;
|
||||||
|
use App\Libraries\InvoiceLedgerService;
|
||||||
|
|
||||||
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
||||||
use FPDF;
|
use FPDF;
|
||||||
|
|
||||||
class FinancialController extends BaseController
|
class FinancialController extends BaseController
|
||||||
{
|
{
|
||||||
|
private ?InvoiceLedgerService $invoiceLedgerService = null;
|
||||||
|
|
||||||
|
private function invoiceLedger(): InvoiceLedgerService
|
||||||
|
{
|
||||||
|
if ($this->invoiceLedgerService === null) {
|
||||||
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->invoiceLedgerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ledgerProjectionForInvoice(int $invoiceId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->invoiceLedger()->calculateInvoice($invoiceId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Invoice ledger projection failed for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||||
|
return [
|
||||||
|
'total_amount' => '0.00',
|
||||||
|
'paid_amount' => '0.00',
|
||||||
|
'discount_total' => '0.00',
|
||||||
|
'refund_paid_total' => '0.00',
|
||||||
|
'balance' => '0.00',
|
||||||
|
'customer_credit' => '0.00',
|
||||||
|
'status' => FinancialStatus::INVOICE_UNPAID,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ledgerProjectionMap(array $invoiceIds): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach (array_values(array_unique(array_map('intval', $invoiceIds))) as $invoiceId) {
|
||||||
|
if ($invoiceId > 0) {
|
||||||
|
$map[$invoiceId] = $this->ledgerProjectionForInvoice($invoiceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
private function wantsJson(): bool
|
private function wantsJson(): bool
|
||||||
{
|
{
|
||||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||||
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function selectedSchoolYear(): string
|
||||||
|
{
|
||||||
|
$requested = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||||
|
if ($requested !== '') {
|
||||||
|
return $requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->currentSchoolYearName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function financialSchoolYearOptions(?string $selectedYear = null): array
|
||||||
|
{
|
||||||
|
$schoolYears = [];
|
||||||
|
$addYear = static function (mixed $value) use (&$schoolYears): void {
|
||||||
|
$year = trim((string) $value);
|
||||||
|
if ($year !== '' && ! in_array($year, $schoolYears, true)) {
|
||||||
|
$schoolYears[] = $year;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$addYear($selectedYear);
|
||||||
|
$addYear($this->currentSchoolYearName());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
if ($db->tableExists('school_years')) {
|
||||||
|
$rows = $db->table('school_years')
|
||||||
|
->select('name')
|
||||||
|
->orderBy('name', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$addYear($row['name'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->tableExists('invoices') && $db->fieldExists('school_year', 'invoices')) {
|
||||||
|
$rows = $db->table('invoices')
|
||||||
|
->select('DISTINCT school_year', false)
|
||||||
|
->where('school_year IS NOT NULL', null, false)
|
||||||
|
->orderBy('school_year', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$addYear($row['school_year'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schoolYears;
|
||||||
|
}
|
||||||
|
|
||||||
public function financialReport()
|
public function financialReport()
|
||||||
{
|
{
|
||||||
$invoiceModel = new InvoiceModel();
|
$invoiceModel = new InvoiceModel();
|
||||||
@@ -32,7 +129,7 @@ public function financialReport()
|
|||||||
|
|
||||||
$dateFrom = $this->request->getGet('date_from');
|
$dateFrom = $this->request->getGet('date_from');
|
||||||
$dateTo = $this->request->getGet('date_to');
|
$dateTo = $this->request->getGet('date_to');
|
||||||
$schoolYear = $this->request->getGet('school_year');
|
$schoolYear = $this->selectedSchoolYear();
|
||||||
|
|
||||||
// === Apply filters to models (invoice/refund/discount/expense/reimb) ===
|
// === Apply filters to models (invoice/refund/discount/expense/reimb) ===
|
||||||
if ($schoolYear) {
|
if ($schoolYear) {
|
||||||
@@ -103,6 +200,7 @@ public function financialReport()
|
|||||||
$id = (int)($inv['id'] ?? 0);
|
$id = (int)($inv['id'] ?? 0);
|
||||||
return $id > 0 ? $id : null;
|
return $id > 0 ? $id : null;
|
||||||
}, $invoices))));
|
}, $invoices))));
|
||||||
|
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||||
|
|
||||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||||
@@ -141,18 +239,14 @@ public function financialReport()
|
|||||||
return $qb;
|
return $qb;
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
// === Ledger-derived invoice totals (for Paid/Refund/Discount/Balance/Status columns) ===
|
||||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
$payments = [];
|
||||||
if (!empty($invoiceIds)) {
|
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
$payments[] = [
|
||||||
} else {
|
'invoice_id' => $invoiceId,
|
||||||
$paymentsQuery->where('invoice_id', -1);
|
'paid_amount' => (float) ($ledger['paid_amount'] ?? 0),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
$payments = $paymentsQuery
|
|
||||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
|
||||||
->where('invoice_id IS NOT NULL')
|
|
||||||
->groupBy('invoice_id')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// === Per-invoice breakdown by normalized method (cash/credit/check) ===
|
// === Per-invoice breakdown by normalized method (cash/credit/check) ===
|
||||||
// Normalization rules:
|
// Normalization rules:
|
||||||
@@ -206,21 +300,44 @@ public function financialReport()
|
|||||||
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
|
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
// === Refunds (grouped) ===
|
// === Ledger-derived refunds and discounts ===
|
||||||
$refunds = $refundModel
|
$refunds = [];
|
||||||
->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
|
$discounts = [];
|
||||||
->where('invoice_id IS NOT NULL')
|
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||||
->whereIn('status', ['Partial', 'Paid'])
|
$invoiceSchoolYear = '';
|
||||||
->groupBy(['invoice_id', 'school_year'])
|
foreach ($invoices as $invoiceRow) {
|
||||||
->findAll();
|
if ((int)($invoiceRow['id'] ?? 0) === (int)$invoiceId) {
|
||||||
|
$invoiceSchoolYear = (string)($invoiceRow['school_year'] ?? '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$refunds[] = [
|
||||||
|
'invoice_id' => $invoiceId,
|
||||||
|
'school_year' => $invoiceSchoolYear,
|
||||||
|
'total_refunded' => (float) ($ledger['refund_paid_total'] ?? 0),
|
||||||
|
];
|
||||||
|
$discounts[] = [
|
||||||
|
'invoice_id' => $invoiceId,
|
||||||
|
'school_year' => $invoiceSchoolYear,
|
||||||
|
'discount_amount' => (float) ($ledger['discount_total'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// === Discounts (grouped) ===
|
foreach ($invoices as &$invoiceRow) {
|
||||||
$discounts = $discountModel
|
$invoiceId = (int)($invoiceRow['id'] ?? 0);
|
||||||
->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
|
$ledger = $ledgerByInvoice[$invoiceId] ?? null;
|
||||||
->where('invoice_id IS NOT NULL')
|
if ($ledger === null) {
|
||||||
->groupBy(['invoice_id', 'school_year'])
|
continue;
|
||||||
->get()
|
}
|
||||||
->getResultArray();
|
$invoiceRow['total_amount'] = (float)($ledger['total_amount'] ?? 0);
|
||||||
|
$invoiceRow['paid_amount'] = (float)($ledger['paid_amount'] ?? 0);
|
||||||
|
$invoiceRow['discount'] = (float)($ledger['discount_total'] ?? 0);
|
||||||
|
$invoiceRow['refund_paid'] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||||
|
$invoiceRow['balance'] = (float)($ledger['balance'] ?? 0);
|
||||||
|
$invoiceRow['customer_credit'] = (float)($ledger['customer_credit'] ?? 0);
|
||||||
|
$invoiceRow['status'] = (string)($ledger['status'] ?? ($invoiceRow['status'] ?? ''));
|
||||||
|
}
|
||||||
|
unset($invoiceRow);
|
||||||
|
|
||||||
// === Expenses ===
|
// === Expenses ===
|
||||||
$expenses = $expenseModel
|
$expenses = $expenseModel
|
||||||
@@ -235,22 +352,7 @@ public function financialReport()
|
|||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
// --- School year options (for detailed view & JSON) ---
|
// --- School year options (for detailed view & JSON) ---
|
||||||
$schoolYears = [];
|
$schoolYears = $this->financialSchoolYearOptions($schoolYear);
|
||||||
try {
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$rows = $db->table('invoices')
|
|
||||||
->select('DISTINCT school_year', false)
|
|
||||||
->where('school_year IS NOT NULL', null, false)
|
|
||||||
->orderBy('school_year', 'DESC')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$val = (string)($r['school_year'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
if (empty($schoolYears) && !empty($schoolYear)) {
|
|
||||||
$schoolYears[] = (string)$schoolYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
||||||
|
|
||||||
@@ -298,28 +400,11 @@ public function financialReport()
|
|||||||
{
|
{
|
||||||
$dateFrom = $this->request->getGet('date_from');
|
$dateFrom = $this->request->getGet('date_from');
|
||||||
$dateTo = $this->request->getGet('date_to');
|
$dateTo = $this->request->getGet('date_to');
|
||||||
$schoolYear = $this->request->getGet('school_year');
|
$schoolYear = $this->selectedSchoolYear();
|
||||||
|
|
||||||
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
|
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
|
||||||
|
|
||||||
// Build school year options from invoices table (fallback to configured year)
|
$data['schoolYears'] = $this->financialSchoolYearOptions((string)($data['schoolYear'] ?? $schoolYear));
|
||||||
$schoolYears = [];
|
|
||||||
try {
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$rows = $db->table('invoices')
|
|
||||||
->select('DISTINCT school_year', false)
|
|
||||||
->where('school_year IS NOT NULL', null, false)
|
|
||||||
->orderBy('school_year', 'DESC')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$val = (string)($r['school_year'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
if (empty($schoolYears) && !empty($data['schoolYear'])) {
|
|
||||||
$schoolYears[] = (string)$data['schoolYear'];
|
|
||||||
}
|
|
||||||
$data['schoolYears'] = $schoolYears;
|
|
||||||
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
||||||
return $this->response->setJSON(['ok' => true] + $data + [
|
return $this->response->setJSON(['ok' => true] + $data + [
|
||||||
'csrf_token' => csrf_token(),
|
'csrf_token' => csrf_token(),
|
||||||
@@ -524,7 +609,7 @@ public function financialReport()
|
|||||||
private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
||||||
{
|
{
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
|
$schoolYear = $schoolYear ?: $this->currentSchoolYearName();
|
||||||
|
|
||||||
$derivedDateFrom = null;
|
$derivedDateFrom = null;
|
||||||
$derivedDateTo = null;
|
$derivedDateTo = null;
|
||||||
@@ -561,77 +646,27 @@ public function financialReport()
|
|||||||
$invoiceRows = $invoiceRows->orderBy('invoices.id', 'ASC')->get()->getResultArray();
|
$invoiceRows = $invoiceRows->orderBy('invoices.id', 'ASC')->get()->getResultArray();
|
||||||
|
|
||||||
$invoiceIds = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoiceRows)));
|
$invoiceIds = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoiceRows)));
|
||||||
|
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||||
$paidByInvoice = [];
|
|
||||||
$discountByInvoice = [];
|
|
||||||
$refundByInvoice = [];
|
|
||||||
if (!empty($invoiceIds)) {
|
|
||||||
$paidRows = $db->table('payments')
|
|
||||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
|
||||||
->whereIn('invoice_id', $invoiceIds)
|
|
||||||
->groupStart()
|
|
||||||
->whereNotIn('status', $paymentExclude)
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
if (!empty($schoolYear)) {
|
|
||||||
$paidRows->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if (!empty($invoiceDateFrom)) {
|
|
||||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($invoiceDateTo)) {
|
|
||||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
|
||||||
}
|
|
||||||
foreach ($paidRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
|
||||||
$paidByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_paid'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$discountRows = $db->table('discount_usages')
|
|
||||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_discount')
|
|
||||||
->whereIn('invoice_id', $invoiceIds);
|
|
||||||
if (!empty($dateFrom)) {
|
|
||||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($dateTo)) {
|
|
||||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
foreach ($discountRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
|
||||||
$discountByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_discount'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$refundRows = $db->table('refunds')
|
|
||||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
|
||||||
->whereIn('invoice_id', $invoiceIds)
|
|
||||||
->whereIn('status', ['Partial', 'Paid']);
|
|
||||||
if (!empty($dateFrom)) {
|
|
||||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($dateTo)) {
|
|
||||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
foreach ($refundRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
|
||||||
$refundByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_refund'] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoices = [];
|
$invoices = [];
|
||||||
foreach ($invoiceRows as $row) {
|
foreach ($invoiceRows as $row) {
|
||||||
$iid = (int)($row['id'] ?? 0);
|
$iid = (int)($row['id'] ?? 0);
|
||||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||||
$refund = (float)($refundByInvoice[$iid] ?? 0);
|
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||||
$balance = round((float)($row['total_amount'] ?? 0) - $disc - $refund - $paid, 2);
|
$refund = (float)($ledger['refund_paid_total'] ?? 0);
|
||||||
|
$balance = (float)($ledger['balance'] ?? 0);
|
||||||
$invoices[] = [
|
$invoices[] = [
|
||||||
'Invoice #' => (string)($row['invoice_number'] ?? ''),
|
'Invoice #' => (string)($row['invoice_number'] ?? ''),
|
||||||
'Parent' => trim((string)($row['parent_name'] ?? '')),
|
'Parent' => trim((string)($row['parent_name'] ?? '')),
|
||||||
'Issue Date' => (string)($row['issue_date'] ?? ''),
|
'Issue Date' => (string)($row['issue_date'] ?? ''),
|
||||||
'Due Date' => (string)($row['due_date'] ?? ''),
|
'Due Date' => (string)($row['due_date'] ?? ''),
|
||||||
'Gross Charges' => (float)($row['total_amount'] ?? 0),
|
'Gross Charges' => (float)($ledger['total_amount'] ?? 0),
|
||||||
'Discounts' => $disc,
|
'Discounts' => $disc,
|
||||||
'Refunds' => $refund,
|
'Refunds' => $refund,
|
||||||
'Paid' => $paid,
|
'Paid' => $paid,
|
||||||
'Balance' => $balance,
|
'Balance' => $balance,
|
||||||
'Status' => (string)($row['status'] ?? ''),
|
'Status' => (string)($ledger['status'] ?? ($row['status'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,10 +1005,6 @@ public function financialReport()
|
|||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
|
|
||||||
$byParent = [];
|
$byParent = [];
|
||||||
$paymentsTbl = 'payments';
|
|
||||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
|
||||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
|
||||||
|
|
||||||
foreach ($invRows as $r) {
|
foreach ($invRows as $r) {
|
||||||
$iid = (int)($r['id'] ?? 0);
|
$iid = (int)($r['id'] ?? 0);
|
||||||
$pid = (int)($r['parent_id'] ?? 0);
|
$pid = (int)($r['parent_id'] ?? 0);
|
||||||
@@ -981,37 +1012,11 @@ public function financialReport()
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||||
if ($hasStatus) {
|
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||||
$qb->groupStart()
|
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
$total = (float)($ledger['total_amount'] ?? 0);
|
||||||
->orWhere('status IS NULL', null, false)
|
$balance = (float)($ledger['balance'] ?? 0);
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($hasVoid) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$paidRow = $qb->get()->getRowArray();
|
|
||||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
|
||||||
|
|
||||||
$discRow = $db->table('discount_usages')
|
|
||||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $iid)
|
|
||||||
->get()->getRowArray();
|
|
||||||
$discSum = (float)($discRow['tot'] ?? 0);
|
|
||||||
|
|
||||||
$refRow = $db->table('refunds')
|
|
||||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $iid)
|
|
||||||
->whereIn('status', ['Partial','Paid'])
|
|
||||||
->get()->getRowArray();
|
|
||||||
$refSum = (float)($refRow['tot'] ?? 0);
|
|
||||||
|
|
||||||
$total = (float)($r['total_amount'] ?? 0);
|
|
||||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
|
||||||
|
|
||||||
if (!isset($byParent[$pid])) {
|
if (!isset($byParent[$pid])) {
|
||||||
$byParent[$pid] = [
|
$byParent[$pid] = [
|
||||||
@@ -1271,8 +1276,10 @@ public function financialReport()
|
|||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
$paymentsMap = [];
|
$paymentsMap = [];
|
||||||
foreach ($payments as $payment) {
|
$invoiceIdsForExport = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoices)));
|
||||||
$paymentsMap[(int)($payment['invoice_id'] ?? 0)] = (float)($payment['paid_amount'] ?? 0);
|
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIdsForExport);
|
||||||
|
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||||
|
$paymentsMap[(int)$invoiceId] = (float)($ledger['paid_amount'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$paymentBreakdownRows = $applyPaymentFilters($buildPaymentModel())
|
$paymentBreakdownRows = $applyPaymentFilters($buildPaymentModel())
|
||||||
@@ -1328,47 +1335,11 @@ public function financialReport()
|
|||||||
}
|
}
|
||||||
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
|
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
|
||||||
|
|
||||||
// Refunds (grouped)
|
|
||||||
if (!empty($schoolYear)) {
|
|
||||||
$refundModel->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if ($hasFrom) {
|
|
||||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if ($hasTo) {
|
|
||||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
$refundsData = $refundModel
|
|
||||||
->select('invoice_id, SUM(refund_paid_amount) AS total_refunded')
|
|
||||||
->where('invoice_id IS NOT NULL')
|
|
||||||
->whereIn('status', ['Partial', 'Paid'])
|
|
||||||
->groupBy('invoice_id')
|
|
||||||
->findAll();
|
|
||||||
$refunds = [];
|
$refunds = [];
|
||||||
foreach ($refundsData as $refund) {
|
|
||||||
$refunds[$refund['invoice_id']] = $refund['total_refunded'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$discountBuilder = $discountModel;
|
|
||||||
if (!empty($schoolYear)) {
|
|
||||||
$discountBuilder->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if ($hasFrom) {
|
|
||||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if ($hasTo) {
|
|
||||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
$discountsData = $discountBuilder
|
|
||||||
->select('invoice_id, SUM(discount_amount) AS discount_amount')
|
|
||||||
->where('invoice_id IS NOT NULL')
|
|
||||||
->groupBy('invoice_id')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$discounts = [];
|
$discounts = [];
|
||||||
foreach ($discountsData as $disc) {
|
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||||
$discounts[$disc['invoice_id']] = $disc['discount_amount'];
|
$refunds[(int)$invoiceId] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||||
|
$discounts[(int)$invoiceId] = (float)($ledger['discount_total'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
||||||
@@ -1397,10 +1368,10 @@ public function financialReport()
|
|||||||
$check = (float)($bd['check'] ?? 0);
|
$check = (float)($bd['check'] ?? 0);
|
||||||
$refunded = (float)($refunds[$invoiceId] ?? 0);
|
$refunded = (float)($refunds[$invoiceId] ?? 0);
|
||||||
$discount = (float)($discounts[$invoiceId] ?? 0);
|
$discount = (float)($discounts[$invoiceId] ?? 0);
|
||||||
$total = (float)($inv['total_amount'] ?? 0);
|
$ledger = $ledgerByInvoice[$invoiceId] ?? [];
|
||||||
$balance = $total - $paid - $discount - $refunded;
|
$total = (float)($ledger['total_amount'] ?? 0);
|
||||||
if ($balance < 0) $balance = 0.0;
|
$balance = (float)($ledger['balance'] ?? 0);
|
||||||
$status = ($balance === 0.0) ? 'Paid' : 'Unpaid';
|
$status = (string)($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||||
|
|
||||||
fputcsv($out, [
|
fputcsv($out, [
|
||||||
$inv['invoice_number'],
|
$inv['invoice_number'],
|
||||||
@@ -1612,7 +1583,7 @@ public function financialReport()
|
|||||||
$additionalModel = new \App\Models\AdditionalChargeModel();
|
$additionalModel = new \App\Models\AdditionalChargeModel();
|
||||||
|
|
||||||
// Allow override via parameter; fallback to configured year
|
// Allow override via parameter; fallback to configured year
|
||||||
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
|
$schoolYear = $schoolYear ?: $this->currentSchoolYearName();
|
||||||
$derivedDateFrom = null;
|
$derivedDateFrom = null;
|
||||||
$derivedDateTo = null;
|
$derivedDateTo = null;
|
||||||
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
||||||
@@ -1638,11 +1609,21 @@ public function financialReport()
|
|||||||
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $invoiceDateTo);
|
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $invoiceDateTo);
|
||||||
}
|
}
|
||||||
$invoices = $invoiceBuilder->findAll();
|
$invoices = $invoiceBuilder->findAll();
|
||||||
$totalCharges = array_sum(array_column($invoices, 'total_amount'));
|
|
||||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||||
$id = (int)($inv['id'] ?? 0);
|
$id = (int)($inv['id'] ?? 0);
|
||||||
return $id > 0 ? $id : null;
|
return $id > 0 ? $id : null;
|
||||||
}, $invoices))));
|
}, $invoices))));
|
||||||
|
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||||
|
$totalCharges = 0.0;
|
||||||
|
$totalPaid = 0.0;
|
||||||
|
$totalDiscounts = 0.0;
|
||||||
|
$totalRefunds = 0.0;
|
||||||
|
foreach ($ledgerByInvoice as $ledger) {
|
||||||
|
$totalCharges += (float)($ledger['total_amount'] ?? 0);
|
||||||
|
$totalPaid += (float)($ledger['paid_amount'] ?? 0);
|
||||||
|
$totalDiscounts += (float)($ledger['discount_total'] ?? 0);
|
||||||
|
$totalRefunds += (float)($ledger['refund_paid_total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
// === Additional Charges ===
|
// === Additional Charges ===
|
||||||
$hasExplicitDates = !empty($dateFrom) || !empty($dateTo);
|
$hasExplicitDates = !empty($dateFrom) || !empty($dateTo);
|
||||||
@@ -1692,98 +1673,6 @@ public function financialReport()
|
|||||||
|
|
||||||
$totalCharges += $extraChargesUnapplied;
|
$totalCharges += $extraChargesUnapplied;
|
||||||
|
|
||||||
// === Payments: Total Paid ===
|
|
||||||
$paymentBuilder = $paymentModel->where('school_year', $schoolYear);
|
|
||||||
if (!empty($invoiceDateFrom)) {
|
|
||||||
$paymentBuilder->where('DATE(payment_date) >=', $invoiceDateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($invoiceDateTo)) {
|
|
||||||
$paymentBuilder->where('DATE(payment_date) <=', $invoiceDateTo);
|
|
||||||
}
|
|
||||||
$payHasStatus = $db->fieldExists('status', 'payments');
|
|
||||||
$payHasVoid = $db->fieldExists('is_void', 'payments');
|
|
||||||
if ($payHasStatus) {
|
|
||||||
$paymentBuilder->groupStart()
|
|
||||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($payHasVoid) {
|
|
||||||
$paymentBuilder->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$paymentResult = $paymentBuilder->selectSum('paid_amount')->get()->getRowArray();
|
|
||||||
$totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00;
|
|
||||||
|
|
||||||
// === Per-invoice paid/discount/refund totals for outstanding balance ===
|
|
||||||
$paidByInvoice = [];
|
|
||||||
$discountByInvoice = [];
|
|
||||||
$refundByInvoice = [];
|
|
||||||
if (!empty($invoiceIds)) {
|
|
||||||
$paidRows = $db->table('payments')
|
|
||||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
|
||||||
->whereIn('invoice_id', $invoiceIds);
|
|
||||||
if (!empty($schoolYear)) {
|
|
||||||
$paidRows->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if (!empty($invoiceDateFrom)) {
|
|
||||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($invoiceDateTo)) {
|
|
||||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
|
||||||
}
|
|
||||||
if ($payHasStatus) {
|
|
||||||
$paidRows->groupStart()
|
|
||||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($payHasVoid) {
|
|
||||||
$paidRows->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$paidRows = $paidRows->groupBy('invoice_id')->get()->getResultArray();
|
|
||||||
foreach ($paidRows as $r) {
|
|
||||||
$iid = (int)($r['invoice_id'] ?? 0);
|
|
||||||
if ($iid > 0) $paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$discRows = $db->table('discount_usages')
|
|
||||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
|
||||||
->whereIn('invoice_id', $invoiceIds);
|
|
||||||
if (!empty($dateFrom)) {
|
|
||||||
$discRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($dateTo)) {
|
|
||||||
$discRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
$discRows = $discRows->groupBy('invoice_id')->get()->getResultArray();
|
|
||||||
foreach ($discRows as $r) {
|
|
||||||
$iid = (int)($r['invoice_id'] ?? 0);
|
|
||||||
if ($iid > 0) $discountByInvoice[$iid] = (float)($r['total_disc'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$refRows = $db->table('refunds')
|
|
||||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
|
||||||
->whereIn('invoice_id', $invoiceIds)
|
|
||||||
->whereIn('status', ['Partial', 'Paid']);
|
|
||||||
if (!empty($dateFrom)) {
|
|
||||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($dateTo)) {
|
|
||||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
$refRows = $refRows->groupBy('invoice_id')->get()->getResultArray();
|
|
||||||
foreach ($refRows as $r) {
|
|
||||||
$iid = (int)($r['invoice_id'] ?? 0);
|
|
||||||
if ($iid > 0) $refundByInvoice[$iid] = (float)($r['total_refund'] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Expenses ===
|
// === Expenses ===
|
||||||
$expenseBuilder = $expenseModel->where('school_year', $schoolYear);
|
$expenseBuilder = $expenseModel->where('school_year', $schoolYear);
|
||||||
if (!empty($invoiceDateFrom)) {
|
if (!empty($invoiceDateFrom)) {
|
||||||
@@ -1921,39 +1810,6 @@ public function financialReport()
|
|||||||
$donationToSchool = $donationExpense + $donationReimb;
|
$donationToSchool = $donationExpense + $donationReimb;
|
||||||
$totalReimbursements = max(0.0, $totalReimbursements - $donationReimb);
|
$totalReimbursements = max(0.0, $totalReimbursements - $donationReimb);
|
||||||
|
|
||||||
// === Refunds ===
|
|
||||||
$refundBuilder = $refundModel
|
|
||||||
->where('school_year', $schoolYear)
|
|
||||||
->whereIn('status', ['Partial', 'Paid'])
|
|
||||||
->where('refund_paid_amount IS NOT NULL');
|
|
||||||
|
|
||||||
if (!empty($dateFrom)) {
|
|
||||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($dateTo)) {
|
|
||||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
$refundResult = $refundBuilder
|
|
||||||
->selectSum('refund_paid_amount')
|
|
||||||
->get()
|
|
||||||
->getRowArray();
|
|
||||||
|
|
||||||
$totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00;
|
|
||||||
|
|
||||||
// === Discounts ===
|
|
||||||
$discountBuilder = $discountModel
|
|
||||||
->join('invoices', 'invoices.id = discount_usages.invoice_id')
|
|
||||||
->where('invoices.school_year', $schoolYear);
|
|
||||||
if (!empty($dateFrom)) {
|
|
||||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom);
|
|
||||||
}
|
|
||||||
if (!empty($dateTo)) {
|
|
||||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo);
|
|
||||||
}
|
|
||||||
$discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray();
|
|
||||||
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
|
|
||||||
|
|
||||||
// === Net, Outstanding & Overpayments ===
|
// === Net, Outstanding & Overpayments ===
|
||||||
$overpaymentDetails = [];
|
$overpaymentDetails = [];
|
||||||
$totalUnpaid = 0.0;
|
$totalUnpaid = 0.0;
|
||||||
@@ -1962,15 +1818,16 @@ public function financialReport()
|
|||||||
$iid = (int)($inv['id'] ?? 0);
|
$iid = (int)($inv['id'] ?? 0);
|
||||||
$pid = (int)($inv['parent_id'] ?? 0);
|
$pid = (int)($inv['parent_id'] ?? 0);
|
||||||
if ($iid <= 0 || $pid <= 0) continue;
|
if ($iid <= 0 || $pid <= 0) continue;
|
||||||
$total = (float)($inv['total_amount'] ?? 0);
|
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
$total = (float)($ledger['total_amount'] ?? 0);
|
||||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||||
$ref = (float)($refundByInvoice[$iid] ?? 0);
|
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||||
$rawBal = round($total - $disc - $paid - $ref, 2);
|
$ref = (float)($ledger['refund_paid_total'] ?? 0);
|
||||||
if ($rawBal > 0.00001) {
|
$balance = (float)($ledger['balance'] ?? 0);
|
||||||
$totalUnpaid += $rawBal;
|
$credit = (float)($ledger['customer_credit'] ?? 0);
|
||||||
} elseif ($rawBal < -0.00001) {
|
if ($balance > 0.00001) {
|
||||||
$credit = abs($rawBal);
|
$totalUnpaid += $balance;
|
||||||
|
} elseif ($credit > 0.00001) {
|
||||||
$totalOverpaid += $credit;
|
$totalOverpaid += $credit;
|
||||||
$overpaymentDetails[] = [
|
$overpaymentDetails[] = [
|
||||||
'type' => 'invoice',
|
'type' => 'invoice',
|
||||||
@@ -1983,7 +1840,7 @@ public function financialReport()
|
|||||||
'discount_amount' => $disc,
|
'discount_amount' => $disc,
|
||||||
'refund_amount' => $ref,
|
'refund_amount' => $ref,
|
||||||
'paid_amount' => $paid,
|
'paid_amount' => $paid,
|
||||||
'note' => 'Invoice payments/discounts exceed net invoice charges.',
|
'note' => 'Invoice ledger shows customer credit.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2068,7 +1925,7 @@ public function financialReport()
|
|||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||||
if ($schoolYear === '') {
|
if ($schoolYear === '') {
|
||||||
$schoolYear = (string) ($configModel->getConfig('school_year') ?? date('Y'));
|
$schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? date('Y')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build school year options from invoices
|
// Build school year options from invoices
|
||||||
@@ -2089,9 +1946,7 @@ public function financialReport()
|
|||||||
$schoolYears[] = (string)$schoolYear;
|
$schoolYears[] = (string)$schoolYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate balances by parent for selected school year
|
// Aggregate balances by parent for selected school year from the canonical invoice ledger.
|
||||||
// IMPORTANT: Compute current balance = total - payments - discounts - refundsPaid
|
|
||||||
// rather than trusting invoices.balance which may become stale.
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$invRows = $db->table('invoices i')
|
$invRows = $db->table('invoices i')
|
||||||
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
|
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
|
||||||
@@ -2101,51 +1956,18 @@ public function financialReport()
|
|||||||
->orderBy('i.id', 'ASC')
|
->orderBy('i.id', 'ASC')
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
|
|
||||||
// Group by parent and compute balances dynamically
|
|
||||||
$byParent = [];
|
$byParent = [];
|
||||||
$paymentsTbl = 'payments';
|
|
||||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
|
||||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
|
||||||
|
|
||||||
foreach ($invRows as $r) {
|
foreach ($invRows as $r) {
|
||||||
$iid = (int)($r['id'] ?? 0);
|
$iid = (int)($r['id'] ?? 0);
|
||||||
$pid = (int)($r['parent_id'] ?? 0);
|
$pid = (int)($r['parent_id'] ?? 0);
|
||||||
if ($iid <= 0 || $pid <= 0) continue;
|
if ($iid <= 0 || $pid <= 0) continue;
|
||||||
|
|
||||||
// Sum payments for this invoice (exclude void/failed if such columns exist)
|
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||||
if ($hasStatus) {
|
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||||
$qb->groupStart()
|
$total = (float)($ledger['total_amount'] ?? 0);
|
||||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
$balance = (float)($ledger['balance'] ?? 0);
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($hasVoid) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$paidRow = $qb->get()->getRowArray();
|
|
||||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
|
||||||
|
|
||||||
// Sum discounts for this invoice
|
|
||||||
$discRow = $db->table('discount_usages')
|
|
||||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $iid)
|
|
||||||
->get()->getRowArray();
|
|
||||||
$discSum = (float)($discRow['tot'] ?? 0);
|
|
||||||
|
|
||||||
// Sum refunds PAID for this invoice (Partial/Paid only)
|
|
||||||
$refRow = $db->table('refunds')
|
|
||||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
|
||||||
->where('invoice_id', $iid)
|
|
||||||
->whereIn('status', ['Partial','Paid'])
|
|
||||||
->get()->getRowArray();
|
|
||||||
$refSum = (float)($refRow['tot'] ?? 0);
|
|
||||||
|
|
||||||
$total = (float)($r['total_amount'] ?? 0);
|
|
||||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
|
||||||
|
|
||||||
if (!isset($byParent[$pid])) {
|
if (!isset($byParent[$pid])) {
|
||||||
$byParent[$pid] = [
|
$byParent[$pid] = [
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ class FlagController extends Controller
|
|||||||
helper(['url', 'form']);
|
helper(['url', 'form']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||||
|
{
|
||||||
|
service('schoolYearWriteGuard')->assertWritable(
|
||||||
|
service('schoolYearContext')->forYearName($schoolYear)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$currentFlagModel = new CurrentFlagModel();
|
$currentFlagModel = new CurrentFlagModel();
|
||||||
@@ -35,8 +42,11 @@ class FlagController extends Controller
|
|||||||
$grades = [];
|
$grades = [];
|
||||||
|
|
||||||
// Retrieve flags and class sections that currently have active students
|
// Retrieve flags and class sections that currently have active students
|
||||||
$flags = $currentFlagModel->findAll();
|
|
||||||
$schoolYear = $configModel->getConfig('school_year');
|
$schoolYear = $configModel->getConfig('school_year');
|
||||||
|
if ((string)$schoolYear !== '' && $this->db->fieldExists('school_year', 'current_flag')) {
|
||||||
|
$currentFlagModel->where('school_year', (string)$schoolYear);
|
||||||
|
}
|
||||||
|
$flags = $currentFlagModel->findAll();
|
||||||
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
||||||
$classSections = [];
|
$classSections = [];
|
||||||
|
|
||||||
@@ -357,7 +367,7 @@ class FlagController extends Controller
|
|||||||
$userId = session()->get('user_id');
|
$userId = session()->get('user_id');
|
||||||
|
|
||||||
// Get the semester and school year from configuration
|
// Get the semester and school year from configuration
|
||||||
$semester = $configModel->getConfig('semester');
|
$semester = getSemester();
|
||||||
$schoolYear = $configModel->getConfig('school_year');
|
$schoolYear = $configModel->getConfig('school_year');
|
||||||
|
|
||||||
// Set the current system date and time for flag_datetime
|
// Set the current system date and time for flag_datetime
|
||||||
@@ -433,6 +443,13 @@ class FlagController extends Controller
|
|||||||
return $this->index();
|
return $this->index();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$flagData = $currentFlagModel->find($id);
|
||||||
|
if (!$flagData) {
|
||||||
|
session()->setFlashdata('error', 'Incident not found.');
|
||||||
|
return $this->index();
|
||||||
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
|
||||||
|
|
||||||
$update = ['flag_state' => $newState];
|
$update = ['flag_state' => $newState];
|
||||||
if ($newState === 'Closed') {
|
if ($newState === 'Closed') {
|
||||||
$update['updated_by_closed'] = $userId;
|
$update['updated_by_closed'] = $userId;
|
||||||
@@ -487,6 +504,7 @@ class FlagController extends Controller
|
|||||||
session()->setFlashdata('error', 'Incident not found.');
|
session()->setFlashdata('error', 'Incident not found.');
|
||||||
return redirect()->back();
|
return redirect()->back();
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
|
||||||
|
|
||||||
// Proceed only if flag is not closed
|
// Proceed only if flag is not closed
|
||||||
if ($flagData['flag_state'] !== 'Closed') {
|
if ($flagData['flag_state'] !== 'Closed') {
|
||||||
@@ -534,6 +552,7 @@ class FlagController extends Controller
|
|||||||
session()->setFlashdata('error', 'Incident not found.');
|
session()->setFlashdata('error', 'Incident not found.');
|
||||||
return redirect()->back();
|
return redirect()->back();
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
|
||||||
|
|
||||||
// Check if the flag is not already canceled
|
// Check if the flag is not already canceled
|
||||||
if ($flagData['flag_state'] !== 'Canceled') {
|
if ($flagData['flag_state'] !== 'Canceled') {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ class HealthController extends Controller
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'path' => $path,
|
|
||||||
'exists' => is_dir($path),
|
'exists' => is_dir($path),
|
||||||
'writable' => is_writable($path),
|
'writable' => is_writable($path),
|
||||||
];
|
];
|
||||||
@@ -65,7 +64,6 @@ class HealthController extends Controller
|
|||||||
'ok' => $ok,
|
'ok' => $ok,
|
||||||
'paths' => $pathsStatus,
|
'paths' => $pathsStatus,
|
||||||
'database' => $dbChecks,
|
'database' => $dbChecks,
|
||||||
'write_path' => WRITEPATH,
|
|
||||||
'timestamp' => date('c'),
|
'timestamp' => date('c'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Controllers\View;
|
|||||||
use App\Models\HomeworkModel;
|
use App\Models\HomeworkModel;
|
||||||
use App\Models\StudentModel;
|
use App\Models\StudentModel;
|
||||||
use App\Models\StudentClassModel;
|
use App\Models\StudentClassModel;
|
||||||
use CodeIgniter\Controller;
|
use App\Controllers\BaseController;
|
||||||
use App\Models\TeacherClassModel;
|
use App\Models\TeacherClassModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
@@ -16,7 +16,7 @@ use App\Controllers\View\GradingController;
|
|||||||
use App\Models\GradingLockModel;
|
use App\Models\GradingLockModel;
|
||||||
use App\Models\MissingScoreOverrideModel;
|
use App\Models\MissingScoreOverrideModel;
|
||||||
|
|
||||||
class HomeworkController extends Controller
|
class HomeworkController extends BaseController
|
||||||
{
|
{
|
||||||
protected $db;
|
protected $db;
|
||||||
protected $semesterScoreService;
|
protected $semesterScoreService;
|
||||||
@@ -42,7 +42,7 @@ class HomeworkController extends Controller
|
|||||||
$this->configModel = new ConfigurationModel();
|
$this->configModel = new ConfigurationModel();
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
|
|
||||||
// Log the service initialization
|
// Log the service initialization
|
||||||
log_message('debug', 'Initializing SemesterScoreService');
|
log_message('debug', 'Initializing SemesterScoreService');
|
||||||
@@ -59,11 +59,12 @@ class HomeworkController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateHomeworkScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
public function updateHomeworkScores(?array $scores = null, ?int $updatedBy = null, ?int $classSectionId = null)
|
||||||
{
|
{
|
||||||
$scores = $this->request->getPost('scores');
|
$scores = $this->request->getPost('scores');
|
||||||
$semester = $this->request->getPost('semester') ?? $this->semester;
|
$semester = $this->request->getPost('semester') ?? $this->semester;
|
||||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
|
|
||||||
if ($updatedBy === null) {
|
if ($updatedBy === null) {
|
||||||
$updatedBy = session()->get('user_id');
|
$updatedBy = session()->get('user_id');
|
||||||
@@ -175,7 +176,7 @@ class HomeworkController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
$studentUserInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||||
|
|
||||||
// Call the updateScoresForStudents method
|
// Call the updateScoresForStudents method
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +196,8 @@ class HomeworkController extends Controller
|
|||||||
$selectedSemester = $this->getSelectedSemester();
|
$selectedSemester = $this->getSelectedSemester();
|
||||||
$normalized = $this->normalizeSemesterSelection($selectedSemester);
|
$normalized = $this->normalizeSemesterSelection($selectedSemester);
|
||||||
$semesterLabel = $normalized !== '' ? ucfirst($normalized) : $selectedSemester;
|
$semesterLabel = $normalized !== '' ? ucfirst($normalized) : $selectedSemester;
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||||
|
|
||||||
// Step 1: Get the highest existing homework_index
|
// Step 1: Get the highest existing homework_index
|
||||||
$existingIndexes = $this->homeworkModel
|
$existingIndexes = $this->homeworkModel
|
||||||
@@ -202,7 +205,7 @@ class HomeworkController extends Controller
|
|||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
//->where('teacher_id', $updatedBy)
|
//->where('teacher_id', $updatedBy)
|
||||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
||||||
->where('school_year', $this->schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->groupBy('homework_index')
|
->groupBy('homework_index')
|
||||||
->orderBy('homework_index', 'DESC')
|
->orderBy('homework_index', 'DESC')
|
||||||
->findAll();
|
->findAll();
|
||||||
@@ -219,6 +222,7 @@ class HomeworkController extends Controller
|
|||||||
// Step 2: Get all students in the class
|
// Step 2: Get all students in the class
|
||||||
$students = $this->studentClassModel
|
$students = $this->studentClassModel
|
||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
// Step 3: Insert a new homework row for each student if not already exists
|
// Step 3: Insert a new homework row for each student if not already exists
|
||||||
@@ -233,7 +237,7 @@ class HomeworkController extends Controller
|
|||||||
->where('class_section_id', $classSectionId)
|
->where('class_section_id', $classSectionId)
|
||||||
//->where('teacher_id', $updatedBy)
|
//->where('teacher_id', $updatedBy)
|
||||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
||||||
->where('school_year', $this->schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing) continue;
|
if ($existing) continue;
|
||||||
@@ -246,7 +250,7 @@ class HomeworkController extends Controller
|
|||||||
'homework_index' => $nextIndex,
|
'homework_index' => $nextIndex,
|
||||||
'score' => null,
|
'score' => null,
|
||||||
'semester' => $semesterLabel,
|
'semester' => $semesterLabel,
|
||||||
'school_year' => $this->schoolYear,
|
'school_year' => $schoolYear,
|
||||||
'created_at' => utc_now(),
|
'created_at' => utc_now(),
|
||||||
'updated_at' => utc_now()
|
'updated_at' => utc_now()
|
||||||
];
|
];
|
||||||
@@ -269,15 +273,23 @@ class HomeworkController extends Controller
|
|||||||
public function showHomework()
|
public function showHomework()
|
||||||
{
|
{
|
||||||
$updatedBy = session()->get('user_id');
|
$updatedBy = session()->get('user_id');
|
||||||
$classSectionId = $this->getClassSectionIdForTeacher($updatedBy);
|
$semester = $this->getSelectedSemester();
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
$classSectionId = (int) (
|
||||||
|
$this->request->getGet('class_section_id')
|
||||||
|
?? $this->request->getPost('class_section_id')
|
||||||
|
?? 0
|
||||||
|
);
|
||||||
|
if ($classSectionId <= 0) {
|
||||||
|
$classSectionId = (int) ($this->getClassSectionIdForTeacher($updatedBy, $schoolYear, $semester) ?? 0);
|
||||||
|
}
|
||||||
if (!$classSectionId) {
|
if (!$classSectionId) {
|
||||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->set('class_section_id', $classSectionId);
|
session()->set('class_section_id', $classSectionId);
|
||||||
|
|
||||||
$semester = $this->getSelectedSemester();
|
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $schoolYear);
|
||||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $this->schoolYear);
|
|
||||||
if (empty($homeworkHeaders)) {
|
if (empty($homeworkHeaders)) {
|
||||||
$homeworkHeaders = [1];
|
$homeworkHeaders = [1];
|
||||||
}
|
}
|
||||||
@@ -285,15 +297,15 @@ class HomeworkController extends Controller
|
|||||||
$classSectionId,
|
$classSectionId,
|
||||||
$homeworkHeaders,
|
$homeworkHeaders,
|
||||||
$semester,
|
$semester,
|
||||||
$this->schoolYear
|
$schoolYear
|
||||||
);
|
);
|
||||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'homework');
|
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'homework');
|
||||||
|
|
||||||
return view('teacher/add_homework', [
|
return view('teacher/add_homework', [
|
||||||
'students' => $students,
|
'students' => $students,
|
||||||
'homeworkHeaders' => $homeworkHeaders,
|
'homeworkHeaders' => $homeworkHeaders,
|
||||||
'semester' => $semester,
|
'semester' => $semester,
|
||||||
'schoolYear' => $this->schoolYear,
|
'schoolYear' => $schoolYear,
|
||||||
'class_section_id' => $classSectionId,
|
'class_section_id' => $classSectionId,
|
||||||
'missingOkMap' => $missingOkMap,
|
'missingOkMap' => $missingOkMap,
|
||||||
]);
|
]);
|
||||||
@@ -392,7 +404,7 @@ class HomeworkController extends Controller
|
|||||||
return $this->showHomeworkMngt($updatedBy);
|
return $this->showHomeworkMngt($updatedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId);
|
$studentTeacherInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||||
// Call the updateScoresForStudents method
|
// Call the updateScoresForStudents method
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -503,9 +515,16 @@ class HomeworkController extends Controller
|
|||||||
return $students;
|
return $students;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getClassSectionIdForTeacher($updatedBy)
|
private function getClassSectionIdForTeacher($updatedBy, ?string $schoolYear = null, ?string $semester = null)
|
||||||
{
|
{
|
||||||
$class = $this->teacherClassModel->where('teacher_id', $updatedBy)->first();
|
$builder = $this->teacherClassModel->where('teacher_id', $updatedBy);
|
||||||
|
if ($schoolYear !== null && $schoolYear !== '') {
|
||||||
|
$builder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
if ($semester !== null && $semester !== '') {
|
||||||
|
$builder->where('semester', $semester);
|
||||||
|
}
|
||||||
|
$class = $builder->first();
|
||||||
return $class['class_section_id'] ?? null;
|
return $class['class_section_id'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class HomeworkTrackingController extends BaseController
|
|||||||
$this->homeworkModel = new HomeworkModel();
|
$this->homeworkModel = new HomeworkModel();
|
||||||
$this->db = \Config\Database::connect();
|
$this->db = \Config\Database::connect();
|
||||||
|
|
||||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
$this->semester = (string) (getSemester() ?? '');
|
||||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,13 +72,18 @@ class HomeworkTrackingController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
||||||
|
$semesterVariants = $this->getSemesterVariants($this->semester);
|
||||||
|
|
||||||
// Aggregate homework presence + first entered date per class_section_id + homework_index
|
// Aggregate submitted homework per class_section_id + homework_index.
|
||||||
|
// Adding a homework column creates blank rows for every student, so only
|
||||||
|
// indexes with at least one non-null score count as submitted.
|
||||||
$hwQ = $this->db->table('homework')
|
$hwQ = $this->db->table('homework')
|
||||||
->select('class_section_id, homework_index, MIN(created_at) AS first_created, COUNT(*) AS cnt')
|
->select('class_section_id, homework_index')
|
||||||
|
->select('MIN(CASE WHEN score IS NOT NULL THEN updated_at ELSE NULL END) AS first_submitted', false)
|
||||||
|
->select('COUNT(score) AS scored_count', false)
|
||||||
->where('school_year', $this->schoolYear);
|
->where('school_year', $this->schoolYear);
|
||||||
if ($limitToSemester && $this->semester !== '') {
|
if ($limitToSemester && $semesterVariants !== []) {
|
||||||
$hwQ->where('semester', $this->semester);
|
$hwQ->whereIn('semester', $semesterVariants);
|
||||||
}
|
}
|
||||||
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
||||||
->get()
|
->get()
|
||||||
@@ -90,60 +95,32 @@ class HomeworkTrackingController extends BaseController
|
|||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
$csid = (int)($r['class_section_id'] ?? 0);
|
||||||
$hi = (int)($r['homework_index'] ?? 0);
|
$hi = (int)($r['homework_index'] ?? 0);
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
$cnt = (int)($r['scored_count'] ?? 0);
|
||||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
||||||
$hasHomework[$csid][$hi] = true;
|
$hasHomework[$csid][$hi] = true;
|
||||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
$dateStr = substr((string)($r['first_submitted'] ?? ''), 0, 10);
|
||||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||||
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
|
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday.
|
$indexToDate = [];
|
||||||
$hb = $this->db->table('homework')
|
foreach ($dateToIndex as $ymd => $homeworkIndex) {
|
||||||
->select("class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt", false)
|
if ($homeworkIndex !== null) {
|
||||||
->where('school_year', $this->schoolYear);
|
$indexToDate[(int) $homeworkIndex] = $ymd;
|
||||||
if ($limitToSemester && $this->semester !== '') {
|
}
|
||||||
$hb->where('semester', $this->semester);
|
|
||||||
}
|
}
|
||||||
$rowsByDate = $hb->groupBy('class_section_id, DATE(created_at)', '', false)
|
|
||||||
->orderBy('hw_date', 'ASC', false)
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$hasHomeworkByDate = [];
|
$hasHomeworkByDate = [];
|
||||||
$hwEnteredAtByDate = [];
|
$hwEnteredAtByDate = [];
|
||||||
foreach ($rowsByDate as $r) {
|
foreach ($hasHomework as $csid => $indexes) {
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
foreach ($indexes as $homeworkIndex => $_submitted) {
|
||||||
$d = substr((string)($r['hw_date'] ?? ''), 0, 10);
|
$ymd = $indexToDate[(int) $homeworkIndex] ?? null;
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
if ($ymd === null) {
|
||||||
$firstCreated = substr((string)($r['first_created'] ?? ''), 0, 10);
|
continue;
|
||||||
if ($csid <= 0 || !$d || $cnt <= 0) { continue; }
|
|
||||||
|
|
||||||
// Find the index of the last Sunday on or before $d
|
|
||||||
$baseIndex = -1;
|
|
||||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
|
||||||
if ($sundays[$i] <= $d) { $baseIndex = $i; break; }
|
|
||||||
}
|
|
||||||
if ($baseIndex < 0) { continue; }
|
|
||||||
|
|
||||||
// Map this homework day to only the nearest prior non–No School Sunday
|
|
||||||
$j = $baseIndex;
|
|
||||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) { $j--; }
|
|
||||||
if ($j < 0) { continue; }
|
|
||||||
$sd = $sundays[$j];
|
|
||||||
|
|
||||||
// Mark homework on that Sunday for this class_section (single mapping)
|
|
||||||
$hasHomeworkByDate[$csid][$sd] = true;
|
|
||||||
if (empty($hwEnteredAtByDate[$csid][$sd])) {
|
|
||||||
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
|
|
||||||
} else {
|
|
||||||
// Keep the earliest date for display
|
|
||||||
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
|
|
||||||
$candidate = $firstCreated ?: $d;
|
|
||||||
if ($candidate && (!$existing || $candidate < $existing)) {
|
|
||||||
$hwEnteredAtByDate[$csid][$sd] = $candidate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$hasHomeworkByDate[(int) $csid][$ymd] = true;
|
||||||
|
$hwEnteredAtByDate[(int) $csid][$ymd] = $hwEnteredAt[(int) $csid][(int) $homeworkIndex] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +229,21 @@ class HomeworkTrackingController extends BaseController
|
|||||||
return $this->teacherAssignmentCache[$year];
|
return $this->teacherAssignmentCache[$year];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getSemesterVariants(string $semester): array
|
||||||
|
{
|
||||||
|
$trimmed = trim($semester);
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique([
|
||||||
|
$trimmed,
|
||||||
|
ucfirst(strtolower($trimmed)),
|
||||||
|
strtolower($trimmed),
|
||||||
|
strtoupper($trimmed),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute start/end dates for the given semester within the school year.
|
* Compute start/end dates for the given semester within the school year.
|
||||||
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Controllers\View;
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||||
use App\Models\ClassSectionModel;
|
use App\Models\ClassSectionModel;
|
||||||
use App\Models\InventoryItemModel;
|
use App\Models\InventoryItemModel;
|
||||||
use App\Models\InventoryCategoryModel;
|
use App\Models\InventoryCategoryModel;
|
||||||
@@ -43,7 +44,7 @@ class InventoryController extends BaseController
|
|||||||
$this->teacherClassModel = new TeacherClassModel();
|
$this->teacherClassModel = new TeacherClassModel();
|
||||||
$this->studentClassModel = new StudentClassModel();
|
$this->studentClassModel = new StudentClassModel();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->classSectionModel = new ClassSectionModel();
|
$this->classSectionModel = new ClassSectionModel();
|
||||||
$this->db = \Config\Database::connect();
|
$this->db = \Config\Database::connect();
|
||||||
$this->teacherModel = new TeacherModel();
|
$this->teacherModel = new TeacherModel();
|
||||||
@@ -75,14 +76,14 @@ class InventoryController extends BaseController
|
|||||||
$selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw);
|
$selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw);
|
||||||
|
|
||||||
$builder = $this->itemModel->where('type', $type);
|
$builder = $this->itemModel->where('type', $type);
|
||||||
if (strtolower($selectedYear) !== 'all') {
|
if ($type !== 'book') {
|
||||||
$builder->where('school_year', $selectedYear);
|
$this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem);
|
||||||
}
|
|
||||||
if ($selectedSem !== '') {
|
|
||||||
$builder->where('semester', $selectedSem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $builder->orderBy('name', 'ASC')->findAll();
|
$items = $builder->orderBy('name', 'ASC')->findAll();
|
||||||
|
if ($type === 'book') {
|
||||||
|
$items = $this->attachBookYearRows($items, $selectedYear);
|
||||||
|
}
|
||||||
$categories = $this->catModel->optionsForType($type);
|
$categories = $this->catModel->optionsForType($type);
|
||||||
|
|
||||||
// NEW: build UpdatedBy name map
|
// NEW: build UpdatedBy name map
|
||||||
@@ -110,6 +111,9 @@ class InventoryController extends BaseController
|
|||||||
|
|
||||||
// Load categories for THIS item type
|
// Load categories for THIS item type
|
||||||
$categories = $this->catModel->optionsForType($item['type']);
|
$categories = $this->catModel->optionsForType($item['type']);
|
||||||
|
if (($item['type'] ?? '') === 'book') {
|
||||||
|
$item = $this->withBookYearData($item);
|
||||||
|
}
|
||||||
|
|
||||||
return view($this->viewForForm($item['type']), [
|
return view($this->viewForForm($item['type']), [
|
||||||
'type' => $item['type'],
|
'type' => $item['type'],
|
||||||
@@ -139,6 +143,13 @@ class InventoryController extends BaseController
|
|||||||
if (!$this->itemModel->update($id, $data)) {
|
if (!$this->itemModel->update($id, $data)) {
|
||||||
return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors()));
|
return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors()));
|
||||||
}
|
}
|
||||||
|
if (($item['type'] ?? '') === 'book') {
|
||||||
|
$this->saveBookClassAssignments($id);
|
||||||
|
$gradeError = $this->syncBookCategoryGradeRange($data['category_id'] ?? null);
|
||||||
|
if ($gradeError !== null) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $gradeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
|
return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
|
||||||
}
|
}
|
||||||
@@ -152,6 +163,19 @@ class InventoryController extends BaseController
|
|||||||
$itemId = $this->itemModel->insert($itemData, true);
|
$itemId = $this->itemModel->insert($itemData, true);
|
||||||
if ($itemId) {
|
if ($itemId) {
|
||||||
$initialQty = (int) ($itemData['quantity'] ?? 0);
|
$initialQty = (int) ($itemData['quantity'] ?? 0);
|
||||||
|
if (($itemData['type'] ?? '') === 'book') {
|
||||||
|
$this->saveBookClassAssignments((int) $itemId);
|
||||||
|
$gradeError = $this->syncBookCategoryGradeRange($itemData['category_id'] ?? null);
|
||||||
|
if ($gradeError !== null) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $gradeError);
|
||||||
|
}
|
||||||
|
if ($initialQty !== 0) {
|
||||||
|
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||||
|
} else {
|
||||||
|
$this->recalcQuantity($itemId);
|
||||||
|
}
|
||||||
|
return redirect()->to(site_url("inventory/{$itemData['type']}"))->with('success', 'Item added.');
|
||||||
|
}
|
||||||
if ($initialQty !== 0) {
|
if ($initialQty !== 0) {
|
||||||
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||||
} else {
|
} else {
|
||||||
@@ -213,20 +237,12 @@ class InventoryController extends BaseController
|
|||||||
|
|
||||||
// Only books have grade range
|
// Only books have grade range
|
||||||
if ($data['type'] === 'book') {
|
if ($data['type'] === 'book') {
|
||||||
$gmin = $this->request->getPost('grade_min');
|
$normalized = $this->normalizeGradeRangePost();
|
||||||
$gmax = $this->request->getPost('grade_max');
|
if (is_string($normalized)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $normalized);
|
||||||
$gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin);
|
|
||||||
$gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax);
|
|
||||||
|
|
||||||
if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Grade Min cannot be greater than Grade Max.');
|
|
||||||
}
|
}
|
||||||
if ($gmin !== null && $gmin > 13) $gmin = 13;
|
$data['grade_min'] = $normalized['grade_min'];
|
||||||
if ($gmax !== null && $gmax > 13) $gmax = 13;
|
$data['grade_max'] = $normalized['grade_max'];
|
||||||
|
|
||||||
$data['grade_min'] = $gmin;
|
|
||||||
$data['grade_max'] = $gmax;
|
|
||||||
} else {
|
} else {
|
||||||
$data['grade_min'] = null;
|
$data['grade_min'] = null;
|
||||||
$data['grade_max'] = null;
|
$data['grade_max'] = null;
|
||||||
@@ -248,7 +264,14 @@ class InventoryController extends BaseController
|
|||||||
'A category with this Type & Name already exists. Please choose a different name or edit the existing one.'
|
'A category with this Type & Name already exists. Please choose a different name or edit the existing one.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$ok = $this->catModel->update((int)$id, $data);
|
$current = $this->db->table('inventory_categories')->where('id', (int) $id)->get()->getRowArray();
|
||||||
|
if (!$current) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Category not found.');
|
||||||
|
}
|
||||||
|
if (!empty($current['school_year'])) {
|
||||||
|
$data['school_year'] = $current['school_year'];
|
||||||
|
}
|
||||||
|
$ok = $this->writeCategoryRow((int) $id, $data);
|
||||||
} else {
|
} else {
|
||||||
// Creating: block if already exists
|
// Creating: block if already exists
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
@@ -279,7 +302,9 @@ class InventoryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
return redirect()->back()->withInput()->with('error', 'Failed to save category.');
|
$errors = $this->catModel->errors();
|
||||||
|
$message = $errors !== [] ? implode(', ', $errors) : 'Failed to save category.';
|
||||||
|
return redirect()->back()->withInput()->with('error', $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Category saved.');
|
return redirect()->back()->with('success', 'Category saved.');
|
||||||
@@ -345,6 +370,13 @@ class InventoryController extends BaseController
|
|||||||
if ($type === 'book') {
|
if ($type === 'book') {
|
||||||
$base['isbn'] = $this->post('isbn');
|
$base['isbn'] = $this->post('isbn');
|
||||||
$base['edition'] = $this->post('edition');
|
$base['edition'] = $this->post('edition');
|
||||||
|
$base['author'] = $this->post('author');
|
||||||
|
$base['is_active'] = 1;
|
||||||
|
$isbn = preg_replace('/[^0-9X]/i', '', strtoupper((string) $base['isbn']));
|
||||||
|
$edition = strtolower(trim((string) $base['edition']));
|
||||||
|
$sku = strtolower(trim((string) $base['sku']));
|
||||||
|
$base['isbn_edition_key'] = $isbn !== '' ? $isbn . '|' . $edition : null;
|
||||||
|
$base['sku_normalized'] = $sku !== '' ? $sku : null;
|
||||||
} else {
|
} else {
|
||||||
$base['isbn'] = $base['edition'] = null;
|
$base['isbn'] = $base['edition'] = null;
|
||||||
}
|
}
|
||||||
@@ -352,6 +384,270 @@ class InventoryController extends BaseController
|
|||||||
return $base;
|
return $base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function withBookYearData(array $item): array
|
||||||
|
{
|
||||||
|
$itemYear = $this->bookItemYear((int) $item['id'], false);
|
||||||
|
if ($itemYear !== null) {
|
||||||
|
$item['charge_price'] = number_format(((int) ($itemYear['charge_price_cents'] ?? 0)) / 100, 2, '.', '');
|
||||||
|
$item['price_confirmed'] = (int) ($itemYear['price_confirmed'] ?? 0);
|
||||||
|
$item['item_year_id'] = (int) $itemYear['id'];
|
||||||
|
$item['opening_quantity'] = (int) ($itemYear['opening_quantity'] ?? 0);
|
||||||
|
}
|
||||||
|
$item['classes'] = array_map(
|
||||||
|
'intval',
|
||||||
|
array_column(
|
||||||
|
$this->db->table('inventory_book_class_assignments')
|
||||||
|
->select('class_number')
|
||||||
|
->where('inventory_item_id', (int) $item['id'])
|
||||||
|
->where('school_year', $this->schoolYear)
|
||||||
|
->orderBy('class_number', 'ASC')
|
||||||
|
->get()->getResultArray(),
|
||||||
|
'class_number'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachBookYearRows(array $items, string $schoolYear): array
|
||||||
|
{
|
||||||
|
$ids = array_values(array_filter(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $items)));
|
||||||
|
if ($ids === []) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
$rows = $this->db->table('inventory_item_years')
|
||||||
|
->whereIn('inventory_item_id', $ids)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->get()->getResultArray();
|
||||||
|
$byItem = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$byItem[(int) $row['inventory_item_id']] = $row;
|
||||||
|
}
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
$row = $byItem[(int) ($item['id'] ?? 0)] ?? null;
|
||||||
|
if ($row !== null) {
|
||||||
|
$item['charge_price_cents'] = (int) ($row['charge_price_cents'] ?? 0);
|
||||||
|
$item['price_confirmed'] = (int) ($row['price_confirmed'] ?? 0);
|
||||||
|
$item['opening_quantity'] = (int) ($row['opening_quantity'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bookPrices()
|
||||||
|
{
|
||||||
|
$books = $this->itemModel
|
||||||
|
->where('type', 'book')
|
||||||
|
->orderBy('name', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
$books = $this->attachBookYearRows($books, (string) $this->schoolYear);
|
||||||
|
|
||||||
|
return view('inventory/book/prices', [
|
||||||
|
'books' => $books,
|
||||||
|
'categories' => $this->catModel->optionsForType('book'),
|
||||||
|
'schoolYear' => $this->schoolYear,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBookPrices()
|
||||||
|
{
|
||||||
|
$prices = (array) $this->request->getPost('prices');
|
||||||
|
$confirmed = (array) $this->request->getPost('confirmed');
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', array_keys($prices)))));
|
||||||
|
|
||||||
|
if ($ids === []) {
|
||||||
|
return redirect()->back()->with('warning', 'No book prices were submitted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$books = $this->itemModel
|
||||||
|
->where('type', 'book')
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->findAll();
|
||||||
|
$booksById = [];
|
||||||
|
foreach ($books as $book) {
|
||||||
|
$booksById[(int) $book['id']] = $book;
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = $this->currentSchoolYearRow();
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->db->transStart();
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (! isset($booksById[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isConfirmed = isset($confirmed[$id]) && (string) $confirmed[$id] === '1';
|
||||||
|
$priceCents = $this->moneyValueToCents($prices[$id] ?? '');
|
||||||
|
if ($isConfirmed && $priceCents <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Confirmed book prices must be greater than $0.00.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemYear = $this->bookItemYear($id, false);
|
||||||
|
$payload = [
|
||||||
|
'inventory_item_id' => $id,
|
||||||
|
'school_year_id' => (int) ($year['id'] ?? 0) ?: null,
|
||||||
|
'school_year' => $this->schoolYear,
|
||||||
|
'charge_price_cents' => $priceCents,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'price_confirmed' => $isConfirmed ? 1 : 0,
|
||||||
|
'status' => 'open',
|
||||||
|
'updated_by' => $this->currentUserId(),
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($itemYear === null) {
|
||||||
|
$payload['opening_quantity'] = (int) ($booksById[$id]['quantity'] ?? 0);
|
||||||
|
$payload['created_by'] = $this->currentUserId();
|
||||||
|
$payload['created_at'] = utc_now();
|
||||||
|
$this->db->table('inventory_item_years')->insert($payload);
|
||||||
|
} else {
|
||||||
|
$this->db->table('inventory_item_years')->where('id', (int) $itemYear['id'])->update($payload);
|
||||||
|
}
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
$this->db->transComplete();
|
||||||
|
|
||||||
|
if ($this->db->transStatus() === false) {
|
||||||
|
throw new \RuntimeException('Book prices could not be saved.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url('inventory/book-prices'))->with('success', 'Saved prices for ' . $updated . ' book(s).');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveBookClassAssignments(int $itemId): void
|
||||||
|
{
|
||||||
|
$classes = array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', (array) $this->request->getPost('classes')),
|
||||||
|
static fn (int $class): bool => $class >= 1 && $class <= 13
|
||||||
|
)));
|
||||||
|
$this->db->table('inventory_book_class_assignments')
|
||||||
|
->where('inventory_item_id', $itemId)
|
||||||
|
->where('school_year', $this->schoolYear)
|
||||||
|
->delete();
|
||||||
|
foreach ($classes as $classNumber) {
|
||||||
|
$this->db->table('inventory_book_class_assignments')->insert([
|
||||||
|
'inventory_item_id' => $itemId,
|
||||||
|
'school_year' => $this->schoolYear,
|
||||||
|
'class_number' => $classNumber,
|
||||||
|
'created_at' => utc_now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist grade_min/grade_max onto the selected book category.
|
||||||
|
* Returns an error message string on failure, or null on success/no-op.
|
||||||
|
*/
|
||||||
|
private function syncBookCategoryGradeRange($categoryId): ?string
|
||||||
|
{
|
||||||
|
$categoryId = (int) ($categoryId ?? 0);
|
||||||
|
if ($categoryId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled inputs are omitted from POST; do not wipe an existing category range.
|
||||||
|
$post = $this->request->getPost();
|
||||||
|
if (! is_array($post)
|
||||||
|
|| (! array_key_exists('grade_min', $post) && ! array_key_exists('grade_max', $post))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeGradeRangePost();
|
||||||
|
if (is_string($normalized)) {
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = $this->db->table('inventory_categories')->where('id', $categoryId)->get()->getRowArray();
|
||||||
|
if (!$category || ($category['type'] ?? '') !== 'book') {
|
||||||
|
return 'Selected category was not found or is not a book category.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'grade_min' => $normalized['grade_min'],
|
||||||
|
'grade_max' => $normalized['grade_max'],
|
||||||
|
];
|
||||||
|
if (!empty($category['school_year'])) {
|
||||||
|
$payload['school_year'] = $category['school_year'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->writeCategoryRow($categoryId, $payload)) {
|
||||||
|
return 'Failed to update category grade range.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeCategoryRow(int $id, array $data): bool
|
||||||
|
{
|
||||||
|
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return (bool) $this->db->table('inventory_categories')->where('id', $id)->update($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{grade_min: ?int, grade_max: ?int}|string
|
||||||
|
*/
|
||||||
|
private function normalizeGradeRangePost()
|
||||||
|
{
|
||||||
|
$gmin = $this->request->getPost('grade_min');
|
||||||
|
$gmax = $this->request->getPost('grade_max');
|
||||||
|
|
||||||
|
$gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int) $gmin);
|
||||||
|
$gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int) $gmax);
|
||||||
|
|
||||||
|
if ($gmin !== null && $gmin > 13) {
|
||||||
|
$gmin = 13;
|
||||||
|
}
|
||||||
|
if ($gmax !== null && $gmax > 13) {
|
||||||
|
$gmax = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
|
||||||
|
return 'Grade Min cannot be greater than Grade Max.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'grade_min' => $gmin,
|
||||||
|
'grade_max' => $gmax,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function moneyValueToCents($value): int
|
||||||
|
{
|
||||||
|
$raw = trim((string) $value);
|
||||||
|
if ($raw === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $raw)) {
|
||||||
|
throw new \InvalidArgumentException('Book charge price must be a valid dollar amount with at most two decimals.');
|
||||||
|
}
|
||||||
|
return (int) round(((float) $raw) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYearRow(): ?array
|
||||||
|
{
|
||||||
|
return $this->db->table('school_years')
|
||||||
|
->where('name', $this->schoolYear)
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bookItemYear(int $itemId, bool $forUpdate = false): ?array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year = ? LIMIT 1';
|
||||||
|
if ($forUpdate) {
|
||||||
|
$sql .= ' FOR UPDATE';
|
||||||
|
}
|
||||||
|
return $this->db->query($sql, [$itemId, $this->schoolYear])->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
public function auditClassroomForm(int $itemId)
|
public function auditClassroomForm(int $itemId)
|
||||||
{
|
{
|
||||||
$item = $this->itemModel->find($itemId);
|
$item = $this->itemModel->find($itemId);
|
||||||
@@ -474,17 +770,26 @@ class InventoryController extends BaseController
|
|||||||
// Distinct school years for a given inventory type (newest first).
|
// Distinct school years for a given inventory type (newest first).
|
||||||
private function getSchoolYearsForType(string $type): array
|
private function getSchoolYearsForType(string $type): array
|
||||||
{
|
{
|
||||||
// Pull distinct non-null school_year values for this type
|
$rows = $this->db->table('inventory_movements m')
|
||||||
$years = $this->itemModel
|
->distinct()
|
||||||
->select('school_year')
|
->select('m.school_year')
|
||||||
->where('type', $type)
|
->join('inventory_items i', 'i.id = m.item_id', 'inner')
|
||||||
->where('school_year IS NOT NULL', null, false)
|
->where('m.school_year IS NOT NULL', null, false)
|
||||||
->groupBy('school_year')
|
->where("TRIM(m.school_year) <>", '')
|
||||||
->orderBy('school_year', 'DESC')
|
->orderBy('m.school_year', 'DESC');
|
||||||
->findColumn('school_year') ?? [];
|
|
||||||
|
if (strtolower($type) !== 'all') {
|
||||||
|
$rows->where('i.type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $rows->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
// Normalize & de-dup
|
// Normalize & de-dup
|
||||||
$years = array_values(array_unique(array_filter(array_map('trim', $years))));
|
$years = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn(array $row): string => trim((string)($row['school_year'] ?? '')),
|
||||||
|
$rows
|
||||||
|
))));
|
||||||
|
|
||||||
// Ensure the current year (from config) appears as an option, even if no rows yet
|
// Ensure the current year (from config) appears as an option, even if no rows yet
|
||||||
$currentYear = $this->schoolYear;
|
$currentYear = $this->schoolYear;
|
||||||
@@ -496,6 +801,31 @@ class InventoryController extends BaseController
|
|||||||
return $years;
|
return $years;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyInventoryMovementPeriodFilter($builder, string $itemAlias, ?string $schoolYear, ?string $semester = null): void
|
||||||
|
{
|
||||||
|
$schoolYear = trim((string) $schoolYear);
|
||||||
|
$semester = trim((string) $semester);
|
||||||
|
$clauses = ["m.item_id = {$itemAlias}.id"];
|
||||||
|
|
||||||
|
if ($schoolYear !== '' && strtolower($schoolYear) !== 'all') {
|
||||||
|
$clauses[] = 'm.school_year = ' . $this->db->escape($schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($semester !== '') {
|
||||||
|
$clauses[] = 'm.semester = ' . $this->db->escape($semester);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($clauses) <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->where(
|
||||||
|
'EXISTS (SELECT 1 FROM inventory_movements m WHERE ' . implode(' AND ', $clauses) . ')',
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function recordMovement(
|
private function recordMovement(
|
||||||
int $itemId,
|
int $itemId,
|
||||||
int $qtyChange,
|
int $qtyChange,
|
||||||
@@ -684,6 +1014,8 @@ class InventoryController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function teacherDistributeForm()
|
public function teacherDistributeForm()
|
||||||
{
|
{
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
|
||||||
// 1) Build teacher/class context (auth/role/no-class handled inside)
|
// 1) Build teacher/class context (auth/role/no-class handled inside)
|
||||||
$ctx = $this->buildTeacherClassContext();
|
$ctx = $this->buildTeacherClassContext();
|
||||||
if ($ctx instanceof \CodeIgniter\HTTP\RedirectResponse) {
|
if ($ctx instanceof \CodeIgniter\HTTP\RedirectResponse) {
|
||||||
@@ -710,12 +1042,17 @@ class InventoryController extends BaseController
|
|||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$builder = $db->table('inventory_items i')
|
$builder = $db->table('inventory_items i')
|
||||||
->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type,
|
->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type,
|
||||||
|
iy.id AS item_year_id, iy.charge_price_cents, iy.price_confirmed,
|
||||||
c.name AS category_name, c.grade_min, c.grade_max')
|
c.name AS category_name, c.grade_min, c.grade_max')
|
||||||
->join('inventory_categories c', 'c.id = i.category_id', 'left')
|
->join('inventory_categories c', 'c.id = i.category_id', 'left')
|
||||||
|
->join('inventory_item_years iy', 'iy.inventory_item_id = i.id AND iy.school_year = ' . $db->escape($this->schoolYear), 'left')
|
||||||
|
->join('inventory_book_class_assignments bca', 'bca.inventory_item_id = i.id AND bca.school_year = ' . $db->escape($this->schoolYear), 'left')
|
||||||
->where('i.type', 'book');
|
->where('i.type', 'book');
|
||||||
|
|
||||||
if (!empty($classID)) {
|
if (!empty($classID)) {
|
||||||
$builder->groupStart()
|
$builder->groupStart()
|
||||||
|
->where('bca.class_number', (int) $classID)
|
||||||
|
->orGroupStart()
|
||||||
// Case A: both bounds set and classID between [min..max]
|
// Case A: both bounds set and classID between [min..max]
|
||||||
->groupStart()
|
->groupStart()
|
||||||
->where('c.grade_min IS NOT NULL', null, false)
|
->where('c.grade_min IS NOT NULL', null, false)
|
||||||
@@ -737,10 +1074,11 @@ class InventoryController extends BaseController
|
|||||||
->where('c.grade_min', null) // IS NULL
|
->where('c.grade_min', null) // IS NULL
|
||||||
->where('c.grade_max', (int)$classID) // =
|
->where('c.grade_max', (int)$classID) // =
|
||||||
->groupEnd()
|
->groupEnd()
|
||||||
|
->groupEnd()
|
||||||
->groupEnd();
|
->groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $builder->orderBy('i.name', 'ASC')->get()->getResultArray();
|
$rows = $builder->groupBy('i.id')->orderBy('i.name', 'ASC')->get()->getResultArray();
|
||||||
|
|
||||||
// Build CATEGORY groups keyed by category_id
|
// Build CATEGORY groups keyed by category_id
|
||||||
$labelFor = static function (?array $r): string {
|
$labelFor = static function (?array $r): string {
|
||||||
@@ -771,6 +1109,8 @@ class InventoryController extends BaseController
|
|||||||
'id' => (int)$r['id'],
|
'id' => (int)$r['id'],
|
||||||
'display' => $display,
|
'display' => $display,
|
||||||
'quantity' => (int)($r['quantity'] ?? 0),
|
'quantity' => (int)($r['quantity'] ?? 0),
|
||||||
|
'charge_price_cents' => (int)($r['charge_price_cents'] ?? 0),
|
||||||
|
'price_confirmed' => (int)($r['price_confirmed'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,16 +1127,16 @@ class InventoryController extends BaseController
|
|||||||
// 7) History map: how many of the selected book each student already received this school year
|
// 7) History map: how many of the selected book each student already received this school year
|
||||||
$already = [];
|
$already = [];
|
||||||
if ($itemId && $classSectionId) {
|
if ($itemId && $classSectionId) {
|
||||||
$rowsHist = $this->movModel
|
$itemYear = $this->bookItemYear($itemId, false);
|
||||||
->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
|
$rowsHist = $this->db->table('student_book_issues')
|
||||||
->where([
|
->select('student_id, SUM(quantity) AS qty')
|
||||||
'item_id' => $itemId,
|
->where('inventory_item_id', $itemId)
|
||||||
'movement_type' => 'distribution',
|
->where('inventory_item_year_id', (int) ($itemYear['id'] ?? 0))
|
||||||
'class_section_id' => $classSectionId,
|
->where('class_section_id', $classSectionId)
|
||||||
'school_year' => $ctx['schoolYear'],
|
->where('school_year', $ctx['schoolYear'])
|
||||||
])
|
->where('status', 'issued')
|
||||||
->groupBy('student_id')
|
->groupBy('student_id')
|
||||||
->findAll();
|
->get()->getResultArray();
|
||||||
|
|
||||||
foreach ($rowsHist as $r) {
|
foreach ($rowsHist as $r) {
|
||||||
$sid = (int) ($r['student_id'] ?? 0);
|
$sid = (int) ($r['student_id'] ?? 0);
|
||||||
@@ -806,11 +1146,16 @@ class InventoryController extends BaseController
|
|||||||
|
|
||||||
// 8) On-hand for the selected book (to show live available stock)
|
// 8) On-hand for the selected book (to show live available stock)
|
||||||
$onHand = 0;
|
$onHand = 0;
|
||||||
|
$unitChargeCents = 0;
|
||||||
|
$priceConfirmed = 0;
|
||||||
if ($itemId) {
|
if ($itemId) {
|
||||||
$book = $this->itemModel->find($itemId);
|
$book = $this->itemModel->find($itemId);
|
||||||
if ($book && ($book['type'] ?? '') === 'book') {
|
if ($book && ($book['type'] ?? '') === 'book') {
|
||||||
$onHand = (int) ($book['quantity'] ?? 0);
|
$onHand = (int) ($book['quantity'] ?? 0);
|
||||||
}
|
}
|
||||||
|
$itemYear = $this->bookItemYear($itemId, false);
|
||||||
|
$unitChargeCents = (int) ($itemYear['charge_price_cents'] ?? 0);
|
||||||
|
$priceConfirmed = (int) ($itemYear['price_confirmed'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9) Render view
|
// 9) Render view
|
||||||
@@ -826,14 +1171,24 @@ class InventoryController extends BaseController
|
|||||||
'students' => $students,
|
'students' => $students,
|
||||||
'already' => $already,
|
'already' => $already,
|
||||||
'onHand' => $onHand,
|
'onHand' => $onHand,
|
||||||
|
'unitChargeCents' => $unitChargeCents,
|
||||||
|
'priceConfirmed' => $priceConfirmed,
|
||||||
|
|
||||||
'schoolYear' => $ctx['schoolYear'],
|
'schoolYear' => $ctx['schoolYear'],
|
||||||
'semester' => $ctx['semester'],
|
'semester' => $ctx['semester'],
|
||||||
|
'isEditable' => ! $schoolYearContext->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function teacherDistributeStore()
|
public function teacherDistributeStore()
|
||||||
{
|
{
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
try {
|
||||||
|
$this->assertSchoolYearWritable($schoolYearContext);
|
||||||
|
} catch (SchoolYearWriteConflictException $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$ctx = $this->buildTeacherClassContext();
|
$ctx = $this->buildTeacherClassContext();
|
||||||
if ($ctx instanceof \CodeIgniter\HTTP\RedirectResponse) {
|
if ($ctx instanceof \CodeIgniter\HTTP\RedirectResponse) {
|
||||||
return $ctx;
|
return $ctx;
|
||||||
@@ -870,50 +1225,34 @@ class InventoryController extends BaseController
|
|||||||
$selectedIds = array_values(array_unique(array_map('intval', $selectedIds)));
|
$selectedIds = array_values(array_unique(array_map('intval', $selectedIds)));
|
||||||
$selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid])));
|
$selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid])));
|
||||||
|
|
||||||
// Already distributed this year for this class/book
|
try {
|
||||||
$already = [];
|
$year = $this->currentSchoolYearRow();
|
||||||
$rows = $this->movModel
|
$result = (new \App\Services\StudentBookIssueService($this->db))->distributeBatch(
|
||||||
->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
|
$itemId,
|
||||||
->where([
|
$selectedIds,
|
||||||
'item_id' => $itemId,
|
$classSectionId,
|
||||||
'movement_type' => 'distribution',
|
(int) ($year['id'] ?? 0),
|
||||||
'class_section_id' => $classSectionId,
|
(string) $ctx['schoolYear'],
|
||||||
'school_year' => $ctx['schoolYear'],
|
$this->currentUserId(),
|
||||||
])->groupBy('student_id')->findAll();
|
$note,
|
||||||
foreach ($rows as $r) {
|
null,
|
||||||
$sid = (int)($r['student_id'] ?? 0);
|
(string) $this->request->getPost('idempotency_key')
|
||||||
if ($sid) $already[$sid] = (int)$r['qty'];
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return redirect()
|
||||||
|
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||||
|
->withInput()
|
||||||
|
->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only assign to students who don't already have it
|
if ((int) ($result['issued'] ?? 0) === 0) {
|
||||||
$toGive = [];
|
|
||||||
foreach ($selectedIds as $sid) {
|
|
||||||
if (($already[$sid] ?? 0) < 1) $toGive[] = $sid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stock check
|
|
||||||
$needed = count($toGive);
|
|
||||||
$onHand = (int)$book['quantity'];
|
|
||||||
if ($needed > $onHand) {
|
|
||||||
return redirect()->back()->withInput()->with('error', 'Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record one movement per student
|
|
||||||
$okCount = 0;
|
|
||||||
foreach ($toGive as $sid) {
|
|
||||||
$ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid);
|
|
||||||
if ($ok) $okCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($okCount === 0) {
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||||
->with('warning', 'No changes (everyone selected already has this book).');
|
->with('warning', 'No changes (everyone selected already has this book).');
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||||
->with('success', 'Distributed to ' . $okCount . ' student(s).');
|
->with('success', 'Distributed to ' . (int) $result['issued'] . ' student(s).');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -949,7 +1288,10 @@ class InventoryController extends BaseController
|
|||||||
->where('ur.user_id', $user_id)
|
->where('ur.user_id', $user_id)
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
|
|
||||||
$roleNames = array_map(fn($r) => $r['name'], $roles);
|
$roleNames = array_map(
|
||||||
|
static fn($r): string => strtolower(trim((string) ($r['name'] ?? ''))),
|
||||||
|
$roles
|
||||||
|
);
|
||||||
|
|
||||||
if (in_array('teacher', $roleNames, true)) {
|
if (in_array('teacher', $roleNames, true)) {
|
||||||
$user['role_name'] = 'teacher';
|
$user['role_name'] = 'teacher';
|
||||||
@@ -960,12 +1302,15 @@ class InventoryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4) Collect class_section IDs
|
// 4) Collect class_section IDs
|
||||||
$classSectionIds = array_column($classes, 'class_section_id');
|
$classSectionIds = array_values(array_filter(array_map(
|
||||||
|
static fn($class): int => (int) ($class['class_section_id'] ?? 0),
|
||||||
|
$classes
|
||||||
|
)));
|
||||||
$studentsData = [];
|
$studentsData = [];
|
||||||
|
|
||||||
// 5) Group students by class_section_id
|
// 5) Group students by class_section_id
|
||||||
if (!empty($classSectionIds)) {
|
if (!empty($classSectionIds)) {
|
||||||
$students = $this->studentClassModel->getStudentsByClassSectionIds($classSectionIds);
|
$students = $this->studentClassModel->getStudentsByClassSectionIds($classSectionIds, $this->schoolYear);
|
||||||
foreach ($students as $student) {
|
foreach ($students as $student) {
|
||||||
$studentsData[$student['class_section_id']][] = $student;
|
$studentsData[$student['class_section_id']][] = $student;
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1410,9 @@ class InventoryController extends BaseController
|
|||||||
|
|
||||||
public function create(string $type = 'classroom')
|
public function create(string $type = 'classroom')
|
||||||
{
|
{
|
||||||
|
$type = $this->normalizeType($type);
|
||||||
$categories = $this->catModel->optionsForType($type);
|
$categories = $this->catModel->optionsForType($type);
|
||||||
|
|
||||||
return view("inventory/{$type}/form", [
|
return view("inventory/{$type}/form", [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'item' => [], // important for create
|
'item' => [], // important for create
|
||||||
@@ -1095,7 +1442,7 @@ class InventoryController extends BaseController
|
|||||||
|
|
||||||
// Filter by school year unless "all"
|
// Filter by school year unless "all"
|
||||||
if (!$isAllYears) {
|
if (!$isAllYears) {
|
||||||
$qbItems->where('i.school_year', $selectedYear);
|
$this->applyInventoryMovementPeriodFilter($qbItems, 'i', $selectedYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: filter by type if provided
|
// Optional: filter by type if provided
|
||||||
@@ -1123,7 +1470,7 @@ class InventoryController extends BaseController
|
|||||||
return view('inventory/summary', [
|
return view('inventory/summary', [
|
||||||
'selectedYear' => $selectedYear,
|
'selectedYear' => $selectedYear,
|
||||||
'currentYear' => $this->schoolYear,
|
'currentYear' => $this->schoolYear,
|
||||||
'schoolYears' => $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All'],
|
'schoolYears' => $this->getSchoolYearsForType($selectedType) ?: [$this->schoolYear, 'All'],
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'totals' => $totals,
|
'totals' => $totals,
|
||||||
// optionally pass the selected type if your view supports it
|
// optionally pass the selected type if your view supports it
|
||||||
@@ -1202,7 +1549,7 @@ class InventoryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------- Year dropdown options --------
|
// -------- Year dropdown options --------
|
||||||
$schoolYears = $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All'];
|
$schoolYears = $this->getSchoolYearsForType($selectedType) ?: [$this->schoolYear, 'All'];
|
||||||
|
|
||||||
return view('inventory/summary', [
|
return view('inventory/summary', [
|
||||||
'selectedYear' => $selectedYear,
|
'selectedYear' => $selectedYear,
|
||||||
@@ -1307,6 +1654,10 @@ class InventoryController extends BaseController
|
|||||||
$userId = (int) (session('user_id') ?? 0);
|
$userId = (int) (session('user_id') ?? 0);
|
||||||
$itemId = (int) $this->request->getPost('item_id');
|
$itemId = (int) $this->request->getPost('item_id');
|
||||||
$movementType = (string) $this->request->getPost('movement_type');
|
$movementType = (string) $this->request->getPost('movement_type');
|
||||||
|
if ($movementType === 'distribution') {
|
||||||
|
return redirect()->back()->withInput()->with('status', 'error')
|
||||||
|
->with('message', 'Student book distributions must be created from the book distribution workflow.');
|
||||||
|
}
|
||||||
|
|
||||||
$item = $this->itemModel->find($itemId);
|
$item = $this->itemModel->find($itemId);
|
||||||
if (!$item) {
|
if (!$item) {
|
||||||
@@ -1364,6 +1715,10 @@ class InventoryController extends BaseController
|
|||||||
return redirect()->to(site_url('inventory/movements'))
|
return redirect()->to(site_url('inventory/movements'))
|
||||||
->with('status', 'error')->with('message', 'Movement not found.');
|
->with('status', 'error')->with('message', 'Movement not found.');
|
||||||
}
|
}
|
||||||
|
if ($this->isProtectedMovement($movement)) {
|
||||||
|
return redirect()->to(site_url('inventory/movements'))
|
||||||
|
->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||||
|
}
|
||||||
|
|
||||||
// enrich for display (names)
|
// enrich for display (names)
|
||||||
$movement = $this->hydrateNames($movement);
|
$movement = $this->hydrateNames($movement);
|
||||||
@@ -1409,8 +1764,16 @@ class InventoryController extends BaseController
|
|||||||
return redirect()->to(site_url('inventory/movements'))
|
return redirect()->to(site_url('inventory/movements'))
|
||||||
->with('status', 'error')->with('message', 'Movement not found.');
|
->with('status', 'error')->with('message', 'Movement not found.');
|
||||||
}
|
}
|
||||||
|
if ($this->isProtectedMovement($existing)) {
|
||||||
|
return redirect()->to(site_url('inventory/movements'))
|
||||||
|
->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||||
|
}
|
||||||
|
|
||||||
$movementType = (string) $this->request->getPost('movement_type');
|
$movementType = (string) $this->request->getPost('movement_type');
|
||||||
|
if ($movementType === 'distribution') {
|
||||||
|
return redirect()->back()->withInput()->with('status', 'error')
|
||||||
|
->with('message', 'Student book distributions must be created from the book distribution workflow.');
|
||||||
|
}
|
||||||
$qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change'));
|
$qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change'));
|
||||||
|
|
||||||
$itemId = (int) ($existing['item_id'] ?? 0);
|
$itemId = (int) ($existing['item_id'] ?? 0);
|
||||||
@@ -1464,10 +1827,13 @@ class InventoryController extends BaseController
|
|||||||
{
|
{
|
||||||
// Optional: authorization checks here
|
// Optional: authorization checks here
|
||||||
log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)(session('user_id') ?? 0)]);
|
log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)(session('user_id') ?? 0)]);
|
||||||
$movement = $this->db->table('inventory_movements')->select('id, item_id')->where('id', $id)->get()->getRowArray();
|
$movement = $this->db->table('inventory_movements')->select('*')->where('id', $id)->get()->getRowArray();
|
||||||
if (!$movement) {
|
if (!$movement) {
|
||||||
return redirect()->back()->with('status', 'error')->with('message', 'Movement not found.');
|
return redirect()->back()->with('status', 'error')->with('message', 'Movement not found.');
|
||||||
}
|
}
|
||||||
|
if ($this->isProtectedMovement($movement)) {
|
||||||
|
return redirect()->back()->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||||
|
}
|
||||||
|
|
||||||
$ok = $this->db->table('inventory_movements')->where('id', $id)->delete();
|
$ok = $this->db->table('inventory_movements')->where('id', $id)->delete();
|
||||||
log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]);
|
log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]);
|
||||||
@@ -1537,6 +1903,16 @@ class InventoryController extends BaseController
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isProtectedMovement(array $movement): bool
|
||||||
|
{
|
||||||
|
$type = (string) ($movement['movement_type'] ?? '');
|
||||||
|
$sourceType = (string) ($movement['source_type'] ?? '');
|
||||||
|
return $type === 'distribution'
|
||||||
|
|| in_array($sourceType, ['student_book_issue', 'student_book_issue_reversal'], true)
|
||||||
|
|| (int) ($movement['source_id'] ?? 0) > 0
|
||||||
|
|| (int) ($movement['reversal_of_movement_id'] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Hydrate display names for a row (used in edit) */
|
/** Hydrate display names for a row (used in edit) */
|
||||||
private function hydrateNames(array $m): array
|
private function hydrateNames(array $m): array
|
||||||
{
|
{
|
||||||
@@ -1642,13 +2018,18 @@ class InventoryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)(session('user_id') ?? 0)]);
|
log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)(session('user_id') ?? 0)]);
|
||||||
$itemIds = $this->db->table('inventory_movements')
|
$movements = $this->db->table('inventory_movements')
|
||||||
->select('item_id')
|
->select('*')
|
||||||
->whereIn('id', $ids)
|
->whereIn('id', $ids)
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
|
foreach ($movements as $movement) {
|
||||||
|
if ($this->isProtectedMovement($movement)) {
|
||||||
|
return redirect()->back()->with('status','error')->with('message','Bulk delete contains issue-backed or distribution movement #' . (int) $movement['id'] . '. Use correction reversal instead.');
|
||||||
|
}
|
||||||
|
}
|
||||||
$itemIds = array_values(array_unique(array_filter(array_map(
|
$itemIds = array_values(array_unique(array_filter(array_map(
|
||||||
static fn($r) => (int) ($r['item_id'] ?? 0),
|
static fn($r) => (int) ($r['item_id'] ?? 0),
|
||||||
$itemIds
|
$movements
|
||||||
))));
|
))));
|
||||||
|
|
||||||
$ok = $this->db->table('inventory_movements')->whereIn('id', $ids)->delete();
|
$ok = $this->db->table('inventory_movements')->whereIn('id', $ids)->delete();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,534 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ApplicationModel;
|
||||||
|
use App\Models\JobPositionModel;
|
||||||
|
use App\Models\JobTemplateModel;
|
||||||
|
use App\Models\JobTemplateVersionModel;
|
||||||
|
use App\Services\EmailService;
|
||||||
|
use App\Services\PhoneFormatterService;
|
||||||
|
|
||||||
|
class JobPostingController extends BaseController
|
||||||
|
{
|
||||||
|
private const ADMIN_FILTER_ROUTE = 'administrator/job-postings';
|
||||||
|
private array $positionStatuses = ['draft', 'open', 'closed', 'filled'];
|
||||||
|
private array $applicationStatuses = ['new', 'reviewed', 'contacted', 'rejected', 'hired'];
|
||||||
|
|
||||||
|
protected JobTemplateModel $templates;
|
||||||
|
protected JobTemplateVersionModel $templateVersions;
|
||||||
|
protected JobPositionModel $positions;
|
||||||
|
protected ApplicationModel $applications;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
helper(['form', 'url', 'text']);
|
||||||
|
$this->templates = new JobTemplateModel();
|
||||||
|
$this->templateVersions = new JobTemplateVersionModel();
|
||||||
|
$this->positions = new JobPositionModel();
|
||||||
|
$this->applications = new ApplicationModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publicIndex()
|
||||||
|
{
|
||||||
|
return view('careers', [
|
||||||
|
'positions' => $this->positions->openPositions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/show', ['position' => $position]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trackDetailsClick(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->positions->recordDetailsClick($positionId);
|
||||||
|
|
||||||
|
return redirect()->to(site_url('careers/' . rawurlencode($positionId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/apply', ['position' => $position]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitApplication(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'first_name' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||||
|
'last_name' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||||
|
'email' => 'required|valid_email|max_length[50]',
|
||||||
|
'phone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
||||||
|
'resume' => 'uploaded[resume]|max_size[resume,5120]|ext_in[resume,pdf,doc,docx]',
|
||||||
|
];
|
||||||
|
$messages = [
|
||||||
|
'first_name' => [
|
||||||
|
'regex_match' => 'First name may only contain letters, spaces, and dashes.',
|
||||||
|
'min_length' => 'First name must be at least 2 characters.',
|
||||||
|
'max_length' => 'First name must be 30 characters or fewer.',
|
||||||
|
],
|
||||||
|
'last_name' => [
|
||||||
|
'regex_match' => 'Last name may only contain letters, spaces, and dashes.',
|
||||||
|
'min_length' => 'Last name must be at least 2 characters.',
|
||||||
|
'max_length' => 'Last name must be 30 characters or fewer.',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'valid_email' => 'Please enter a valid email address.',
|
||||||
|
'max_length' => 'Email must be 50 characters or fewer.',
|
||||||
|
],
|
||||||
|
'phone' => [
|
||||||
|
'regex_match' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
'min_length' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
'max_length' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validate($rules, $messages)) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$formattedPhone = (new PhoneFormatterService())->formatPhoneNumber((string) $this->request->getPost('phone'));
|
||||||
|
if ($formattedPhone === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', [
|
||||||
|
'phone' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->request->getFile('resume');
|
||||||
|
$uploadDir = WRITEPATH . 'uploads/job_applications';
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newName = $file->getRandomName();
|
||||||
|
$file->move($uploadDir, $newName);
|
||||||
|
$relativePath = 'job_applications/' . $newName;
|
||||||
|
|
||||||
|
$application = [
|
||||||
|
'application_id' => $this->newUuid(),
|
||||||
|
'position_id' => $position['position_id'],
|
||||||
|
'first_name' => trim((string) $this->request->getPost('first_name')),
|
||||||
|
'last_name' => trim((string) $this->request->getPost('last_name')),
|
||||||
|
'email' => trim((string) $this->request->getPost('email')),
|
||||||
|
'phone' => $formattedPhone,
|
||||||
|
'resume_file_url' => $relativePath,
|
||||||
|
'status' => 'new',
|
||||||
|
'submitted_at' => utc_now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->applications->insert($application)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to submit application.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendApplicationEmails($application, $position);
|
||||||
|
|
||||||
|
return redirect()->to(site_url('careers/application-received'))->with('success', 'Application submitted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applicationReceived()
|
||||||
|
{
|
||||||
|
return view('jobs/received');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates()
|
||||||
|
{
|
||||||
|
return view('jobs/admin/templates', [
|
||||||
|
'templates' => $this->templates->orderBy('updated_at', 'DESC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newTemplate()
|
||||||
|
{
|
||||||
|
return view('jobs/admin/template_form', ['template' => null, 'versions' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createTemplate()
|
||||||
|
{
|
||||||
|
$payload = $this->templatePayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['template_id'] = $this->newUuid();
|
||||||
|
$payload['version'] = 1;
|
||||||
|
$payload['is_active'] = 1;
|
||||||
|
$payload['created_by'] = $this->currentUserId();
|
||||||
|
|
||||||
|
if (!$this->templates->insert($payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to create template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->snapshotTemplate($payload);
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editTemplate(string $templateId)
|
||||||
|
{
|
||||||
|
$template = $this->templates->find($templateId);
|
||||||
|
if (!$template) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('error', 'Template not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/template_form', [
|
||||||
|
'template' => $template,
|
||||||
|
'versions' => $this->templateVersions->where('template_id', $templateId)->orderBy('version', 'DESC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTemplate(string $templateId)
|
||||||
|
{
|
||||||
|
$template = $this->templates->find($templateId);
|
||||||
|
if (!$template) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('error', 'Template not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->templatePayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->request->getPost('save_mode') === 'new_version') {
|
||||||
|
$payload['version'] = ((int) ($template['version'] ?? 1)) + 1;
|
||||||
|
} else {
|
||||||
|
$payload['version'] = (int) ($template['version'] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->templates->update($templateId, $payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to update template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['template_id'] = $templateId;
|
||||||
|
$this->snapshotTemplate($payload);
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archiveTemplate(string $templateId)
|
||||||
|
{
|
||||||
|
$this->templates->update($templateId, ['is_active' => 0]);
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template archived.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreTemplateVersion(string $versionId)
|
||||||
|
{
|
||||||
|
$version = $this->templateVersions->find($versionId);
|
||||||
|
if (!$version) {
|
||||||
|
return redirect()->back()->with('error', 'Template version not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->templateVersionPayload($version);
|
||||||
|
$payload['version'] = (int) $version['version'];
|
||||||
|
$this->templates->update($version['template_id'], $payload);
|
||||||
|
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates/' . $version['template_id'] . '/edit'))->with('success', 'Template version restored.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function positions()
|
||||||
|
{
|
||||||
|
return view('jobs/admin/positions', [
|
||||||
|
'positions' => $this->positions->adminPositions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newPosition()
|
||||||
|
{
|
||||||
|
$template = null;
|
||||||
|
$templateId = (string) $this->request->getGet('template_id');
|
||||||
|
if ($templateId !== '') {
|
||||||
|
$template = $this->templates->find($templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/position_form', [
|
||||||
|
'position' => $template ? $this->templateVersionPayload($template) + ['template_id' => $templateId, 'status' => 'draft'] : null,
|
||||||
|
'statuses' => $this->positionStatuses,
|
||||||
|
'templates' => $this->templates->where('is_active', 1)->orderBy('title', 'ASC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPosition()
|
||||||
|
{
|
||||||
|
$payload = $this->positionPayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['position_id'] = $this->newUuid();
|
||||||
|
$payload['posted_by'] = $this->currentUserId();
|
||||||
|
if ($payload['status'] === 'open') {
|
||||||
|
$payload['posted_at'] = utc_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->positions->insert($payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to create position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('success', 'Position created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editPosition(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->find($positionId);
|
||||||
|
if (!$position) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('error', 'Position not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/position_form', [
|
||||||
|
'position' => $position,
|
||||||
|
'statuses' => $this->positionStatuses,
|
||||||
|
'templates' => $this->templates->where('is_active', 1)->orderBy('title', 'ASC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePosition(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->find($positionId);
|
||||||
|
if (!$position) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('error', 'Position not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->positionPayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload['status'] === 'open' && (($position['status'] ?? '') !== 'open' || empty($position['posted_at']))) {
|
||||||
|
$payload['posted_at'] = utc_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->positions->update($positionId, $payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to update position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('success', 'Position updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applications()
|
||||||
|
{
|
||||||
|
$builder = $this->applications
|
||||||
|
->select('applications.*, job_positions.title AS position_title, job_positions.department')
|
||||||
|
->join('job_positions', 'job_positions.position_id = applications.position_id', 'left');
|
||||||
|
|
||||||
|
$status = (string) $this->request->getGet('status');
|
||||||
|
if (in_array($status, $this->applicationStatuses, true)) {
|
||||||
|
$builder->where('applications.status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$positionId = (string) $this->request->getGet('position_id');
|
||||||
|
if ($positionId !== '') {
|
||||||
|
$builder->where('applications.position_id', $positionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/applications', [
|
||||||
|
'applications' => $builder->orderBy('submitted_at', 'DESC')->findAll(),
|
||||||
|
'positions' => $this->positions->orderBy('title', 'ASC')->findAll(),
|
||||||
|
'statuses' => $this->applicationStatuses,
|
||||||
|
'selectedStatus' => $status,
|
||||||
|
'selectedPosition' => $positionId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateApplication(string $applicationId)
|
||||||
|
{
|
||||||
|
$status = (string) $this->request->getPost('status');
|
||||||
|
if (!in_array($status, $this->applicationStatuses, true)) {
|
||||||
|
return redirect()->back()->with('error', 'Invalid application status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->applications->update($applicationId, [
|
||||||
|
'status' => $status,
|
||||||
|
'admin_notes' => (string) $this->request->getPost('admin_notes'),
|
||||||
|
])) {
|
||||||
|
return redirect()->back()->with('error', 'Unable to update application.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = $this->applications
|
||||||
|
->select('applications.*, job_positions.title AS position_title')
|
||||||
|
->join('job_positions', 'job_positions.position_id = applications.position_id', 'left')
|
||||||
|
->find($applicationId);
|
||||||
|
|
||||||
|
if ($application) {
|
||||||
|
$this->sendApplicationStatusEmail($application);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Application updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(string $applicationId)
|
||||||
|
{
|
||||||
|
$application = $this->applications->find($applicationId);
|
||||||
|
if (!$application) {
|
||||||
|
return redirect()->back()->with('error', 'Application not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = WRITEPATH . 'uploads/' . ltrim((string) $application['resume_file_url'], '/');
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return redirect()->back()->with('error', 'Resume file not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->download($path, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templatePayload(): ?array
|
||||||
|
{
|
||||||
|
if (!$this->validate(['title' => 'required|max_length[255]'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sharedPostingPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positionPayload(): ?array
|
||||||
|
{
|
||||||
|
if (!$this->validate([
|
||||||
|
'title' => 'required|max_length[255]',
|
||||||
|
'status' => 'required|in_list[draft,open,closed,filled]',
|
||||||
|
])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->sharedPostingPayload();
|
||||||
|
$payload['template_id'] = $this->request->getPost('template_id') ?: null;
|
||||||
|
$payload['status'] = (string) $this->request->getPost('status');
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sharedPostingPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => trim((string) $this->request->getPost('title')),
|
||||||
|
'description' => trim((string) $this->request->getPost('description')),
|
||||||
|
'department' => trim((string) $this->request->getPost('department')),
|
||||||
|
'location' => trim((string) $this->request->getPost('location')),
|
||||||
|
'employment_type' => trim((string) $this->request->getPost('employment_type')),
|
||||||
|
'responsibilities' => trim((string) $this->request->getPost('responsibilities')),
|
||||||
|
'requirements' => trim((string) $this->request->getPost('requirements')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshotTemplate(array $template): void
|
||||||
|
{
|
||||||
|
$this->templateVersions
|
||||||
|
->where('template_id', $template['template_id'])
|
||||||
|
->where('version', (int) ($template['version'] ?? 1))
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$snapshot = $this->templateVersionPayload($template);
|
||||||
|
$snapshot['version_id'] = $this->newUuid();
|
||||||
|
$snapshot['template_id'] = $template['template_id'];
|
||||||
|
$snapshot['version'] = (int) ($template['version'] ?? 1);
|
||||||
|
$snapshot['saved_by'] = $this->currentUserId();
|
||||||
|
$snapshot['saved_at'] = utc_now();
|
||||||
|
|
||||||
|
$this->templateVersions->insert($snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templateVersionPayload(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => (string) ($data['title'] ?? ''),
|
||||||
|
'description' => (string) ($data['description'] ?? ''),
|
||||||
|
'department' => (string) ($data['department'] ?? ''),
|
||||||
|
'location' => (string) ($data['location'] ?? ''),
|
||||||
|
'employment_type' => (string) ($data['employment_type'] ?? ''),
|
||||||
|
'responsibilities' => (string) ($data['responsibilities'] ?? ''),
|
||||||
|
'requirements' => (string) ($data['requirements'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendApplicationEmails(array $application, array $position): void
|
||||||
|
{
|
||||||
|
$fullName = trim($application['first_name'] . ' ' . $application['last_name']);
|
||||||
|
$body = view('emails/job_application_confirmation', [
|
||||||
|
'name' => $fullName,
|
||||||
|
'position' => $position,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new EmailService())->send($application['email'], 'Application received: ' . $position['title'], $body, 'general');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Job application confirmation email failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminRecipients = array_unique(array_filter([
|
||||||
|
trim((string) env('JOBS_ADMIN_EMAIL', '')),
|
||||||
|
trim((string) env('PRINCIPAL_EMAIL', '')),
|
||||||
|
], static fn (string $email): bool => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)));
|
||||||
|
|
||||||
|
if ($adminRecipients !== []) {
|
||||||
|
$adminBody = '<p>New application received for <strong>' . esc($position['title']) . '</strong>.</p>'
|
||||||
|
. '<p>Applicant: ' . esc($fullName) . '<br>Email: ' . esc($application['email']) . '<br>Phone: ' . esc($application['phone']) . '</p>';
|
||||||
|
|
||||||
|
$emailService = new EmailService();
|
||||||
|
try {
|
||||||
|
foreach ($adminRecipients as $adminEmail) {
|
||||||
|
$emailService->send($adminEmail, 'New job application: ' . $position['title'], $adminBody, 'general');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Job application admin email failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendApplicationStatusEmail(array $application): void
|
||||||
|
{
|
||||||
|
$email = trim((string) ($application['email'] ?? ''));
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($application['status'] ?? '');
|
||||||
|
$statusLabel = ucfirst(str_replace('_', ' ', $status));
|
||||||
|
$positionTitle = (string) ($application['position_title'] ?? 'the volunteer position');
|
||||||
|
$fullName = trim((string) (($application['first_name'] ?? '') . ' ' . ($application['last_name'] ?? '')));
|
||||||
|
|
||||||
|
$body = view('emails/job_application_status_update', [
|
||||||
|
'name' => $fullName !== '' ? $fullName : 'Applicant',
|
||||||
|
'positionTitle' => $positionTitle,
|
||||||
|
'statusLabel' => $statusLabel,
|
||||||
|
'adminNotes' => trim((string) ($application['admin_notes'] ?? '')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new EmailService())->send($email, 'Application status update: ' . $positionTitle, $body, 'general');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Job application status email failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentUserId(): ?int
|
||||||
|
{
|
||||||
|
$userId = (int) session()->get('user_id');
|
||||||
|
return $userId > 0 ? $userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newUuid(): string
|
||||||
|
{
|
||||||
|
$bytes = random_bytes(16);
|
||||||
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||||
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||||
|
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ use App\Models\ScoreCommentModel;
|
|||||||
use App\Models\AttendanceRecordModel;
|
use App\Models\AttendanceRecordModel;
|
||||||
use App\Models\AttendanceDayModel;
|
use App\Models\AttendanceDayModel;
|
||||||
use App\Models\CalendarModel;
|
use App\Models\CalendarModel;
|
||||||
|
use App\Models\JobPositionModel;
|
||||||
|
use App\Models\PreferencesModel;
|
||||||
use \Config\Database;
|
use \Config\Database;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
@@ -56,7 +58,8 @@ class LandingPageController extends BaseController
|
|||||||
|
|
||||||
// Fetch Enrollment and Refund Deadlines from Configuration
|
// Fetch Enrollment and Refund Deadlines from Configuration
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
|
$this->schoolYear = $this->selectedSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set';
|
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set';
|
||||||
$this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set';
|
$this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set';
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@ class LandingPageController extends BaseController
|
|||||||
// Get all class assignments for this teacher in the current term
|
// Get all class assignments for this teacher in the current term
|
||||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||||
(int)$user_id,
|
(int)$user_id,
|
||||||
(string)$this->schoolYear,
|
$this->selectedSchoolYearName((string) ($this->schoolYear ?? '')),
|
||||||
(string)$this->semester
|
(string)$this->semester
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,6 +129,15 @@ class LandingPageController extends BaseController
|
|||||||
return $chosen;
|
return $chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function selectedSchoolYearName(string $fallback): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function administrator()
|
public function administrator()
|
||||||
{
|
{
|
||||||
@@ -764,39 +776,6 @@ class LandingPageController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Fetch Notifications (only active, non-expired, non-deleted)
|
|
||||||
$notifications = $this->db->table('notifications')
|
|
||||||
->select([
|
|
||||||
'notifications.id',
|
|
||||||
'notifications.title',
|
|
||||||
'notifications.message',
|
|
||||||
'notifications.target_group',
|
|
||||||
'notifications.created_at',
|
|
||||||
'notifications.expires_at',
|
|
||||||
'user_notifications.user_id',
|
|
||||||
"CASE
|
|
||||||
WHEN user_notifications.user_id IS NOT NULL THEN 'personal'
|
|
||||||
ELSE 'broadcast'
|
|
||||||
END as notification_type"
|
|
||||||
])
|
|
||||||
->join(
|
|
||||||
'user_notifications',
|
|
||||||
'user_notifications.notification_id = notifications.id AND user_notifications.user_id = ' . (int) $parentId,
|
|
||||||
'left'
|
|
||||||
)
|
|
||||||
->groupStart()
|
|
||||||
->where('notifications.target_group', 'parent')
|
|
||||||
->orWhere('user_notifications.user_id', $parentId)
|
|
||||||
->groupEnd()
|
|
||||||
->where('notifications.deleted_at IS NULL') // Exclude soft-deleted notifications
|
|
||||||
->groupStart()
|
|
||||||
->where('notifications.expires_at IS NULL')
|
|
||||||
->orWhere('notifications.expires_at > NOW()') // Exclude expired
|
|
||||||
->groupEnd()
|
|
||||||
->orderBy('notifications.created_at', 'DESC')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
// Fetch Student Information (no filtering needed by school year or semester)
|
// Fetch Student Information (no filtering needed by school year or semester)
|
||||||
|
|
||||||
$students = $this->db->table('students')
|
$students = $this->db->table('students')
|
||||||
@@ -812,6 +791,8 @@ class LandingPageController extends BaseController
|
|||||||
}
|
}
|
||||||
unset($student);
|
unset($student);
|
||||||
|
|
||||||
|
$notifications = $this->parentDashboardNotifications((int) $parentId, (int) session()->get('user_id'));
|
||||||
|
|
||||||
|
|
||||||
// Fetch Attendance Records (filtered by most recent school year and semester)
|
// Fetch Attendance Records (filtered by most recent school year and semester)
|
||||||
$attendanceData = $this->db->table('attendance_data')
|
$attendanceData = $this->db->table('attendance_data')
|
||||||
@@ -850,8 +831,24 @@ class LandingPageController extends BaseController
|
|||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|
||||||
// Fetch latest invoice balance
|
$paymentRow = $this->db->table('invoices')
|
||||||
$paymentBalance = $this->invoiceModel->getLatestInvoiceTotalAmount($parentId);
|
->select('COALESCE(SUM(balance), 0) AS account_balance')
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $this->schoolYear)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$paymentBalance = (float) ($paymentRow['account_balance'] ?? 0);
|
||||||
|
$openPositions = [];
|
||||||
|
|
||||||
|
if (! $this->parentHidesJobOpeningsPopup($parentId)) {
|
||||||
|
try {
|
||||||
|
if ($this->db->tableExists('job_positions')) {
|
||||||
|
$openPositions = (new JobPositionModel())->openPositions();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Unable to load parent dashboard job positions: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass data to the view, including the deadlines
|
// Pass data to the view, including the deadlines
|
||||||
return view('/landing_page/parent_dashboard', [
|
return view('/landing_page/parent_dashboard', [
|
||||||
@@ -863,9 +860,259 @@ class LandingPageController extends BaseController
|
|||||||
'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
|
'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
|
||||||
'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
|
'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
|
||||||
'paymentBalance' => $paymentBalance, // 🔹 New
|
'paymentBalance' => $paymentBalance, // 🔹 New
|
||||||
|
'openPositions' => $openPositions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hideJobOpeningsPopup()
|
||||||
|
{
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return $this->response->setStatusCode(401)->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Please log in to update this setting.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('user_preferences')
|
||||||
|
|| ! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')
|
||||||
|
) {
|
||||||
|
return $this->response->setStatusCode(500)->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Preference storage is not ready.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hide = (string) $this->request->getPost('hide_job_openings_popup') === '1' ? 1 : 0;
|
||||||
|
$preferencesModel = new PreferencesModel();
|
||||||
|
$existing = $preferencesModel->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$preferencesModel->update((int) $existing['id'], [
|
||||||
|
'hide_job_openings_popup' => $hide,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$preferencesModel->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'hide_job_openings_popup' => $hide,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'hide_job_openings_popup' => $hide,
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentHidesJobOpeningsPopup(int $parentId): bool
|
||||||
|
{
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('user_preferences')
|
||||||
|
|| ! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preferences = (new PreferencesModel())->where('user_id', $parentId)->first();
|
||||||
|
|
||||||
|
return ! empty($preferences['hide_job_openings_popup']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Unable to load parent job openings popup preference: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentDashboardNotifications(int $parentId, int $userId): array
|
||||||
|
{
|
||||||
|
$notifications = array_merge(
|
||||||
|
$this->parentAttendanceNotifications($parentId),
|
||||||
|
$this->activeParentBroadcastNotifications($parentId, $userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
usort($notifications, static function (array $a, array $b): int {
|
||||||
|
return strtotime((string) ($b['created_at'] ?? '')) <=> strtotime((string) ($a['created_at'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_slice($notifications, 0, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentAttendanceNotifications(int $parentId): array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$parentId <= 0
|
||||||
|
|| ! $this->db->tableExists('parent_notifications')
|
||||||
|
|| ! $this->db->tableExists('students')
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('parent_notifications pn')
|
||||||
|
->select([
|
||||||
|
'pn.id',
|
||||||
|
'pn.student_id',
|
||||||
|
'pn.code',
|
||||||
|
'pn.incident_date',
|
||||||
|
'pn.channel',
|
||||||
|
'pn.subject',
|
||||||
|
'pn.status',
|
||||||
|
'pn.response',
|
||||||
|
'pn.semester',
|
||||||
|
'pn.school_year',
|
||||||
|
'pn.created_at',
|
||||||
|
'pn.updated_at',
|
||||||
|
'students.firstname',
|
||||||
|
'students.lastname',
|
||||||
|
])
|
||||||
|
->join('students', 'students.id = pn.student_id')
|
||||||
|
->where('students.parent_id', $parentId)
|
||||||
|
->where('pn.school_year', (string) $this->schoolYear)
|
||||||
|
->where('pn.semester', (string) $this->semester)
|
||||||
|
->orderBy('COALESCE(pn.updated_at, pn.created_at)', 'DESC', false)
|
||||||
|
->orderBy('pn.id', 'DESC')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$notifications = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = implode('|', [
|
||||||
|
(string) ($row['student_id'] ?? ''),
|
||||||
|
(string) ($row['code'] ?? ''),
|
||||||
|
(string) ($row['incident_date'] ?? ''),
|
||||||
|
(string) ($row['subject'] ?? ''),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||||
|
$code = strtoupper((string) ($row['code'] ?? ''));
|
||||||
|
$incidentDate = (string) ($row['incident_date'] ?? '');
|
||||||
|
$subject = trim((string) ($row['subject'] ?? ''));
|
||||||
|
|
||||||
|
$title = $subject !== '' ? $subject : $this->parentNotificationCodeLabel($code);
|
||||||
|
if ($studentName !== '') {
|
||||||
|
$title .= ' - ' . $studentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messageParts = [];
|
||||||
|
if ($incidentDate !== '') {
|
||||||
|
$messageParts[] = 'Incident date: ' . $incidentDate;
|
||||||
|
}
|
||||||
|
if (!empty($row['status'])) {
|
||||||
|
$messageParts[] = 'Status: ' . ucfirst((string) $row['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifications[] = [
|
||||||
|
'id' => $row['id'] ?? null,
|
||||||
|
'title' => $title,
|
||||||
|
'message' => implode(' | ', $messageParts),
|
||||||
|
'created_at' => $row['updated_at'] ?: ($row['created_at'] ?? null),
|
||||||
|
'notification_type' => $this->parentNotificationCodeLabel($code),
|
||||||
|
'status' => $row['status'] ?? null,
|
||||||
|
'code' => $code,
|
||||||
|
'incident_date' => $incidentDate,
|
||||||
|
'student_name' => $studentName,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($notifications) >= 25) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeParentBroadcastNotifications(int $parentId, int $userId): array
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || ! $this->db->tableExists('notifications')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('notifications')
|
||||||
|
->select([
|
||||||
|
'notifications.id',
|
||||||
|
'notifications.title',
|
||||||
|
'notifications.message',
|
||||||
|
'notifications.target_group',
|
||||||
|
'notifications.created_at',
|
||||||
|
'notifications.expires_at',
|
||||||
|
"CASE
|
||||||
|
WHEN user_notifications.user_id IS NOT NULL THEN 'personal'
|
||||||
|
ELSE 'broadcast'
|
||||||
|
END as notification_type",
|
||||||
|
])
|
||||||
|
->join(
|
||||||
|
'user_notifications',
|
||||||
|
'user_notifications.notification_id = notifications.id AND user_notifications.user_id IN (' . implode(',', array_unique([$parentId, $userId])) . ')',
|
||||||
|
'left'
|
||||||
|
)
|
||||||
|
->groupStart()
|
||||||
|
->whereIn('notifications.target_group', ['parent', 'everyone'])
|
||||||
|
->orWhere('user_notifications.user_id IS NOT NULL')
|
||||||
|
->groupEnd()
|
||||||
|
->where('notifications.deleted_at IS NULL')
|
||||||
|
->groupStart()
|
||||||
|
->where('notifications.scheduled_at IS NULL')
|
||||||
|
->orWhere('notifications.scheduled_at <=', utc_now())
|
||||||
|
->groupEnd()
|
||||||
|
->groupStart()
|
||||||
|
->where('notifications.expires_at IS NULL')
|
||||||
|
->orWhere('notifications.expires_at >', utc_now())
|
||||||
|
->groupEnd();
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('school_year', 'notifications')) {
|
||||||
|
$builder->where('notifications.school_year', (string) $this->schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('semester', 'notifications')) {
|
||||||
|
$builder->groupStart()
|
||||||
|
->where('notifications.semester', null)
|
||||||
|
->orWhere('notifications.semester', '')
|
||||||
|
->orWhere('notifications.semester', (string) $this->semester)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder
|
||||||
|
->groupBy('notifications.id')
|
||||||
|
->orderBy('notifications.created_at', 'DESC')
|
||||||
|
->limit(25)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentNotificationCodeLabel(string $code): string
|
||||||
|
{
|
||||||
|
return match ($code) {
|
||||||
|
'ABS_1' => 'Unreported absence',
|
||||||
|
'ABS_2' => 'Repeated absences',
|
||||||
|
'ABS_3' => 'Attendance warning',
|
||||||
|
'ABS_4' => 'Attendance review',
|
||||||
|
'LATE_2' => 'Repeated lateness',
|
||||||
|
'LATE_3' => 'Lateness warning',
|
||||||
|
'LATE_4' => 'Lateness review',
|
||||||
|
'MIX_L2A1' => 'Attendance warning',
|
||||||
|
default => 'Parent notice',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function guest()
|
public function guest()
|
||||||
{
|
{
|
||||||
return view('/landing_page/guest_dashboard');
|
return view('/landing_page/guest_dashboard');
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ class LateSlipLogsController extends BaseController
|
|||||||
{
|
{
|
||||||
$req = $this->request;
|
$req = $this->request;
|
||||||
|
|
||||||
$defaultYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$defaultYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
|
$defaultSem = (string) (getSemester() ?? '');
|
||||||
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
|
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
|
||||||
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
|
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
|
||||||
$q = trim((string) ($req->getGet('q') ?? ''));
|
$q = trim((string) ($req->getGet('q') ?? ''));
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ class MessagesController extends BaseController
|
|||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
//$role = session()->get('role');
|
//$role = session()->get('role');
|
||||||
$userId = session()->get('user_id');
|
$userId = session()->get('user_id');
|
||||||
|
if (empty($userId)) {
|
||||||
|
return redirect()->to('/login');
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the user role from the user_roles and roles tables
|
// Fetch the user role from the user_roles and roles tables
|
||||||
$role = $userRoleModel->select('roles.name')
|
$role = $userRoleModel->select('roles.name')
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
@@ -103,6 +107,11 @@ class MessagesController extends BaseController
|
|||||||
$attachmentPath = null;
|
$attachmentPath = null;
|
||||||
$file = $this->request->getFile('attachment');
|
$file = $this->request->getFile('attachment');
|
||||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||||
|
$allowedExt = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx'];
|
||||||
|
$ext = strtolower((string) $file->getExtension());
|
||||||
|
if (! in_array($ext, $allowedExt, true) || $file->getSize() > 5 * 1024 * 1024) {
|
||||||
|
return redirect()->back()->with('error', 'Attachment must be a document or image under 5MB.');
|
||||||
|
}
|
||||||
$attachmentPath = $file->store();
|
$attachmentPath = $file->store();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class MidtermController extends BaseController
|
|||||||
$this->teacherClassModel = new TeacherClassModel();
|
$this->teacherClassModel = new TeacherClassModel();
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
$this->semesterScoreService = service('semesterScoreService');
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
$this->gradingLockModel = new GradingLockModel();
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ class MidtermController extends BaseController
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
$studentTeacherInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||||
// Call the updateScoresForStudents method
|
// Call the updateScoresForStudents method
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ class MidtermController extends BaseController
|
|||||||
|
|
||||||
session()->setFlashdata('status', 'Midterm exam scores updated successfully.');
|
session()->setFlashdata('status', 'Midterm exam scores updated successfully.');
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
$studentTeacherInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||||
// Call the updateScoresForStudents method
|
// Call the updateScoresForStudents method
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -288,6 +288,7 @@ class MidtermController extends BaseController
|
|||||||
// Join the "latest midterm per student" subquery, then the actual midterm row
|
// Join the "latest midterm per student" subquery, then the actual midterm row
|
||||||
->join('(' . $latestMidtermSub . ') ml', 'ml.student_id = s.id', 'left', false)
|
->join('(' . $latestMidtermSub . ') ml', 'ml.student_id = s.id', 'left', false)
|
||||||
->join('midterm_exam me', 'me.id = ml.max_id', 'left')
|
->join('midterm_exam me', 'me.id = ml.max_id', 'left')
|
||||||
|
->where('s.is_active', 1)
|
||||||
->orderBy('s.lastname', 'ASC')
|
->orderBy('s.lastname', 'ASC')
|
||||||
->orderBy('s.firstname', 'ASC');
|
->orderBy('s.firstname', 'ASC');
|
||||||
|
|
||||||
|
|||||||
@@ -22,33 +22,14 @@ class NavBuilderController extends BaseController
|
|||||||
|
|
||||||
protected function ensureAdmin(): void
|
protected function ensureAdmin(): void
|
||||||
{
|
{
|
||||||
$sessionRole = session()->get('role'); // could be a string or array in your app
|
$session = session();
|
||||||
$roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
|
$roles = array_filter(array_merge(
|
||||||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
(array) $session->get('roles'),
|
||||||
|
(array) $session->get('role')
|
||||||
|
));
|
||||||
|
|
||||||
if (empty($roleNames)) {
|
$normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles);
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
if (!in_array('administrator', $normalizedRoles, true)) {
|
||||||
}
|
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
|
|
||||||
// Map role names -> ids
|
|
||||||
$roleIdRows = $db->table('roles')->select('id')->whereIn('name', $roleNames)->get()->getResultArray();
|
|
||||||
$roleIds = array_map('intval', array_column($roleIdRows, 'id'));
|
|
||||||
if (empty($roleIds)) {
|
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this route allowed for any of the user's roles?
|
|
||||||
$allowed = $db->table('role_nav_items AS rni')
|
|
||||||
->select('1')
|
|
||||||
->join('nav_items AS ni', 'ni.id = rni.nav_item_id')
|
|
||||||
->where('ni.url', 'nav-builder') // IMPORTANT: your current route path
|
|
||||||
->whereIn('rni.role_id', $roleIds)
|
|
||||||
->get(1)->getFirstRow();
|
|
||||||
|
|
||||||
if (!$allowed) {
|
|
||||||
// You can show a nicer "Access Denied" view if you prefer
|
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +87,15 @@ public function save()
|
|||||||
if ($menuParentId !== null) {
|
if ($menuParentId !== null) {
|
||||||
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
||||||
if (!$parent) {
|
if (!$parent) {
|
||||||
|
if ($this->wantsJson()) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Selected parent does not exist.',
|
||||||
|
'csrf' => $this->csrfPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
|
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +129,33 @@ public function save()
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->service->clearCache();
|
$this->service->clearCache();
|
||||||
|
if ($this->wantsJson()) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Menu saved.',
|
||||||
|
'id' => $id,
|
||||||
|
'csrf' => $this->csrfPayload(),
|
||||||
|
'payload' => $this->buildNavPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Menu saved.');
|
return redirect()->back()->with('success', 'Menu saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function wantsJson(): bool
|
||||||
|
{
|
||||||
|
return $this->request->isAJAX()
|
||||||
|
|| str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function csrfPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => csrf_token(),
|
||||||
|
'hash' => csrf_hash(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function delete($id)
|
public function delete($id)
|
||||||
{
|
{
|
||||||
@@ -158,12 +172,182 @@ public function save()
|
|||||||
{
|
{
|
||||||
$this->ensureAdmin();
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$structure = $this->request->getPost('structure') ?? [];
|
||||||
|
if (is_array($structure) && !empty($structure)) {
|
||||||
|
$updates = $this->normalizeStructureUpdates($structure);
|
||||||
|
foreach ($updates as $row) {
|
||||||
|
$this->items->update($row['id'], [
|
||||||
|
'menu_parent_id' => $row['menu_parent_id'],
|
||||||
|
'sort_order' => $row['sort_order'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->clearCache();
|
||||||
|
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible payload used by the previous builder.
|
||||||
$orders = $this->request->getPost('orders') ?? [];
|
$orders = $this->request->getPost('orders') ?? [];
|
||||||
foreach ($orders as $id => $order) {
|
foreach ($orders as $id => $order) {
|
||||||
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->service->clearCache();
|
$this->service->clearCache();
|
||||||
return $this->response->setJSON(['ok' => true]);
|
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function roleAccess()
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$action = strtolower(trim((string) $this->request->getPost('action')));
|
||||||
|
$navItemId = (int) $this->request->getPost('nav_item_id');
|
||||||
|
$sourceRoleId = (int) $this->request->getPost('source_role_id');
|
||||||
|
$targetRoleId = (int) $this->request->getPost('target_role_id');
|
||||||
|
|
||||||
|
if ($navItemId <= 0 || !$this->items->select('id')->find($navItemId)) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Selected page does not exist.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'remove') {
|
||||||
|
if (!$this->roleExists($sourceRoleId)) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maps
|
||||||
|
->where('nav_item_id', $navItemId)
|
||||||
|
->where('role_id', $sourceRoleId)
|
||||||
|
->delete();
|
||||||
|
} elseif ($action === 'move') {
|
||||||
|
if (!$this->roleExists($sourceRoleId) || !$this->roleExists($targetRoleId)) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceRoleId !== $targetRoleId) {
|
||||||
|
$this->maps
|
||||||
|
->where('nav_item_id', $navItemId)
|
||||||
|
->where('role_id', $sourceRoleId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$exists = $this->maps
|
||||||
|
->where('nav_item_id', $navItemId)
|
||||||
|
->where('role_id', $targetRoleId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$exists) {
|
||||||
|
$this->maps->insert([
|
||||||
|
'role_id' => $targetRoleId,
|
||||||
|
'nav_item_id' => $navItemId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Unsupported role access action.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->clearCache();
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Role access updated.',
|
||||||
|
'csrf' => $this->csrfPayload(),
|
||||||
|
'payload' => $this->buildNavPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function roleExists(int $roleId): bool
|
||||||
|
{
|
||||||
|
if ($roleId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
return (bool) $db->table('roles')
|
||||||
|
->select('id')
|
||||||
|
->where('id', $roleId)
|
||||||
|
->get(1)
|
||||||
|
->getFirstRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeStructureUpdates(array $structure): array
|
||||||
|
{
|
||||||
|
$existingRows = $this->items->select('id, menu_parent_id')->findAll();
|
||||||
|
$existingIds = array_map('intval', array_column($existingRows, 'id'));
|
||||||
|
$existingIdLookup = array_fill_keys($existingIds, true);
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
foreach ($structure as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
if ($id <= 0 || !isset($existingIdLookup[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentId = $row['parent_id'] ?? null;
|
||||||
|
$parentId = ($parentId === '' || $parentId === null) ? null : (int) $parentId;
|
||||||
|
if ($parentId !== null && (!isset($existingIdLookup[$parentId]) || $parentId === $id)) {
|
||||||
|
$parentId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'menu_parent_id' => $parentId,
|
||||||
|
'sort_order' => max(0, (int) ($row['sort_order'] ?? 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatesById = [];
|
||||||
|
foreach ($updates as $row) {
|
||||||
|
$updatesById[$row['id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($updatesById as $id => &$row) {
|
||||||
|
if ($row['menu_parent_id'] !== null && $this->wouldCreateCycle($id, $row['menu_parent_id'], $updatesById, $existingRows)) {
|
||||||
|
$row['menu_parent_id'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return array_values($updatesById);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function wouldCreateCycle(int $id, int $parentId, array $updatesById, array $existingRows): bool
|
||||||
|
{
|
||||||
|
$parentById = [];
|
||||||
|
foreach ($existingRows as $row) {
|
||||||
|
$parentById[(int) ($row['id'] ?? 0)] = isset($row['menu_parent_id']) && (int) $row['menu_parent_id'] !== 0
|
||||||
|
? (int) $row['menu_parent_id']
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
foreach ($updatesById as $row) {
|
||||||
|
$parentById[$row['id']] = $row['menu_parent_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
$current = $parentId;
|
||||||
|
while ($current !== null) {
|
||||||
|
if ($current === $id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isset($seen[$current])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$seen[$current] = true;
|
||||||
|
$current = $parentById[$current] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function distinctRoles(): array
|
protected function distinctRoles(): array
|
||||||
@@ -256,9 +440,59 @@ public function save()
|
|||||||
'items' => $flattened,
|
'items' => $flattened,
|
||||||
'roles' => $roles,
|
'roles' => $roles,
|
||||||
'parentOptions' => $parentOptions,
|
'parentOptions' => $parentOptions,
|
||||||
|
'routeOptions' => $this->routeOptions(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function routeOptions(): array
|
||||||
|
{
|
||||||
|
$routes = service('routes');
|
||||||
|
$getRoutes = $routes->getRoutes('GET', false);
|
||||||
|
if (empty($getRoutes)) {
|
||||||
|
$routes = $routes->loadRoutes();
|
||||||
|
$getRoutes = $routes->getRoutes('GET', false);
|
||||||
|
}
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach (array_keys($getRoutes) as $route) {
|
||||||
|
$route = trim((string) $route, '/');
|
||||||
|
if ($route === '' || $this->shouldHideRouteOption($route)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options[] = [
|
||||||
|
'value' => $route,
|
||||||
|
'label' => $this->routeLabel($route),
|
||||||
|
'needs_params' => str_contains($route, '(') || str_contains($route, '[') || str_contains($route, '{'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($options, static function ($a, $b) {
|
||||||
|
return strnatcasecmp($a['label'] ?? '', $b['label'] ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldHideRouteOption(string $route): bool
|
||||||
|
{
|
||||||
|
if (str_starts_with($route, 'api/') || str_starts_with($route, 'docs/') || str_starts_with($route, 'debugbar/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) preg_match('#(^|/)(csrf-token|file|attachment|download|delete)(/|$)#i', $route);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function routeLabel(string $route): string
|
||||||
|
{
|
||||||
|
$label = preg_replace('#\(\?:[^)]+\)|\(\[\^/\]\+\)|\(\[0-9\]\+\)|\(:[a-z_]+\)|\{[^}]+\}#i', '{value}', $route) ?? $route;
|
||||||
|
$label = str_replace(['_', '-'], ' ', $label);
|
||||||
|
$label = preg_replace('#/+#', ' / ', $label) ?? $label;
|
||||||
|
$label = preg_replace('/\s+/', ' ', $label) ?? $label;
|
||||||
|
|
||||||
|
return ucwords(trim($label));
|
||||||
|
}
|
||||||
|
|
||||||
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
|
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
|
||||||
{
|
{
|
||||||
foreach ($nodes as $node) {
|
foreach ($nodes as $node) {
|
||||||
@@ -300,4 +534,28 @@ public function save()
|
|||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sortTreeByOrder(array &$nodes): void
|
||||||
|
{
|
||||||
|
usort($nodes, function ($a, $b) {
|
||||||
|
$order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
|
||||||
|
if ($order !== 0) {
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
|
||||||
|
if ($label !== 0) {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($nodes as &$node) {
|
||||||
|
if (!empty($node['children'])) {
|
||||||
|
$this->sortTreeByOrder($node['children']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ class NotificationsController extends BaseController
|
|||||||
{
|
{
|
||||||
helper('url');
|
helper('url');
|
||||||
|
|
||||||
|
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/active')) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
$targetGroup = $this->request->getGet('target_group');
|
$targetGroup = $this->request->getGet('target_group');
|
||||||
|
|
||||||
return view('notifications/list_active', [
|
return view('notifications/list_active', [
|
||||||
@@ -102,6 +106,10 @@ class NotificationsController extends BaseController
|
|||||||
{
|
{
|
||||||
helper(['url', 'form']);
|
helper(['url', 'form']);
|
||||||
|
|
||||||
|
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/deleted')) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
return view('notifications/list_deleted', [
|
return view('notifications/list_deleted', [
|
||||||
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
|
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
|
||||||
'restoreEndpoint' => site_url('notifications/restore'),
|
'restoreEndpoint' => site_url('notifications/restore'),
|
||||||
@@ -149,6 +157,11 @@ class NotificationsController extends BaseController
|
|||||||
$targetGroup = null;
|
$targetGroup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$schoolYear = $this->currentSchoolYearName();
|
||||||
|
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) {
|
||||||
|
$this->notificationModel->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
|
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
|
||||||
if (!is_array($rows)) {
|
if (!is_array($rows)) {
|
||||||
$rows = [];
|
$rows = [];
|
||||||
@@ -210,4 +223,25 @@ class NotificationsController extends BaseController
|
|||||||
'notifications' => $notifications,
|
'notifications' => $notifications,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||||
|
{
|
||||||
|
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||||
|
$query = $this->request->getGet();
|
||||||
|
$hasLegacyTermFilter = false;
|
||||||
|
|
||||||
|
foreach ($legacyTermKeys as $key) {
|
||||||
|
if (array_key_exists($key, $query)) {
|
||||||
|
unset($query[$key]);
|
||||||
|
$hasLegacyTermFilter = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasLegacyTermFilter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||||
|
return redirect()->to($target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\StudentClassModel;
|
|||||||
use App\Models\ClassSectionModel;
|
use App\Models\ClassSectionModel;
|
||||||
use App\Models\AttendanceDataModel;
|
use App\Models\AttendanceDataModel;
|
||||||
use App\Models\AttendanceRecordModel;
|
use App\Models\AttendanceRecordModel;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||||
use App\Services\SemesterRangeService;
|
use App\Services\SemesterRangeService;
|
||||||
|
|
||||||
class ParentAttendanceReportController extends BaseController
|
class ParentAttendanceReportController extends BaseController
|
||||||
@@ -58,7 +59,8 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
[$sundays, $defaultDate] = $this->computeSundays();
|
[$sundays, $defaultDate] = $this->computeSundays();
|
||||||
|
|
||||||
// Load upcoming reports for preview/edit (today and forward within this school year)
|
// Load upcoming reports for preview/edit (today and forward within this school year)
|
||||||
$schoolYear = (string) $this->configModel->getConfig('school_year');
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
$schoolYear = $schoolYearContext->yearName();
|
||||||
$todayYmd = local_date(utc_now(), 'Y-m-d');
|
$todayYmd = local_date(utc_now(), 'Y-m-d');
|
||||||
$previewRows = $this->reportModel->builder()
|
$previewRows = $this->reportModel->builder()
|
||||||
->select('parent_attendance_reports.*, s.firstname, s.lastname')
|
->select('parent_attendance_reports.*, s.firstname, s.lastname')
|
||||||
@@ -80,6 +82,8 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
'defaultDate' => $defaultDate, // preselected date (upcoming Sunday)
|
'defaultDate' => $defaultDate, // preselected date (upcoming Sunday)
|
||||||
'myReports' => $previewRows,
|
'myReports' => $previewRows,
|
||||||
'cutoffThreshold' => $cutoffThreshold,
|
'cutoffThreshold' => $cutoffThreshold,
|
||||||
|
'selectedYear' => $schoolYear,
|
||||||
|
'isEditable' => ! $schoolYearContext->isReadonly(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +95,13 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
return redirect()->to('/login');
|
return redirect()->to('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
try {
|
||||||
|
$this->assertSchoolYearWritable($schoolYearContext);
|
||||||
|
} catch (SchoolYearWriteConflictException $e) {
|
||||||
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$post = $this->request->getPost();
|
$post = $this->request->getPost();
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -141,6 +152,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
$dismissTime = $post['dismiss_time'] ?? null;
|
$dismissTime = $post['dismiss_time'] ?? null;
|
||||||
$reasonRaw = $post['reason'] ?? null;
|
$reasonRaw = $post['reason'] ?? null;
|
||||||
$reason = is_string($reasonRaw) ? trim($reasonRaw) : null;
|
$reason = is_string($reasonRaw) ? trim($reasonRaw) : null;
|
||||||
|
$schoolYear = $schoolYearContext->yearName();
|
||||||
|
|
||||||
// Enforce Sunday-only and future-or-today selection
|
// Enforce Sunday-only and future-or-today selection
|
||||||
$todayCheck = new \DateTime('today');
|
$todayCheck = new \DateTime('today');
|
||||||
@@ -157,7 +169,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
}
|
}
|
||||||
$validDates = [];
|
$validDates = [];
|
||||||
|
|
||||||
$cap = $this->firstSundayOfJune((string) $this->configModel->getConfig('school_year'));
|
$cap = $this->firstSundayOfJune($schoolYear);
|
||||||
|
|
||||||
$lateCutoff = null;
|
$lateCutoff = null;
|
||||||
$lateNow = null;
|
$lateNow = null;
|
||||||
@@ -235,8 +247,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
|
|
||||||
// Upper bound already enforced by $validDates building
|
// Upper bound already enforced by $validDates building
|
||||||
|
|
||||||
$semester = (string) $this->configModel->getConfig('semester');
|
$semester = (string) getSemester();
|
||||||
$schoolYear = (string) $this->configModel->getConfig('school_year');
|
|
||||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||||
|
|
||||||
$inserted = 0;
|
$inserted = 0;
|
||||||
@@ -431,7 +442,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Build formatted success message
|
// ✅ Build formatted success message
|
||||||
$msg = '✅ <strong>Submission received successfully for ' . count($successNames) . ' item' . (count($successNames) > 1 ? 's' : '') . '.</strong><br>';
|
$msg = '✅ <strong>Submission received successfully for ' . count($successNames) . ' request' . (count($successNames) > 1 ? 's' : '') . '.</strong><br>';
|
||||||
$msg .= '<ul style="margin-top:5px;">';
|
$msg .= '<ul style="margin-top:5px;">';
|
||||||
foreach ($successNames as $s) {
|
foreach ($successNames as $s) {
|
||||||
$dateLabel = $s['date_label'] ?? ($s['date'] ?? '');
|
$dateLabel = $s['date_label'] ?? ($s['date'] ?? '');
|
||||||
@@ -493,7 +504,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upper bound: first Sunday of June (school year end)
|
// Upper bound: first Sunday of June (school year end)
|
||||||
$schoolYear = (string) $this->configModel->getConfig('school_year');
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
$cap = $this->firstSundayOfJune($schoolYear);
|
$cap = $this->firstSundayOfJune($schoolYear);
|
||||||
|
|
||||||
$dates = [];
|
$dates = [];
|
||||||
@@ -985,7 +996,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
: (string)$this->configModel->getConfig('school_year');
|
: (string)$this->configModel->getConfig('school_year');
|
||||||
$semester = (is_string($semesterParam) && $semesterParam !== '')
|
$semester = (is_string($semesterParam) && $semesterParam !== '')
|
||||||
? (string)$semesterParam
|
? (string)$semesterParam
|
||||||
: (string)$this->configModel->getConfig('semester');
|
: (string) getSemester();
|
||||||
|
|
||||||
$rows = $this->reportModel->listForDateRange($start, $end ?: null, $schoolYear, $semester);
|
$rows = $this->reportModel->listForDateRange($start, $end ?: null, $schoolYear, $semester);
|
||||||
|
|
||||||
@@ -1295,7 +1306,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = (string)$this->configModel->getConfig('school_year');
|
$schoolYear = (string)$this->configModel->getConfig('school_year');
|
||||||
$semester = (string)$this->configModel->getConfig('semester');
|
$semester = (string) getSemester();
|
||||||
|
|
||||||
// Fetch students with their current-year class section (if any)
|
// Fetch students with their current-year class section (if any)
|
||||||
try {
|
try {
|
||||||
@@ -1351,7 +1362,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = (string)$this->configModel->getConfig('school_year');
|
$schoolYear = (string)$this->configModel->getConfig('school_year');
|
||||||
$semester = (string)$this->configModel->getConfig('semester');
|
$semester = (string) getSemester();
|
||||||
|
|
||||||
// Resolve parent_id of the student
|
// Resolve parent_id of the student
|
||||||
$stu = $this->studentModel->select('id, parent_id, firstname, lastname')->find($studentId);
|
$stu = $this->studentModel->select('id, parent_id, firstname, lastname')->find($studentId);
|
||||||
@@ -1458,12 +1469,15 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
return $this->response->setJSON(['ok' => true, 'students' => []]);
|
return $this->response->setJSON(['ok' => true, 'students' => []]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
|
||||||
// Fetch existing submissions for these students on this date
|
// Fetch existing submissions for these students on this date
|
||||||
$rows = $this->reportModel->builder()
|
$rows = $this->reportModel->builder()
|
||||||
->select('parent_attendance_reports.student_id, parent_attendance_reports.type, parent_attendance_reports.report_date, s.firstname, s.lastname')
|
->select('parent_attendance_reports.student_id, parent_attendance_reports.type, parent_attendance_reports.report_date, s.firstname, s.lastname')
|
||||||
->join('students s', 's.id = parent_attendance_reports.student_id', 'left')
|
->join('students s', 's.id = parent_attendance_reports.student_id', 'left')
|
||||||
->whereIn('parent_attendance_reports.report_date', $dateValues)
|
->whereIn('parent_attendance_reports.report_date', $dateValues)
|
||||||
->whereIn('parent_attendance_reports.student_id', $studentIds)
|
->whereIn('parent_attendance_reports.student_id', $studentIds)
|
||||||
|
->where('parent_attendance_reports.school_year', $schoolYear)
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
|
|
||||||
$map = [];
|
$map = [];
|
||||||
@@ -1505,6 +1519,13 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
return redirect()->to('/login');
|
return redirect()->to('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||||
|
try {
|
||||||
|
$this->assertSchoolYearWritable($schoolYearContext);
|
||||||
|
} catch (SchoolYearWriteConflictException $e) {
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$id = (int) ($this->request->getPost('id') ?? 0);
|
$id = (int) ($this->request->getPost('id') ?? 0);
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
return redirect()->back()->with('error', 'Invalid report.');
|
return redirect()->back()->with('error', 'Invalid report.');
|
||||||
@@ -1514,6 +1535,9 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
if (!$row || (int)($row['parent_id'] ?? 0) !== (int)$parentId) {
|
if (!$row || (int)($row['parent_id'] ?? 0) !== (int)$parentId) {
|
||||||
return redirect()->back()->with('error', 'Report not found.');
|
return redirect()->back()->with('error', 'Report not found.');
|
||||||
}
|
}
|
||||||
|
if ((string) ($row['school_year'] ?? '') !== $schoolYearContext->yearName()) {
|
||||||
|
return redirect()->back()->with('error', 'Report not found for the selected school year.');
|
||||||
|
}
|
||||||
|
|
||||||
// Only allow editing for 'new' status entries
|
// Only allow editing for 'new' status entries
|
||||||
$status = (string) ($row['status'] ?? '');
|
$status = (string) ($row['status'] ?? '');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Models\FinancialAidRequestModel;
|
||||||
|
use App\Models\StudentModel;
|
||||||
|
|
||||||
|
class ParentFinancialAidController extends BaseController
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$parentId = (int) session()->get('user_id');
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return redirect()->to('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
|
||||||
|
$model = new FinancialAidRequestModel();
|
||||||
|
$requests = $model
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
return view('parent/financial_aid', [
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'requests' => $requests,
|
||||||
|
'openRequest' => $model->openRequestForParent($parentId, $schoolYear),
|
||||||
|
'existingRequest' => $model->requestForParentYear($parentId, $schoolYear),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit()
|
||||||
|
{
|
||||||
|
$parentId = (int) session()->get('user_id');
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return redirect()->to('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
|
||||||
|
$model = new FinancialAidRequestModel();
|
||||||
|
if ($model->requestForParentYear($parentId, $schoolYear) !== null) {
|
||||||
|
return redirect()->back()->with('error', 'You can submit only one financial aid application per school year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentIds = array_map('intval', array_column(
|
||||||
|
(new StudentModel())->select('id')->where('parent_id', $parentId)->findAll(),
|
||||||
|
'id'
|
||||||
|
));
|
||||||
|
if ($studentIds === []) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'No students are linked to your account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$householdIncomeRaw = trim((string) $this->request->getPost('household_income'));
|
||||||
|
if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Enter your household income.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$householdSize = (int) $this->request->getPost('household_size');
|
||||||
|
if ($householdSize < 1) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Enter your household size.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedAmountRaw = trim((string) $this->request->getPost('requested_amount'));
|
||||||
|
if ($requestedAmountRaw === '' || ! is_numeric($requestedAmountRaw) || (float) $requestedAmountRaw <= 0) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Enter the amount you are requesting.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$needStatement = trim((string) $this->request->getPost('need_statement'));
|
||||||
|
if ($needStatement === '') {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Please describe why you are requesting financial aid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->insert([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'student_ids_json' => json_encode(array_values($studentIds)),
|
||||||
|
'household_size' => $householdSize,
|
||||||
|
'household_income' => round((float) $householdIncomeRaw, 2),
|
||||||
|
'need_statement' => $needStatement,
|
||||||
|
'requested_amount' => round((float) $requestedAmountRaw, 2),
|
||||||
|
'status' => 'submitted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->to('/parent/financial-aid')->with('success', 'Your financial aid request was submitted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ class ParticipationController extends BaseController
|
|||||||
|
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
$this->semesterScoreService = service('semesterScoreService');
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = getSemester();
|
||||||
$this->gradingLockModel = new GradingLockModel();
|
$this->gradingLockModel = new GradingLockModel();
|
||||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ class ParticipationController extends BaseController
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
$studentTeacherInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||||
// Call the updateScoresForStudents method
|
// Call the updateScoresForStudents method
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ class ParticipationController extends BaseController
|
|||||||
|
|
||||||
session()->setFlashdata('status', 'Participation scores updated successfully.');
|
session()->setFlashdata('status', 'Participation scores updated successfully.');
|
||||||
|
|
||||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
$studentTeacherInfo = $this->studentModel->getActiveStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||||
// Call the updateScoresForStudents method
|
// Call the updateScoresForStudents method
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class PaymentController extends ResourceController
|
|||||||
$this->enrollmentModel = new EnrollmentModel();
|
$this->enrollmentModel = new EnrollmentModel();
|
||||||
$this->studentModel = new StudentModel();
|
$this->studentModel = new StudentModel();
|
||||||
$this->studentClassModel = new StudentClassModel();
|
$this->studentClassModel = new StudentClassModel();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->currentSchoolYearName();
|
||||||
$this->semester = $this->configModel->getConfig('semester'); //installment_date
|
$this->semester = getSemester(); //installment_date
|
||||||
$this->installmentDate = $this->configModel->getConfig('installment_date');
|
$this->installmentDate = $this->configModel->getConfig('installment_date');
|
||||||
$this->discountUsageModel = new DiscountUsageModel();
|
$this->discountUsageModel = new DiscountUsageModel();
|
||||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||||
@@ -137,11 +137,36 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
private function getSelectedPaymentHistoryYear(): ?string
|
private function getSelectedPaymentHistoryYear(): ?string
|
||||||
{
|
{
|
||||||
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
$selectedYear = $this->request->getGet('school_year') ?? $this->currentSchoolYearName();
|
||||||
|
|
||||||
return $selectedYear !== '' ? $selectedYear : null;
|
return $selectedYear !== '' ? $selectedYear : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYearName(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->resolve($this->request)->yearName();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeSchoolYearName(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('schoolYearContext')->active()->yearName();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||||
|
{
|
||||||
|
service('schoolYearWriteGuard')->assertWritable(
|
||||||
|
service('schoolYearContext')->forYearName($schoolYear)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// View: Create a new payment plan
|
// View: Create a new payment plan
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
@@ -151,6 +176,12 @@ class PaymentController extends ResourceController
|
|||||||
// API: Update balance after payment
|
// API: Update balance after payment
|
||||||
public function updateBalanceAPI($paymentId)
|
public function updateBalanceAPI($paymentId)
|
||||||
{
|
{
|
||||||
|
$payment = $this->paymentModel->find($paymentId);
|
||||||
|
if (!$payment) {
|
||||||
|
return $this->failNotFound('Payment not found.');
|
||||||
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? ''));
|
||||||
|
|
||||||
$amountPaid = $this->request->getPost('paid_amount');
|
$amountPaid = $this->request->getPost('paid_amount');
|
||||||
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
|
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
|
||||||
return $this->respond(['status' => 'success']);
|
return $this->respond(['status' => 'success']);
|
||||||
@@ -162,6 +193,12 @@ class PaymentController extends ResourceController
|
|||||||
// View: Update balance after payment
|
// View: Update balance after payment
|
||||||
public function updateBalance($paymentId)
|
public function updateBalance($paymentId)
|
||||||
{
|
{
|
||||||
|
$payment = $this->paymentModel->find($paymentId);
|
||||||
|
if (!$payment) {
|
||||||
|
return redirect()->back()->with('error', 'Payment not found.');
|
||||||
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? ''));
|
||||||
|
|
||||||
$amountPaid = $this->request->getPost('paid_amount');
|
$amountPaid = $this->request->getPost('paid_amount');
|
||||||
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
|
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
|
||||||
return redirect()->to('/payments');
|
return redirect()->to('/payments');
|
||||||
@@ -186,6 +223,8 @@ class PaymentController extends ResourceController
|
|||||||
'amount' => $this->request->getPost('amount'),
|
'amount' => $this->request->getPost('amount'),
|
||||||
'payment_method' => $this->request->getPost('payment_method'),
|
'payment_method' => $this->request->getPost('payment_method'),
|
||||||
'reference' => $this->request->getPost('reference'),
|
'reference' => $this->request->getPost('reference'),
|
||||||
|
'school_year' => $this->schoolYear,
|
||||||
|
'update_by' => $this->loggedInUserId(),
|
||||||
'created_at' => utc_now(),
|
'created_at' => utc_now(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -204,7 +243,7 @@ class PaymentController extends ResourceController
|
|||||||
public function eventChargesShow()
|
public function eventChargesShow()
|
||||||
{
|
{
|
||||||
$parents = $this->userModel->getParents();
|
$parents = $this->userModel->getParents();
|
||||||
$school_Year = $this->request->getGet('school_year') ?? $this->schoolYear;
|
$school_Year = $this->request->getGet('school_year') ?? $this->currentSchoolYearName();
|
||||||
$seme_ster = $this->request->getGet('semester') ?? $this->semester;
|
$seme_ster = $this->request->getGet('semester') ?? $this->semester;
|
||||||
|
|
||||||
// Join with users and students for full info
|
// Join with users and students for full info
|
||||||
@@ -288,9 +327,12 @@ class PaymentController extends ResourceController
|
|||||||
$payments = [];
|
$payments = [];
|
||||||
$invoices = [];
|
$invoices = [];
|
||||||
$pager = null;
|
$pager = null;
|
||||||
|
$carryForwardPaymentRequired = false;
|
||||||
|
$carryForwardPaymentMessage = '';
|
||||||
|
|
||||||
// Read the search term (email or phone)
|
// Read the search term (email or phone)
|
||||||
$searchTerm = trim((string) $this->request->getGet('search_term'));
|
$searchTerm = trim((string) $this->request->getGet('search_term'));
|
||||||
|
$manualPaySchoolYear = $this->currentSchoolYearName();
|
||||||
|
|
||||||
// --- Installment end date comes ONLY from config ---
|
// --- Installment end date comes ONLY from config ---
|
||||||
$installmentDateRaw = (string) ($this->installmentDate ?? '');
|
$installmentDateRaw = (string) ($this->installmentDate ?? '');
|
||||||
@@ -306,29 +348,36 @@ class PaymentController extends ResourceController
|
|||||||
$searchClean = preg_replace('/\D/', '', $searchTerm);
|
$searchClean = preg_replace('/\D/', '', $searchTerm);
|
||||||
$searchFormatted = $this->formatPhone($searchClean);
|
$searchFormatted = $this->formatPhone($searchClean);
|
||||||
|
|
||||||
$builder = $this->db->table('users');
|
$builder = $this->db->table('users u');
|
||||||
$builder->select('*');
|
$builder->distinct();
|
||||||
|
$builder->select('u.*');
|
||||||
|
$builder->join('user_roles ur', 'ur.user_id = u.id', 'inner');
|
||||||
|
$builder->join('roles r', 'r.id = ur.role_id', 'inner');
|
||||||
|
$builder->where('LOWER(r.name)', 'parent');
|
||||||
|
|
||||||
// Build search conditions
|
// Build search conditions
|
||||||
$builder->groupStart();
|
$builder->groupStart();
|
||||||
$builder->where('email', $searchTerm);
|
$builder->where('u.email', $searchTerm);
|
||||||
|
$builder->orLike("CONCAT_WS(' ', u.firstname, u.lastname)", $searchTerm, 'both', null, true);
|
||||||
|
$builder->orLike('u.firstname', $searchTerm);
|
||||||
|
$builder->orLike('u.lastname', $searchTerm);
|
||||||
|
|
||||||
if (!empty($searchClean)) {
|
if (!empty($searchClean)) {
|
||||||
if (strlen($searchClean) === 10) {
|
if (strlen($searchClean) === 10) {
|
||||||
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
|
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
|
||||||
$builder->orWhere('cellphone', $formattedPhone);
|
$builder->orWhere('u.cellphone', $formattedPhone);
|
||||||
}
|
}
|
||||||
|
|
||||||
// compare stripped formatting
|
// compare stripped formatting
|
||||||
$builder->orWhere(
|
$builder->orWhere(
|
||||||
"REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =",
|
"REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =",
|
||||||
$this->db->escapeString($searchClean),
|
$this->db->escapeString($searchClean),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($searchFormatted)) {
|
if (!empty($searchFormatted)) {
|
||||||
$builder->orWhere('cellphone', $searchFormatted);
|
$builder->orWhere('u.cellphone', $searchFormatted);
|
||||||
}
|
}
|
||||||
$builder->groupEnd();
|
$builder->groupEnd();
|
||||||
|
|
||||||
@@ -342,6 +391,15 @@ class PaymentController extends ResourceController
|
|||||||
if ($parent && !empty($parent['id'])) {
|
if ($parent && !empty($parent['id'])) {
|
||||||
$parentData = $parent;
|
$parentData = $parent;
|
||||||
$parentId = (int) $parent['id'];
|
$parentId = (int) $parent['id'];
|
||||||
|
$hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $manualPaySchoolYear);
|
||||||
|
$hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $manualPaySchoolYear);
|
||||||
|
$carryForwardPaymentRequired = $hasCarryForwardInvoice && ! $hasCurrentYearInstallmentOverride;
|
||||||
|
if ($hasCarryForwardInvoice) {
|
||||||
|
$carryForwardPaymentMessage = 'This parent has a carry-over balance record from a previous school year. Installments are not allowed without an admin exception.';
|
||||||
|
if ($hasCurrentYearInstallmentOverride) {
|
||||||
|
$carryForwardPaymentMessage = 'Carry-over invoices must be paid in full. Installments are allowed only for current-year balances by admin override.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Students
|
// Students
|
||||||
$students = $this->studentModel
|
$students = $this->studentModel
|
||||||
@@ -350,80 +408,37 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
// Payments (paginated). Join invoices so history is filtered by invoice term
|
// Payments (paginated). Join invoices so history is filtered by invoice term
|
||||||
// and displays current invoice state instead of stale payment snapshots.
|
// and displays current invoice state instead of stale payment snapshots.
|
||||||
$selectedYear = $this->getSelectedPaymentHistoryYear();
|
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $manualPaySchoolYear);
|
||||||
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $selectedYear);
|
|
||||||
$payments = $paymentHistory->paginate(10);
|
$payments = $paymentHistory->paginate(10);
|
||||||
$pager = $paymentHistory->pager;
|
$pager = $paymentHistory->pager;
|
||||||
|
|
||||||
// Invoices
|
// Invoices
|
||||||
$rawInvoices = $this->invoiceModel
|
$rawInvoices = $this->invoiceModel
|
||||||
->where('parent_id', $parentId) // <- fixed (removed "value:")
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $manualPaySchoolYear)
|
||||||
->orderBy('issue_date', 'DESC')
|
->orderBy('issue_date', 'DESC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
// Preload sums of actual payments/discounts/refunds per invoice to avoid stale invoice.paid_amount/balance
|
// Normalize invoices for the view/JS; all accounting amounts come from the ledger.
|
||||||
$paidByInvoice = [];
|
$invoices = array_map(function (array $inv) use ($installmentEndYmd) {
|
||||||
$discountByInvoice = [];
|
|
||||||
$refundByInvoice = [];
|
|
||||||
if (!empty($rawInvoices)) {
|
|
||||||
$invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices)));
|
|
||||||
if (!empty($invoiceIds)) {
|
|
||||||
$rows = $this->paymentModel
|
|
||||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
|
||||||
->whereIn('invoice_id', $invoiceIds)
|
|
||||||
->groupBy('invoice_id')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$iid = (int)($r['invoice_id'] ?? 0);
|
|
||||||
$paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum discounts per invoice_id
|
|
||||||
$discRows = $this->db->table('discount_usages')
|
|
||||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
|
||||||
->whereIn('invoice_id', $invoiceIds)
|
|
||||||
->groupBy('invoice_id')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($discRows as $dr) {
|
|
||||||
$iid = (int)($dr['invoice_id'] ?? 0);
|
|
||||||
$discountByInvoice[$iid] = (float)($dr['total_disc'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum PAID refunds per invoice_id (only Partial/Paid reduce liability)
|
|
||||||
$refRows = $this->db->table('refunds')
|
|
||||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
|
||||||
->whereIn('invoice_id', $invoiceIds)
|
|
||||||
->whereIn('status', ['Partial','Paid'])
|
|
||||||
->groupBy('invoice_id')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($refRows as $rr) {
|
|
||||||
$iid = (int)($rr['invoice_id'] ?? 0);
|
|
||||||
$refundByInvoice[$iid] = (float)($rr['total_refund_paid'] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize invoices for the view/JS; force due_ymd to the CONFIG date
|
|
||||||
$invoices = array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
|
|
||||||
// Cast numeric fields we rely on
|
|
||||||
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
|
|
||||||
// Prefer actual sum of payments for paid_amount
|
|
||||||
$iid = (int)($inv['id'] ?? 0);
|
$iid = (int)($inv['id'] ?? 0);
|
||||||
$actualPaid = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null;
|
if ($iid > 0) {
|
||||||
$inv['paid_amount'] = is_numeric($actualPaid)
|
$ledger = $this->invoiceLedgerService->recalculateInvoice($iid);
|
||||||
? (float)$actualPaid
|
$inv['display_total'] = (float) ($ledger['total_amount'] ?? 0);
|
||||||
: (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.0);
|
$inv['total_amount'] = $inv['display_total'];
|
||||||
// Per-invoice discount
|
$inv['paid_amount'] = (float) ($ledger['paid_amount'] ?? 0);
|
||||||
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float)$discountByInvoice[$iid] : (float)($inv['discount'] ?? 0.0);
|
$inv['discount'] = (float) ($ledger['discount_total'] ?? 0);
|
||||||
|
$inv['refund_paid'] = (float) ($ledger['refund_paid_total'] ?? 0);
|
||||||
// Per-invoice refunds (paid out)
|
$inv['balance'] = (float) ($ledger['balance'] ?? 0);
|
||||||
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float)$refundByInvoice[$iid] : 0.0;
|
$inv['balance_due_cents'] = (int) ($ledger['balanceDueCents'] ?? 0);
|
||||||
|
$inv['customer_credit_cents'] = (int) ($ledger['customerCreditCents'] ?? 0);
|
||||||
// Derive balance from total - paid - discount - refundsPaid (never below 0)
|
$inv['customer_credit'] = (float) ($ledger['customer_credit'] ?? 0);
|
||||||
$inv['balance'] = max(0.0, (float)$inv['total_amount'] - (float)$inv['paid_amount'] - (float)$inv['discount'] - (float)$inv['refund_paid']);
|
$inv['status'] = (string) ($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
// Always use the configured end date for installments
|
// Always use the configured end date for installments
|
||||||
$inv['due_ymd'] = $installmentEndYmd;
|
$inv['due_ymd'] = $installmentEndYmd;
|
||||||
|
$inv['is_carry_forward_invoice'] = $this->isCarryForwardInvoiceRow($inv) ? 1 : 0;
|
||||||
|
|
||||||
// Optional: keep a start marker if you ever need it elsewhere
|
// Optional: keep a start marker if you ever need it elsewhere
|
||||||
$issueYmd = '';
|
$issueYmd = '';
|
||||||
@@ -452,6 +467,8 @@ class PaymentController extends ResourceController
|
|||||||
'searchTermUsedInSearch' => $searchTerm,
|
'searchTermUsedInSearch' => $searchTerm,
|
||||||
'todayYmd' => utc_now(),
|
'todayYmd' => utc_now(),
|
||||||
'installmentEndYmd' => $installmentEndYmd, // SELECT will use this in data-end-date
|
'installmentEndYmd' => $installmentEndYmd, // SELECT will use this in data-end-date
|
||||||
|
'carryForwardPaymentRequired' => $carryForwardPaymentRequired,
|
||||||
|
'carryForwardPaymentMessage' => $carryForwardPaymentMessage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,21 +483,23 @@ class PaymentController extends ResourceController
|
|||||||
$qs = '%' . $db->escapeLikeString($q) . '%';
|
$qs = '%' . $db->escapeLikeString($q) . '%';
|
||||||
$digits = preg_replace('/\D/', '', $q);
|
$digits = preg_replace('/\D/', '', $q);
|
||||||
|
|
||||||
$sql = "SELECT id, firstname, lastname, email, cellphone
|
$sql = "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
|
||||||
FROM users
|
FROM users u
|
||||||
|
JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
JOIN roles r ON r.id = ur.role_id AND LOWER(r.name) = 'parent'
|
||||||
WHERE (
|
WHERE (
|
||||||
CONCAT_WS(' ', firstname, lastname) LIKE ?
|
CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?
|
||||||
OR email LIKE ?
|
OR u.email LIKE ?
|
||||||
OR cellphone LIKE ?";
|
OR u.cellphone LIKE ?";
|
||||||
$params = [$qs, $qs, $qs];
|
$params = [$qs, $qs, $qs];
|
||||||
|
|
||||||
if ($digits !== '') {
|
if ($digits !== '') {
|
||||||
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
|
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
|
||||||
$params[] = '%' . $digits . '%';
|
$params[] = '%' . $digits . '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql .= ")
|
$sql .= ")
|
||||||
ORDER BY lastname, firstname
|
ORDER BY u.lastname, u.firstname
|
||||||
LIMIT 20";
|
LIMIT 20";
|
||||||
|
|
||||||
$rows = $db->query($sql, $params)->getResultArray();
|
$rows = $db->query($sql, $params)->getResultArray();
|
||||||
@@ -503,7 +522,7 @@ class PaymentController extends ResourceController
|
|||||||
return $this->response->setJSON(['items' => $items]);
|
return $this->response->setJSON(['items' => $items]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
|
private function updateEnrollmentStatusAfterRecordedPayment(int $invoiceId): int
|
||||||
{
|
{
|
||||||
// 1) Fetch invoice
|
// 1) Fetch invoice
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
$invoice = $this->invoiceModel->find($invoiceId);
|
||||||
@@ -512,20 +531,19 @@ class PaymentController extends ResourceController
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Payment check: recompute current balance from payments (authoritative)
|
// 2) Payment check: any successful payment, including the first installment, enrolls the student.
|
||||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
if ($this->getSuccessfulPaymentCount($invoiceId) < 1) {
|
||||||
$currentBal = $this->getCurrentInvoiceBalance($invoiceId); // <-- uses the safe helper
|
log_message('info', 'No successful payment found. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||||
if (!($total > 0 && $currentBal < $total)) {
|
|
||||||
log_message('info', 'No payment yet (or still full balance). Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||||
$schoolYear = (string) $this->schoolYear;
|
$schoolYear = (string) ($invoice['school_year'] ?? $this->schoolYear);
|
||||||
$semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null;
|
$semester = trim((string) ($invoice['semester'] ?? ''));
|
||||||
|
$semester = $semester !== '' ? $semester : null;
|
||||||
|
|
||||||
if ($parentId <= 0 || $schoolYear === '') {
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
log_message('warning', 'updateEnrollmentStatusAfterRecordedPayment: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +586,7 @@ class PaymentController extends ResourceController
|
|||||||
->orWhere('enrollment_status', 'Payment pending')
|
->orWhere('enrollment_status', 'Payment pending')
|
||||||
->orWhere('enrollment_status', 'Payment Pending')
|
->orWhere('enrollment_status', 'Payment Pending')
|
||||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
||||||
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
|
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false)
|
||||||
->groupEnd();
|
->groupEnd();
|
||||||
|
|
||||||
if ($semester !== null) {
|
if ($semester !== null) {
|
||||||
@@ -596,22 +614,32 @@ class PaymentController extends ResourceController
|
|||||||
$db = $this->db;
|
$db = $this->db;
|
||||||
$db->transBegin();
|
$db->transBegin();
|
||||||
try {
|
try {
|
||||||
$builder = $db->table('enrollments');
|
$rowsToUpdate = $db->table('enrollments')
|
||||||
$builder->set('enrollment_status', 'enrolled')
|
->whereIn('id', $toUpdateIds)
|
||||||
->set('enrollment_date', local_date(utc_now(), 'Y-m-d')) // or 'CURRENT_DATE()' with false flag
|
->get()
|
||||||
->whereIn('id', $toUpdateIds);
|
->getResultArray();
|
||||||
|
$statusService = \Config\Services::enrollmentStatus(false);
|
||||||
|
|
||||||
if ($builder->update() === false) {
|
foreach ($rowsToUpdate as $row) {
|
||||||
$err = $db->error();
|
$statusService->upsertStatus([
|
||||||
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
|
'id' => (int) $row['id'],
|
||||||
|
'student_id' => (int) $row['student_id'],
|
||||||
|
'parent_id' => (int) $row['parent_id'],
|
||||||
|
'school_year' => (string) $row['school_year'],
|
||||||
|
'semester' => (string) ($row['semester'] ?? ''),
|
||||||
|
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
||||||
|
'enrollment_status' => 'enrolled',
|
||||||
|
'admission_status' => 'accepted',
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
], (int) (session()->get('user_id') ?? 0) ?: null, 'payment_recorded');
|
||||||
}
|
}
|
||||||
|
|
||||||
$affected = $db->affectedRows();
|
$affected = count($rowsToUpdate);
|
||||||
$db->transCommit();
|
$db->transCommit();
|
||||||
|
|
||||||
log_message(
|
log_message(
|
||||||
'info',
|
'info',
|
||||||
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
'Enrollment status -> enrolled after payment for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
||||||
[
|
[
|
||||||
'n' => $affected,
|
'n' => $affected,
|
||||||
'p' => $parentId,
|
'p' => $parentId,
|
||||||
@@ -624,7 +652,7 @@ class PaymentController extends ResourceController
|
|||||||
return $affected;
|
return $affected;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$db->transRollback();
|
$db->transRollback();
|
||||||
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
|
log_message('error', 'updateEnrollmentStatusAfterRecordedPayment error: ' . $e->getMessage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,6 +898,10 @@ class PaymentController extends ResourceController
|
|||||||
$paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card
|
$paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card
|
||||||
$checkNumber = trim((string) $this->request->getPost('check_number'));
|
$checkNumber = trim((string) $this->request->getPost('check_number'));
|
||||||
$searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? '';
|
$searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? '';
|
||||||
|
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
|
||||||
|
if ($idempotencyKey === '') {
|
||||||
|
$idempotencyKey = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
// UI-only
|
// UI-only
|
||||||
$paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment
|
$paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment
|
||||||
@@ -900,6 +932,7 @@ class PaymentController extends ResourceController
|
|||||||
if (!$invoice) {
|
if (!$invoice) {
|
||||||
return redirect()->back()->with('error', 'Invoice not found.');
|
return redirect()->back()->with('error', 'Invoice not found.');
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
|
||||||
|
|
||||||
// Snapshot pre-payment balance
|
// Snapshot pre-payment balance
|
||||||
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId);
|
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId);
|
||||||
@@ -913,10 +946,11 @@ class PaymentController extends ResourceController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optional receipt upload
|
// Optional receipt upload
|
||||||
|
$stagedEvidence = null;
|
||||||
$checkFile = null;
|
$checkFile = null;
|
||||||
$paymentFile = $this->request->getFile('payment_file');
|
$paymentFile = $this->request->getFile('payment_file');
|
||||||
try {
|
try {
|
||||||
$checkFile = $this->financialAttachmentService->saveUploadedFile(
|
$stagedEvidence = $this->financialAttachmentService->stageUploadedFile(
|
||||||
$paymentFile,
|
$paymentFile,
|
||||||
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
|
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
|
||||||
);
|
);
|
||||||
@@ -928,12 +962,13 @@ class PaymentController extends ResourceController
|
|||||||
try {
|
try {
|
||||||
// Lock invoice & get context (also ensure totals are up-to-date before validation)
|
// Lock invoice & get context (also ensure totals are up-to-date before validation)
|
||||||
$row = $this->db->query(
|
$row = $this->db->query(
|
||||||
'SELECT id, parent_id, total_amount, school_year FROM invoices WHERE id = ? FOR UPDATE',
|
'SELECT id, parent_id, invoice_number, total_amount, school_year, semester, description FROM invoices WHERE id = ? FOR UPDATE',
|
||||||
[$invoiceId]
|
[$invoiceId]
|
||||||
)->getRowArray();
|
)->getRowArray();
|
||||||
|
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
$this->db->transRollback();
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
return redirect()->back()->with('error', 'Invoice not found.');
|
return redirect()->back()->with('error', 'Invoice not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,31 +978,51 @@ class PaymentController extends ResourceController
|
|||||||
// Recompute invoice totals from tuition + events + additional charges
|
// Recompute invoice totals from tuition + events + additional charges
|
||||||
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
|
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
|
||||||
|
|
||||||
|
$hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $invYear);
|
||||||
|
$hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $invYear);
|
||||||
|
$carryForwardPaymentRequired = $hasCarryForwardInvoice
|
||||||
|
&& ($this->isCarryForwardInvoiceRow($row) || ! $hasCurrentYearInstallmentOverride);
|
||||||
|
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
|
||||||
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
|
return redirect()->back()->withInput()->with(
|
||||||
|
'error',
|
||||||
|
'This parent has a balance carried over from a previous school year. Installments are not allowed; payment must be made in full.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($amount > $currentBalance + 0.00001) {
|
if ($amount > $currentBalance + 0.00001) {
|
||||||
$this->db->transRollback();
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
return redirect()->back()->withInput()->with(
|
return redirect()->back()->withInput()->with(
|
||||||
'error',
|
'error',
|
||||||
'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
|
'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($carryForwardPaymentRequired && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
|
return redirect()->back()->withInput()->with(
|
||||||
|
'error',
|
||||||
|
'This parent has a balance carried over from a previous school year. Payment must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
||||||
$this->db->transRollback();
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
return redirect()->back()->withInput()->with(
|
return redirect()->back()->withInput()->with(
|
||||||
'error',
|
'error',
|
||||||
'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installment sequence
|
|
||||||
$paidCount = $this->getSuccessfulPaymentCount($invoiceId);
|
|
||||||
$installmentSeq = $paidCount + 1;
|
|
||||||
|
|
||||||
// Generate a stable transaction id
|
// Generate a stable transaction id
|
||||||
$transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string)microtime(true));
|
$transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string)microtime(true));
|
||||||
|
|
||||||
// Record payment
|
// Record payment
|
||||||
$ok = $this->processPayment(
|
$paymentResult = $this->processPayment(
|
||||||
$invoiceId,
|
$invoiceId,
|
||||||
$amount,
|
$amount,
|
||||||
$paymentMethod,
|
$paymentMethod,
|
||||||
@@ -976,15 +1031,38 @@ class PaymentController extends ResourceController
|
|||||||
$paymentDate,
|
$paymentDate,
|
||||||
$invYear,
|
$invYear,
|
||||||
$checkNumber,
|
$checkNumber,
|
||||||
$installmentSeq,
|
null,
|
||||||
(array) $this->invoiceModel->find($invoiceId),
|
(array) $row,
|
||||||
$currentBalance
|
$currentBalance,
|
||||||
|
$idempotencyKey
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$ok) {
|
if ($paymentResult === false) {
|
||||||
$this->db->transRollback();
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
return redirect()->back()->with('error', 'Failed to record payment.');
|
return redirect()->back()->with('error', 'Failed to record payment.');
|
||||||
}
|
}
|
||||||
|
if (!empty($paymentResult['conflict'])) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Payment idempotency key conflicts with a different request.');
|
||||||
|
}
|
||||||
|
$installmentSeq = (int) ($paymentResult['installment_seq'] ?? 1);
|
||||||
|
|
||||||
|
if (!$this->recordManualPayment(
|
||||||
|
(string)($row['invoice_number'] ?? $invoiceId),
|
||||||
|
$amount,
|
||||||
|
$paymentMethod,
|
||||||
|
$transactionId,
|
||||||
|
$checkFile,
|
||||||
|
$invYear,
|
||||||
|
$paymentDate
|
||||||
|
)) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
|
log_message('error', '[manualPayUpdate] Failed to record manual payment audit row: ' . json_encode($this->manualPaymentModel->errors()));
|
||||||
|
return redirect()->back()->with('error', 'Payment was not recorded because the manual payment audit row could not be saved.');
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure invoice totals/balance reflect discounts and this payment
|
// Ensure invoice totals/balance reflect discounts and this payment
|
||||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||||
@@ -992,11 +1070,18 @@ class PaymentController extends ResourceController
|
|||||||
// Post-payment balance from snapshot
|
// Post-payment balance from snapshot
|
||||||
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
|
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
|
||||||
|
|
||||||
|
$this->syncEnrollmentFinanceAfterPayment($parentId, $invYear);
|
||||||
|
|
||||||
// Optional enrollment update
|
// Optional enrollment update
|
||||||
$enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId);
|
$enrollmentupdated = $this->updateEnrollmentStatusAfterRecordedPayment($invoiceId);
|
||||||
if ($enrollmentupdated != 0) {
|
if ($enrollmentupdated != 0) {
|
||||||
$studentIds = $this->studentModel->getStudentIdsByParentId($parentId);
|
$studentIds = $this->studentModel->getStudentIdsByParentId($parentId);
|
||||||
[$eventDataEnroll, $studentDataEnroll] = $this->buildStudentEnrolledEventData($parentId, $studentIds);
|
[$eventDataEnroll, $studentDataEnroll] = $this->buildStudentEnrolledEventData(
|
||||||
|
$parentId,
|
||||||
|
$studentIds,
|
||||||
|
$invYear,
|
||||||
|
(string) ($row['semester'] ?? $this->semester)
|
||||||
|
);
|
||||||
Events::trigger('studentEnrolled', $eventDataEnroll, $studentDataEnroll);
|
Events::trigger('studentEnrolled', $eventDataEnroll, $studentDataEnroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,6 +1109,25 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
$this->db->transCommit();
|
$this->db->transCommit();
|
||||||
|
|
||||||
|
$evidenceWarning = null;
|
||||||
|
if ($stagedEvidence !== null && empty($paymentResult['duplicate'])) {
|
||||||
|
try {
|
||||||
|
$checkFile = $this->financialAttachmentService->finalizeStagedFile($stagedEvidence);
|
||||||
|
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
|
||||||
|
'check_file' => $checkFile,
|
||||||
|
'evidence_status' => 'complete',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
|
$evidenceWarning = 'Payment recorded, but evidence could not be finalized.';
|
||||||
|
log_message('critical', '[manualPayUpdate] evidence incomplete for payment #' . (int)($paymentResult['payment_id'] ?? 0) . ': ' . $e->getMessage());
|
||||||
|
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
|
||||||
|
'evidence_status' => 'incomplete',
|
||||||
|
'evidence_failure_message' => $evidenceWarning,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build payload & notify
|
// Build payload & notify
|
||||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||||
$invoiceId,
|
$invoiceId,
|
||||||
@@ -1043,15 +1147,45 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm)))
|
->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm)))
|
||||||
->with('success', 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
|
->with($evidenceWarning ? 'warning' : 'success', ($evidenceWarning ?? 'Payment recorded successfully') . ' (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if ($this->db->transStatus()) $this->db->transRollback();
|
if ($this->db->transStatus()) $this->db->transRollback();
|
||||||
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence ?? null);
|
||||||
log_message('error', '[manualPayUpdate] ' . $e->getMessage());
|
log_message('error', '[manualPayUpdate] ' . $e->getMessage());
|
||||||
return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.');
|
return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function recordManualPayment(
|
||||||
|
string $invoiceNumber,
|
||||||
|
float $amount,
|
||||||
|
string $paymentMethod,
|
||||||
|
string $reference,
|
||||||
|
?string $proofPath,
|
||||||
|
string $schoolYear,
|
||||||
|
string $createdAt
|
||||||
|
): bool {
|
||||||
|
return (bool) $this->manualPaymentModel->insert([
|
||||||
|
'invoice_number' => $invoiceNumber,
|
||||||
|
'amount' => $amount,
|
||||||
|
'payment_method' => strtolower($paymentMethod),
|
||||||
|
'reference' => $reference,
|
||||||
|
'proof_path' => $proofPath,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'update_by' => $this->loggedInUserId(),
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loggedInUserId(): ?int
|
||||||
|
{
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
|
||||||
|
return $userId > 0 ? $userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function manualPayEdit()
|
public function manualPayEdit()
|
||||||
{
|
{
|
||||||
$paymentId = (int) $this->request->getPost('payment_id');
|
$paymentId = (int) $this->request->getPost('payment_id');
|
||||||
@@ -1072,6 +1206,7 @@ class PaymentController extends ResourceController
|
|||||||
if (!$invoice) {
|
if (!$invoice) {
|
||||||
return redirect()->back()->with('error', 'Linked invoice not found.');
|
return redirect()->back()->with('error', 'Linked invoice not found.');
|
||||||
}
|
}
|
||||||
|
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ($payment['school_year'] ?? '')));
|
||||||
|
|
||||||
//$schoolYear = (new ConfigurationModel())->getConfig('school_year');
|
//$schoolYear = (new ConfigurationModel())->getConfig('school_year');
|
||||||
$checkFile = $payment['check_file']; // default keep old file
|
$checkFile = $payment['check_file']; // default keep old file
|
||||||
@@ -1149,6 +1284,115 @@ class PaymentController extends ResourceController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parentHasCarryForwardInvoice(int $parentId, ?string $schoolYear = null): bool
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || ! $this->db->tableExists('invoices')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('invoices')
|
||||||
|
->where('parent_id', $parentId);
|
||||||
|
|
||||||
|
if ($schoolYear !== null && $schoolYear !== '') {
|
||||||
|
$builder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->groupStart()
|
||||||
|
->like('invoice_number', 'CF-', 'after')
|
||||||
|
->orWhere('semester', 'Opening Balance');
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('description', 'invoices')) {
|
||||||
|
$builder
|
||||||
|
->orLike('description', 'carried over')
|
||||||
|
->orLike('description', 'carry-forward')
|
||||||
|
->orLike('description', 'carry over')
|
||||||
|
->orLike('description', 'previous school year');
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->groupEnd();
|
||||||
|
|
||||||
|
return $builder->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentHasCurrentYearInstallmentOverride(int $parentId, string $schoolYear): bool
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('enrollment_exceptions')
|
||||||
|
->select('bypassed_rule_codes_json')
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->whereIn('status', ['active', 'used'])
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true);
|
||||||
|
if (! is_array($codes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes);
|
||||||
|
if (in_array('CURRENT_YEAR_INSTALLMENT_OVERRIDE', $codes, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isCarryForwardInvoiceRow(array $invoice): bool
|
||||||
|
{
|
||||||
|
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
|
||||||
|
if (str_starts_with($invoiceNumber, 'CF-')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = strtolower((string) ($invoice['description'] ?? ''));
|
||||||
|
|
||||||
|
return str_contains($description, 'carried over')
|
||||||
|
|| str_contains($description, 'carry-forward')
|
||||||
|
|| str_contains($description, 'carry over')
|
||||||
|
|| str_contains($description, 'previous school year');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncEnrollmentFinanceAfterPayment(int $parentId, string $targetSchoolYear): void
|
||||||
|
{
|
||||||
|
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||||
|
if ($parentId <= 0 || $sourceSchoolYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$studentIds = $this->studentModel->getStudentIdsByParentId($parentId);
|
||||||
|
service('enrollmentTransition')->syncParentFinancialReviewFlags(
|
||||||
|
$parentId,
|
||||||
|
$sourceSchoolYear,
|
||||||
|
$targetSchoolYear,
|
||||||
|
array_values(array_map('intval', $studentIds ?? []))
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Enrollment finance sync after payment failed for parent {parent_id}, school year {school_year}: {error}', [
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'school_year' => $targetSchoolYear,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||||||
|
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year
|
* 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year
|
||||||
@@ -1180,6 +1424,7 @@ class PaymentController extends ResourceController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear);
|
$schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear);
|
||||||
|
$this->assertSchoolYearNameWritable($schoolYear);
|
||||||
$this->recalculateInvoice((int)$invoice['id'], $schoolYear);
|
$this->recalculateInvoice((int)$invoice['id'], $schoolYear);
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Invoice recalculated.');
|
return redirect()->back()->with('success', 'Invoice recalculated.');
|
||||||
@@ -1274,55 +1519,119 @@ class PaymentController extends ResourceController
|
|||||||
$checkNumber = null,
|
$checkNumber = null,
|
||||||
?int $installmentSeq = null,
|
?int $installmentSeq = null,
|
||||||
?array $invoice = null,
|
?array $invoice = null,
|
||||||
?float $currentBalance = null
|
?float $currentBalance = null,
|
||||||
|
?string $idempotencyKey = null
|
||||||
) {
|
) {
|
||||||
$invoice = $invoice ?? $this->invoiceModel->find($invoiceId);
|
$invoice = $invoice ?? $this->invoiceModel->find($invoiceId);
|
||||||
if (!$invoice) {
|
if (!$invoice) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$invoiceId = (int) ($invoice['id'] ?? $invoiceId);
|
||||||
|
$amount = round((float) $amount, 2);
|
||||||
|
if ($amount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time();
|
$transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time();
|
||||||
$paymentDate = $paymentDate ?? utc_now();
|
$paymentDate = $paymentDate ?? utc_now();
|
||||||
|
|
||||||
// If sequence wasn't provided (legacy callers), compute it here.
|
if ($this->paymentModel->where('transaction_id', $transactionId)->first()) {
|
||||||
// NOTE: If you call this from inside a transaction (recommended), this will be consistent.
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($idempotencyKey !== null && $idempotencyKey !== '') {
|
||||||
|
$fingerprint = $this->buildPaymentFingerprint(
|
||||||
|
(int)$invoice['parent_id'],
|
||||||
|
$invoiceId,
|
||||||
|
(int)round($amount * 100),
|
||||||
|
strtolower($paymentMethod),
|
||||||
|
'USD'
|
||||||
|
);
|
||||||
|
$existing = $this->paymentModel->where('idempotency_key', $idempotencyKey)->first();
|
||||||
|
if ($existing) {
|
||||||
|
if ((string)($existing['request_fingerprint_hash'] ?? '') !== $fingerprint) {
|
||||||
|
return [
|
||||||
|
'conflict' => true,
|
||||||
|
'duplicate' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'payment_id' => (int) ($existing['id'] ?? 0),
|
||||||
|
'installment_seq' => (int) ($existing['installment_seq'] ?? $existing['number_of_installments'] ?? 1),
|
||||||
|
'duplicate' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sequence wasn't provided, compute it inside the caller's invoice lock.
|
||||||
if ($installmentSeq === null || $installmentSeq < 1) {
|
if ($installmentSeq === null || $installmentSeq < 1) {
|
||||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
$row = $this->db->table('payments')
|
||||||
$priorCount = $this->paymentModel
|
->select('COALESCE(MAX(installment_seq), 0) + 1 AS next_seq', false)
|
||||||
->where('invoice_id', $invoiceId)
|
->where('invoice_id', $invoiceId)
|
||||||
->where('paid_amount >', 0)
|
->get()
|
||||||
->whereNotIn('status', $exclude)
|
->getRowArray();
|
||||||
->countAllResults();
|
$installmentSeq = (int) ($row['next_seq'] ?? 1);
|
||||||
$installmentSeq = $priorCount + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$preBalance = $currentBalance ?? $this->getCurrentInvoiceBalance((int) $invoiceId);
|
$preBalance = $currentBalance ?? $this->getCurrentInvoiceBalance((int) $invoiceId);
|
||||||
$newBalance = max(0.0, round($preBalance - (float) $amount, 2));
|
if ($amount > $preBalance + 0.00001) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$postBalance = max(0.0, round($preBalance - $amount, 2));
|
||||||
$paymentData = [
|
$paymentData = [
|
||||||
'parent_id' => $invoice['parent_id'],
|
'parent_id' => (int) $invoice['parent_id'],
|
||||||
'invoice_id' => $invoiceId,
|
'invoice_id' => $invoiceId,
|
||||||
'total_amount' => $invoice['total_amount'],
|
'total_amount' => $invoice['total_amount'],
|
||||||
'paid_amount' => $amount,
|
'paid_amount' => $amount,
|
||||||
'balance' => $newBalance,
|
'balance' => $postBalance,
|
||||||
|
'balance_amount' => $postBalance,
|
||||||
|
'balance_after_payment' => $postBalance,
|
||||||
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
|
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
|
||||||
'installment_seq' => $installmentSeq,
|
'installment_seq' => $installmentSeq,
|
||||||
'transaction_id' => $transactionId,
|
'transaction_id' => $transactionId,
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
|
'request_fingerprint_hash' => $idempotencyKey ? ($fingerprint ?? null) : null,
|
||||||
'payment_method' => strtolower($paymentMethod),
|
'payment_method' => strtolower($paymentMethod),
|
||||||
'payment_date' => $paymentDate,
|
'payment_date' => $paymentDate,
|
||||||
'status' => FinancialStatus::PAYMENT_RECORDED,
|
'status' => FinancialStatus::PAYMENT_RECORDED,
|
||||||
'check_file' => $checkFile,
|
'check_file' => $checkFile,
|
||||||
|
'evidence_status' => $checkFile ? 'complete' : null,
|
||||||
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
|
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
|
||||||
'updated_by' => session()->get('user_id'),
|
'updated_by' => session()->get('user_id'),
|
||||||
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
|
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!$this->paymentModel->insert($paymentData)) {
|
$paymentId = $this->paymentModel->insert($paymentData);
|
||||||
|
if (!$paymentId) {
|
||||||
|
log_message('error', '[processPayment] Failed to insert payment: ' . json_encode($this->paymentModel->errors()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return [
|
||||||
|
'payment_id' => (int) $paymentId,
|
||||||
|
'installment_seq' => $installmentSeq,
|
||||||
|
'duplicate' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPaymentFingerprint(
|
||||||
|
int $parentId,
|
||||||
|
int $invoiceId,
|
||||||
|
int $amountCents,
|
||||||
|
string $paymentMethod,
|
||||||
|
string $currency
|
||||||
|
): string {
|
||||||
|
return hash('sha256', json_encode([
|
||||||
|
'operation_type' => 'payment_creation',
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'invoice_id' => $invoiceId,
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
'payment_method' => strtolower($paymentMethod),
|
||||||
|
'currency' => strtoupper($currency),
|
||||||
|
], JSON_UNESCAPED_SLASHES));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getSuccessfulPaymentCount(int $invoiceId): int
|
private function getSuccessfulPaymentCount(int $invoiceId): int
|
||||||
@@ -1376,7 +1685,7 @@ class PaymentController extends ResourceController
|
|||||||
$roles[] = $activeRole;
|
$roles[] = $activeRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'];
|
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant', 'head fa', 'head of fa', 'head_of_fa', 'financial_contributor'];
|
||||||
foreach ($staffRoles as $role) {
|
foreach ($staffRoles as $role) {
|
||||||
if (in_array($role, $roles, true)) {
|
if (in_array($role, $roles, true)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\ConfigurationModel;
|
|||||||
use App\Models\InvoiceModel;
|
use App\Models\InvoiceModel;
|
||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Models\FamilyGuardianModel;
|
use App\Models\FamilyGuardianModel;
|
||||||
|
use App\Libraries\InvoiceLedgerService;
|
||||||
use App\Services\EmailService;
|
use App\Services\EmailService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class PaymentNotificationController extends BaseController
|
|||||||
protected PaymentModel $paymentModel;
|
protected PaymentModel $paymentModel;
|
||||||
protected FamilyGuardianModel $familyGuardianModel;
|
protected FamilyGuardianModel $familyGuardianModel;
|
||||||
protected EmailService $emailService;
|
protected EmailService $emailService;
|
||||||
|
protected InvoiceLedgerService $invoiceLedgerService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,7 @@ class PaymentNotificationController extends BaseController
|
|||||||
$this->paymentModel = new PaymentModel();
|
$this->paymentModel = new PaymentModel();
|
||||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
$this->familyGuardianModel = new FamilyGuardianModel();
|
||||||
$this->emailService = new EmailService();
|
$this->emailService = new EmailService();
|
||||||
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
@@ -87,43 +90,19 @@ class PaymentNotificationController extends BaseController
|
|||||||
$year = (int)$now->format('Y');
|
$year = (int)$now->format('Y');
|
||||||
$month= (int)$now->format('n');
|
$month= (int)$now->format('n');
|
||||||
|
|
||||||
// Helper: compute current total balance across invoices for this parent/year
|
|
||||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
$computeBalance = function (int $pid) use ($schoolYear): array {
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$rows = $db->table('invoices')
|
$rows = $db->table('invoices')
|
||||||
->select('id, total_amount')
|
->select('id')
|
||||||
->where('parent_id', $pid)
|
->where('parent_id', $pid)
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
$paymentsTbl = 'payments';
|
|
||||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
|
||||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
|
||||||
|
|
||||||
$sumBalance = 0.0; $latestId = null;
|
$sumBalance = 0.0; $latestId = null;
|
||||||
foreach ($rows as $ir) {
|
foreach ($rows as $ir) {
|
||||||
$iid = (int)$ir['id'];
|
$iid = (int)$ir['id'];
|
||||||
if ($latestId === null) $latestId = $iid;
|
if ($latestId === null) $latestId = $iid;
|
||||||
$total = (float)($ir['total_amount'] ?? 0);
|
$sumBalance += (float)($this->invoiceLedgerService->calculateInvoice($iid)['balance'] ?? 0.0);
|
||||||
|
|
||||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
|
||||||
if ($hasStatus) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
|
||||||
->orWhere('status IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
if ($hasVoid) {
|
|
||||||
$qb->groupStart()
|
|
||||||
->where('is_void', 0)
|
|
||||||
->orWhere('is_void IS NULL', null, false)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
|
||||||
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
|
|
||||||
|
|
||||||
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
|
|
||||||
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
|
|
||||||
|
|
||||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
|
||||||
}
|
}
|
||||||
return [$sumBalance, $latestId];
|
return [$sumBalance, $latestId];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\View;
|
|
||||||
|
|
||||||
use App\Models\PaymentTransactionModel;
|
|
||||||
use CodeIgniter\RESTful\ResourceController;
|
|
||||||
|
|
||||||
class PaymentTransactionController extends ResourceController
|
|
||||||
{
|
|
||||||
protected $paymentTransactionModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->paymentTransactionModel = new PaymentTransactionModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: Create a new payment transaction (installment)
|
|
||||||
public function createAPI()
|
|
||||||
{
|
|
||||||
$data = [
|
|
||||||
'transaction_id' => $this->request->getPost('transaction_id'),
|
|
||||||
'payment_id' => $this->request->getPost('payment_id'),
|
|
||||||
'transaction_date' => $this->request->getPost('transaction_date'),
|
|
||||||
'amount' => $this->request->getPost('amount'),
|
|
||||||
'payment_method' => $this->request->getPost('payment_method'),
|
|
||||||
'payment_status' => 'Pending',
|
|
||||||
'transaction_fee' => $this->request->getPost('transaction_fee'),
|
|
||||||
'payment_reference' => $this->request->getPost('payment_reference'),
|
|
||||||
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->paymentTransactionModel->save($data)) {
|
|
||||||
return $this->respondCreated($data);
|
|
||||||
} else {
|
|
||||||
return $this->failValidationErrors($this->paymentTransactionModel->errors());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: Get all transactions for a specific payment
|
|
||||||
public function getByPaymentAPI($paymentId)
|
|
||||||
{
|
|
||||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
|
||||||
if ($transactions) {
|
|
||||||
return $this->respond($transactions);
|
|
||||||
} else {
|
|
||||||
return $this->failNotFound('Transactions not found for the payment.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// View: Get all transactions for a specific payment (for web views)
|
|
||||||
public function getByPayment($paymentId)
|
|
||||||
{
|
|
||||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
|
||||||
return view('payment_transaction_list', ['transactions' => $transactions]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// View: Create a new payment transaction
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
return view('payment_transaction_create');
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: Update payment status by transaction ID
|
|
||||||
public function updateStatusAPI($transactionId)
|
|
||||||
{
|
|
||||||
$status = $this->request->getPost('status');
|
|
||||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
|
||||||
return $this->respond(['status' => 'success']);
|
|
||||||
} else {
|
|
||||||
return $this->failNotFound('Transaction not found.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// View: Update payment status by transaction ID
|
|
||||||
public function updateStatus($transactionId)
|
|
||||||
{
|
|
||||||
$status = $this->request->getPost('status');
|
|
||||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
|
||||||
return redirect()->to('/payment_transactions');
|
|
||||||
} else {
|
|
||||||
return redirect()->back()->with('error', 'Failed to update status.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,13 +20,16 @@ class PreferencesController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function index($userId = null)
|
public function index($userId = null)
|
||||||
{
|
{
|
||||||
// Get user ID from parameter or session
|
$sessionUserId = (int) (session()->get('user_id') ?? 0);
|
||||||
$userId = $userId ?? (int) session()->get('user_id');
|
if ($sessionUserId <= 0) {
|
||||||
|
|
||||||
if (!$userId) {
|
|
||||||
return redirect()->to('/login')->with('error', 'Please log in to view preferences');
|
return redirect()->to('/login')->with('error', 'Please log in to view preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($userId ?? $sessionUserId);
|
||||||
|
if (! $this->canAccessUserPreferences($userId, $sessionUserId)) {
|
||||||
|
return redirect()->to('/access_denied');
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch preferences for the current user
|
// Fetch preferences for the current user
|
||||||
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
|
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
|
||||||
|
|
||||||
@@ -64,13 +67,16 @@ class PreferencesController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function updatePreferences($userId = null)
|
public function updatePreferences($userId = null)
|
||||||
{
|
{
|
||||||
// Get user ID from parameter or session
|
$sessionUserId = (int) (session()->get('user_id') ?? 0);
|
||||||
$userId = $userId ?? (int) session()->get('user_id');
|
if ($sessionUserId <= 0) {
|
||||||
|
|
||||||
if (!$userId) {
|
|
||||||
return redirect()->to('/login')->with('error', 'Please log in to update preferences');
|
return redirect()->to('/login')->with('error', 'Please log in to update preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($userId ?? $sessionUserId);
|
||||||
|
if (! $this->canAccessUserPreferences($userId, $sessionUserId)) {
|
||||||
|
return redirect()->to('/access_denied');
|
||||||
|
}
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
$validation = \Config\Services::validation();
|
$validation = \Config\Services::validation();
|
||||||
|
|
||||||
@@ -141,4 +147,21 @@ class PreferencesController extends BaseController
|
|||||||
// Redirect back to preferences page with success message
|
// Redirect back to preferences page with success message
|
||||||
return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
|
return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canAccessUserPreferences(int $requestedUserId, int $sessionUserId): bool
|
||||||
|
{
|
||||||
|
if ($requestedUserId <= 0 || $sessionUserId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($requestedUserId === $sessionUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = array_map(
|
||||||
|
static fn ($role): string => strtolower(trim((string) $role)),
|
||||||
|
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user