From e957f190ff45a46b358b56f7b735b600a472bd7c Mon Sep 17 00:00:00 2001 From: drumhead39 Date: Tue, 21 Jul 2026 13:56:55 -0400 Subject: [PATCH 1/2] Prepare public open-source release --- .github/ISSUE_TEMPLATE/bug_report.yml | 50 + .github/ISSUE_TEMPLATE/config.yml | 6 + .github/ISSUE_TEMPLATE/feature_request.yml | 30 + .github/dependabot.yml | 7 + .github/pull_request_template.md | 16 + .github/workflows/php-syntax.yml | 17 +- .gitignore | 3 +- .htaccess | 20 +- CHANGELOG.md | 24 + CODE_OF_CONDUCT.md | 22 + CONTRIBUTING.md | 28 + LICENSE | 674 ++++++++++++++ README-CSS-MENU-PATCH.txt | 36 +- README.md | 139 +-- SECURITY.md | 24 +- app/Controllers/AdminController.php | 819 ++++++++-------- app/Controllers/AuthController.php | 87 +- app/Controllers/DebugController.php | 67 +- app/Controllers/EmployeeController.php | 319 +++---- app/Controllers/ProviderController.php | 874 +++++++++--------- app/Controllers/SettingsController.php | 263 +++--- app/Core/Auth.php | 119 +-- app/Core/Config.php | 34 +- app/Core/Csrf.php | 54 +- app/Core/DB.php | 69 +- app/Core/Router.php | 64 +- app/Core/View.php | 26 +- app/Models/AppointmentType.php | 156 ++-- app/Models/PayPeriod.php | 92 +- app/Models/Provider.php | 110 +-- app/Models/ProviderProduction.php | 180 ++-- app/Models/ProviderPto.php | 184 ++-- app/Models/ProviderRate.php | 140 +-- app/Models/Settings.php | 100 +- app/Models/TimeEntry.php | 56 +- app/Models/Timecard.php | 166 ++-- app/Models/User.php | 124 +-- app/Services/LoginThrottle.php | 57 ++ app/Services/MailerService.php | 64 +- app/Services/ReportService.php | 600 ++++++------ app/Services/TimeService.php | 222 +++-- app/Views/admin/appointment_types.php | 270 +++--- app/Views/admin/dashboard.php | 218 ++--- app/Views/admin/employee_edit.php | 220 ++--- app/Views/admin/employees.php | 70 +- app/Views/admin/provider_edit.php | 132 +-- app/Views/admin/provider_production.php | 128 +-- app/Views/admin/provider_production_edit.php | 386 ++++---- app/Views/admin/provider_rates.php | 226 ++--- app/Views/admin/providers.php | 92 +- app/Views/admin/reports.php | 190 ++-- app/Views/admin/settings.php | 256 ++--- app/Views/admin/timecard_edit.php | 632 ++++++------- app/Views/admin/timecards.php | 276 +++--- app/Views/auth/login.php | 70 +- app/Views/employee/dashboard.php | 88 +- app/Views/employee/timecard.php | 652 ++++++------- app/Views/layout.php | 170 ++-- app/bootstrap.php | 326 +++---- app/config.sample.php | 38 +- app/helpers.php | 490 +++++----- bin/create-admin.php | 59 ++ cron/ensure_pay_period.php | 58 +- .../2026-01-12_add_provider_pto.sql | 38 +- .../2026-01-12_add_weight_loss_consultant.sql | 12 +- .../2026-03-27_add_nursing_encounters.sql | 8 +- database/schema.sql | 279 ++++-- public/.htaccess | 34 +- public/DFC Circle Logo - clock.svg | 35 - public/android-chrome-192x192.png | Bin 17390 -> 0 bytes public/android-chrome-512x512.png | Bin 50835 -> 0 bytes public/apple-touch-icon.png | Bin 16063 -> 0 bytes public/assets/css/app.css | 657 ++++++------- public/favicon-16x16.png | Bin 634 -> 0 bytes public/favicon-32x32.png | Bin 1522 -> 0 bytes public/favicon-512x512.png | Bin 50835 -> 0 bytes public/favicon.ico | Bin 638 -> 0 bytes public/icon.svg | 7 + public/index.php | 184 ++-- public/site.webmanifest | 7 +- storage/.htaccess | 6 + storage/logs/.htaccess | 7 +- storage/rate-limits/.gitkeep | 1 + storage/reports/.htaccess | 7 +- tests/TimeServiceTest.php | 52 ++ 85 files changed, 6922 insertions(+), 5601 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 app/Services/LoginThrottle.php create mode 100644 bin/create-admin.php delete mode 100644 public/DFC Circle Logo - clock.svg delete mode 100644 public/android-chrome-192x192.png delete mode 100644 public/android-chrome-512x512.png delete mode 100644 public/apple-touch-icon.png delete mode 100644 public/favicon-16x16.png delete mode 100644 public/favicon-32x32.png delete mode 100644 public/favicon-512x512.png delete mode 100644 public/favicon.ico create mode 100644 public/icon.svg create mode 100644 storage/.htaccess create mode 100644 storage/rate-limits/.gitkeep create mode 100644 tests/TimeServiceTest.php diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..78c6b24 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: Bug report +description: Report a reproducible problem without including private employee or server data. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: "Do not include credentials, employee information, production reports, or unsanitized logs. Report vulnerabilities through the Security tab." + - type: input + id: version + attributes: + label: TimeClock Pro version + placeholder: "For example: 1.0.0 or a commit SHA" + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: Include PHP, database, web-server, and browser versions. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Provide the smallest sanitized sequence that reproduces the issue. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: checkboxes + id: privacy + attributes: + label: Privacy confirmation + options: + - label: I removed credentials, employee information, reports, and other production data. + required: true + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fecbec4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/drumhead39/Timeclock-pro/security/advisories/new + about: Report vulnerabilities privately instead of opening a public issue. + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..ec4a3f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest an improvement for TimeClock Pro. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem or need + description: Explain the workflow problem this feature would solve. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: contribution + attributes: + label: Contribution + options: + - label: I may be willing to help implement this feature. + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d47a49f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2d2cb89 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + +Describe the purpose and scope of this change. + +## Verification + +Explain how the change was tested. + +## Checklist + +- [ ] PHP syntax checks pass. +- [ ] Tests and documentation were updated where needed. +- [ ] Authentication, authorization, and CSRF behavior were reviewed. +- [ ] No credentials, employee information, logs, sessions, reports, or database exports are included. +- [ ] The change is compatible with the documented PHP version. + diff --git a/.github/workflows/php-syntax.yml b/.github/workflows/php-syntax.yml index d30f098..1857e11 100644 --- a/.github/workflows/php-syntax.yml +++ b/.github/workflows/php-syntax.yml @@ -1,4 +1,4 @@ -name: PHP syntax check +name: PHP checks on: push: @@ -8,7 +8,7 @@ permissions: contents: read jobs: - lint: + test: runs-on: ubuntu-latest steps: - name: Check out repository @@ -22,3 +22,16 @@ jobs: - name: Check PHP syntax run: find . -type f -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l + - name: Run regression tests + run: php tests/TimeServiceTest.php + + - name: Check repository hygiene + shell: bash + run: | + forbidden="$(git ls-files | grep -E '^(app/config\.php|public/(boot-test|diag|phpver)\.php|public/error_log|storage/DEBUG_ON|storage/.*\.(log|pdf)|storage/sessions/sess_)' || true)" + if [ -n "$forbidden" ]; then + echo "Production-only files were committed:" + echo "$forbidden" + exit 1 + fi + diff --git a/.gitignore b/.gitignore index 8cf718d..d67b72d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ !/storage/reports/.gitkeep /storage/sessions/* !/storage/sessions/.gitkeep +/storage/rate-limits/* +!/storage/rate-limits/.gitkeep # Public diagnostic files and server logs /public/boot-test.php @@ -27,4 +29,3 @@ Thumbs.db .idea/ .vscode/ - diff --git a/.htaccess b/.htaccess index 4aadebf..f86c4c8 100644 --- a/.htaccess +++ b/.htaccess @@ -1,8 +1,12 @@ -# If you place this entire folder under a web-accessible directory (e.g., public_html/timeclock-pro), -# this helps keep private folders from being served. - - RewriteEngine On - - -# Deny direct access to app, database, and storage folders if misconfigured -RedirectMatch 403 ^/(app|database|storage|cron)(/|$) +# If you place this entire folder under a web-accessible directory (e.g., public_html/timeclock-pro), +# this helps keep private folders from being served. + + RewriteEngine On + RewriteRule ^(?:app|database|storage|cron|bin)(?:/|$) - [F,L,NC] + + + + + Require all denied + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7a9374c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +Notable changes to TimeClock Pro are documented here. + +## Unreleased + +### Added + +- Complete fresh-install database schema +- Command-line first-administrator setup +- Login throttling +- Open-source contribution and security documentation +- Generic project icon and GitHub community templates +- Regression checks for time-entry validation and repository hygiene + +### Security + +- Restricted the all-employee timecard overview to administrators +- Added session ID and CSRF token rotation after login +- Changed logout to a CSRF-protected POST request +- Added strict time-entry date and time validation +- Hid server filesystem paths from production error pages +- Strengthened private-directory and security-header protection + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1a7b4bb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,22 @@ +# Code of Conduct + +## Our commitment + +We are committed to providing a welcoming, respectful, and harassment-free project environment for everyone, regardless of experience, identity, background, or ability. + +## Expected behavior + +- Be respectful and constructive. +- Focus criticism on ideas and code, not people. +- Welcome questions and different experience levels. +- Protect private information shared while diagnosing problems. +- Accept moderation decisions intended to keep the project safe and productive. + +## Unacceptable behavior + +Harassment, threats, discrimination, deliberate intimidation, publishing private information, and sustained disruptive conduct are not acceptable. + +## Enforcement + +Project maintainers may edit or remove contributions and may temporarily or permanently restrict participation when this code is violated. Concerns should be reported privately to the repository owner. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d07f1a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing to TimeClock Pro + +Thank you for helping improve TimeClock Pro. + +## Before opening an issue + +- Search existing issues for the same problem or request. +- Confirm the problem still occurs on the latest release or `main` branch. +- Remove employee names, email addresses, credentials, server paths, and production data from screenshots and logs. +- Report security vulnerabilities privately according to `SECURITY.md`. + +## Bug reports + +Include the PHP version, MySQL or MariaDB version, hosting environment, relevant steps, expected behavior, actual behavior, and sanitized error output. + +## Pull requests + +1. Create a focused branch from `main`. +2. Keep the change limited to one concern. +3. Preserve compatibility with PHP 8.0 unless a version change has been discussed. +4. Use prepared database statements for all user-controlled values. +5. Require authentication, authorization, and CSRF checks for sensitive actions. +6. Add or update tests and documentation when behavior changes. +7. Confirm that no configuration, employee data, reports, logs, or session files are included. +8. Describe the change and its verification in the pull request. + +By contributing, you agree that your contribution will be licensed under GPLv3. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README-CSS-MENU-PATCH.txt b/README-CSS-MENU-PATCH.txt index 7a60053..c864b07 100644 --- a/README-CSS-MENU-PATCH.txt +++ b/README-CSS-MENU-PATCH.txt @@ -1,18 +1,18 @@ -TimeClock Pro patch - Admin dropdown / Time Cards layout / mobile cleanup - -Upload these files over the existing files in your live timeclock-pro folder: - -app/Views/layout.php -app/Views/admin/timecards.php -app/Controllers/AdminController.php -app/Controllers/ProviderController.php -public/assets/css/app.css - -Changes: -- Admin menu changed to a real click-to-open dropdown using
/. -- CSS cache-busting was added to the stylesheet link. -- Time Cards Employees and Providers sections now stack vertically on desktop and mobile. -- Provider production access is now super-user-only; no provider name matching. -- Added extra mobile CSS cleanup for nav/dropdown/table wrapping. - -No database change required. +TimeClock Pro patch - Admin dropdown / Time Cards layout / mobile cleanup + +Upload these files over the existing files in your live timeclock-pro folder: + +app/Views/layout.php +app/Views/admin/timecards.php +app/Controllers/AdminController.php +app/Controllers/ProviderController.php +public/assets/css/app.css + +Changes: +- Admin menu changed to a real click-to-open dropdown using
/. +- CSS cache-busting was added to the stylesheet link. +- Time Cards Employees and Providers sections now stack vertically on desktop and mobile. +- Provider production access is now super-user-only; no provider name matching. +- Added extra mobile CSS cleanup for nav/dropdown/table wrapping. + +No database change required. diff --git a/README.md b/README.md index ea3c151..1cc6f42 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,144 @@ # TimeClock Pro -TimeClock Pro is a lightweight PHP and MySQL application for biweekly employee timecards on shared hosting. +[![PHP syntax check](https://github.com/drumhead39/Timeclock-pro/actions/workflows/php-syntax.yml/badge.svg)](https://github.com/drumhead39/Timeclock-pro/actions/workflows/php-syntax.yml) -> This application handles employee and payroll-related information. Keep the GitHub repository private and never commit production credentials, session files, logs, database exports, or generated timecard PDFs. +TimeClock Pro is a self-hosted PHP and MySQL application for biweekly employee timecards, PTO tracking, pay-period approvals, payroll PDF reports, and optional provider-production reporting. It is designed for small clinics and service businesses using Apache-based shared hosting. ## Features ### Employees -- Email and password login -- Mobile-friendly timecard for the current pay period -- Configurable time increments and rounding -- PTO balance, usage, and availability -- Final submission with confirmation -- Optional per-pay-period production and encounter tracking +- Secure email and password authentication +- Mobile-friendly time entry +- Configurable increments and rounding +- PTO balance and usage display +- Final submission with locking controls +- Optional per-pay-period sales or encounter counters ### Administrators - Employee and provider management -- Timecard review, editing, submission, and locking -- Versioned timecard and rounding preferences -- Pay-period controls +- Timecard review, correction, submission, and locking +- Versioned timecard preferences +- Provider rates and production totals - PDF payroll report generation and email delivery -- Provider production and rate reporting +- Pay-period selection and historical reporting -## Requirements - -- PHP 8.0 or newer; PHP 8.1 or newer is recommended -- MySQL or MariaDB -- Apache with `mod_rewrite`, or equivalent rewrite support -- HTTPS -- PHP write access to `storage/logs/`, `storage/reports/`, and `storage/sessions/` +## Security and privacy -## Repository safety +TimeClock Pro handles employee and payroll-related information. Production credentials, sessions, logs, database exports, and generated reports must never be committed to Git. -The included `.gitignore` intentionally excludes: +The repository includes protection for these files, but administrators remain responsible for HTTPS, server permissions, backups, access controls, and applicable privacy or employment-law requirements. Review [SECURITY.md](SECURITY.md) before deployment. -- `app/config.php` -- Debug flags and temporary diagnostic scripts -- PHP and application logs -- Session files -- Generated payroll PDFs -- Environment files and database credentials +## Requirements -Use `app/config.sample.php` as the template for each installation. Do not rename or remove the sample file from the repository. +- PHP 8.0 or newer; PHP 8.1 or newer is recommended +- MySQL 5.7+ or MariaDB 10.3+ +- Apache with `mod_rewrite`, or an equivalent web-server configuration +- HTTPS +- PHP `PDO` and `pdo_mysql` +- PHP write access to the required `storage/` subdirectories ## Installation -### 1. Choose the web root +### 1. Download the source -Recommended: upload the project outside `public_html` and point the domain or subdomain document root to the project's `public/` directory. +Clone the repository or download a release archive. Place the project outside the public web root whenever the hosting provider permits it. -For a subfolder installation on shared hosting, upload the entire project to a folder such as `public_html/timeclock-pro/` and access the application through its `public/` subdirectory. Keep the root `.htaccess` file in place so private folders cannot be served directly. +```bash +git clone https://github.com/drumhead39/Timeclock-pro.git +cd Timeclock-pro +``` ### 2. Create the database -Create a MySQL database and database user, grant the user the required privileges, and import: +Create an empty MySQL or MariaDB database and database user. Grant that user the required privileges, then import: ```text database/schema.sql ``` -For an existing installation, apply only the migration files that have not already been run. +The fresh-install schema creates the complete table structure and default application settings. It does not create a default administrator. -### 3. Create the local configuration +### 3. Configure the application -On the server, copy: - -```text -app/config.sample.php -``` +Copy the sample configuration on the server: -to: - -```text -app/config.php +```bash +cp app/config.sample.php app/config.php ``` -Then update the database name, username, password, base URL, timezone, and debug setting in `app/config.php`. This file is ignored by Git and must remain server-only. +Edit `app/config.php` and supply the database name, username, password, base URL, and timezone. Keep debug mode disabled in production. The real configuration file is ignored by Git. ### 4. Create the first administrator -The initial database schema does not create a default administrator. Generate a password hash with PHP: +From the project directory, run: ```bash -php -r "echo password_hash('REPLACE_WITH_A_STRONG_PASSWORD', PASSWORD_DEFAULT), PHP_EOL;" +php bin/create-admin.php "Administrator Name" admin@example.com ``` -Insert the first user into the `users` table with the generated hash and the role `super`. After login, additional employees can be created through the administrator area. +The command will request a password containing at least 12 characters. To avoid an interactive prompt, the password may be supplied temporarily through `TIMECLOCK_ADMIN_PASSWORD`. + +### 5. Configure the document root + +Point the domain or subdomain document root to the project's `public/` directory. This is the recommended deployment arrangement because application code, configuration, database scripts, and runtime data remain outside the web root. + +If the entire project must be placed under a web-accessible shared-hosting directory, keep both `.htaccess` files in place and confirm that direct requests to `app/`, `database/`, `storage/`, `cron/`, and `bin/` return HTTP 403. -### 5. Set writable directories +### 6. Set writable directories -Ensure PHP can write to: +PHP must be able to write to: ```text storage/logs/ +storage/rate-limits/ storage/reports/ storage/sessions/ ``` -On shared hosting, directory permissions of `0775` are often appropriate, but follow the hosting provider's guidance. +Permissions of `0775` are commonly appropriate on shared hosting, but follow the provider's guidance and avoid world-writable permissions. ## Usage -- Employees sign in at `/login` and use `/timecard`. -- Administrators use `/admin` to manage employees, review timecards, and finalize pay periods. -- Finalized payroll reports are generated in `storage/reports/` and are intentionally excluded from Git. +- Employees sign in and use **My Timecard**. +- Administrators use **Time Cards** and the **Admin** menu. +- Generated payroll PDFs are stored in `storage/reports/` and excluded from Git. +- Application and PHP logs are stored in `storage/logs/` and excluded from Git. ## Optional cron job -To create upcoming pay periods automatically, run this daily with the correct server path: +Create upcoming pay periods automatically by running this command daily with the correct server path: ```bash php /full/path/to/timeclock-pro/cron/ensure_pay_period.php ``` -## Updating an existing installation +## Updating an installation -1. Back up the production files and database. -2. Preserve the server's `app/config.php` file. -3. Upload the updated tracked source files. -4. Run only the new SQL files in `database/migrations/`. -5. Verify login, time entry, totals, report generation, and email delivery. +1. Back up the production database and files. +2. Preserve the server-only `app/config.php`. +3. Upload the new tracked source files. +4. Apply only the new SQL files under `database/migrations/`. +5. Verify login, time entry, PTO totals, report generation, and email delivery. -Never overwrite production runtime data with files from GitHub. +Never replace production runtime data with files from a source archive. + +## Development checks + +Run the lightweight regression test with: + +```bash +php tests/TimeServiceTest.php +``` -## Debugging +GitHub Actions checks every PHP file for syntax errors, runs the regression test, and confirms that known production-only files have not been committed. -For temporary server troubleshooting, create the empty file `storage/DEBUG_ON`. Delete it immediately after troubleshooting. Debug mode can also be controlled by the `app.debug` setting in the server-only `app/config.php`. +## Contributing -Logs are written under `storage/logs/`. They may contain server paths or other sensitive context and must not be committed. +Bug reports and contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request. Security vulnerabilities must be reported privately according to [SECURITY.md](SECURITY.md). -## Security +## License -See [SECURITY.md](SECURITY.md) for repository and deployment safeguards. +TimeClock Pro is free software released under the [GNU General Public License version 3](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 667d324..1ac2038 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,19 +1,29 @@ # Security Policy -TimeClock Pro stores employee and payroll-related information. Keep production credentials, session files, logs, generated reports, and database exports out of this repository. +TimeClock Pro stores employee and payroll-related information. Security and privacy reports are taken seriously. + +## Supported versions + +Security fixes are applied to the latest release and the current `main` branch. Older versions may no longer receive patches. ## Reporting a vulnerability -Do not open a public issue containing credentials, employee information, screenshots of production data, or exploit details. Report security concerns privately to the repository owner. +Do not open a public issue containing credentials, employee information, screenshots of production data, exploit instructions, or vulnerability details. + +Use the repository's **Security** tab to submit a private vulnerability report. Include the affected version, reproduction steps, potential impact, and any suggested mitigation. Please allow a reasonable period for investigation before public disclosure. ## Deployment safeguards -- Keep the repository private unless the code has been independently reviewed for public release. -- Serve the application over HTTPS. +- Use HTTPS and redirect all HTTP traffic to HTTPS. - Point the web root to `public/` whenever possible. -- Copy `app/config.sample.php` to `app/config.php` only on the server and never commit the resulting file. +- Keep `app/config.php`, database exports, reports, logs, and sessions out of Git. - Disable debug mode in production. - Remove temporary diagnostic scripts after troubleshooting. -- Restrict write access to the required `storage/` directories. -- Back up the database and generated reports outside the Git repository. +- Use a dedicated database user with only the privileges the application needs. +- Restrict filesystem write access to the required `storage/` directories. +- Maintain encrypted, tested backups outside the Git repository. +- Review user accounts promptly when staff access changes. +- Add web-server or firewall rate limiting when the application is internet-accessible. + +The project is provided without warranty. Each operator is responsible for evaluating whether a deployment satisfies applicable privacy, payroll, employment, and data-retention requirements. diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 55cff94..e13a8ec 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -1,398 +1,421 @@ - 'Employees', - 'user' => Auth::user(), - 'employees' => User::all(), - ]); - } - - public static function employeeEdit(array $params): void { - Auth::requireRole('super'); - $id = (int)($params['id'] ?? 0); - $emp = $id ? User::findById($id) : null; - View::render('admin/employee_edit', [ - 'title' => $id ? 'Edit Employee' : 'Add Employee', - 'user' => Auth::user(), - 'emp' => $emp, - ]); - } - - public static function employeeSave(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $id = (int)($_POST['id'] ?? 0); - $full = trim((string)($_POST['full_name'] ?? '')); - $email = trim((string)($_POST['email'] ?? '')); - $role = ($_POST['role'] ?? 'employee') === 'super' ? 'super' : 'employee'; - $active = isset($_POST['active']) ? 1 : 0; - $wlc = isset($_POST['weight_loss_consultant']) ? 1 : 0; - $nec = isset($_POST['nursing_encounters_enabled']) ? 1 : 0; - $ptoBankHrs = (float)($_POST['pto_bank_hours'] ?? 0); - $ptoBankMin = (int)round(max(0, $ptoBankHrs) * 60); - - if ($full === '' || $email === '') { flash_set('error','Name and email required.'); redirect($id?"/admin/employees/$id":"\/admin/employees/new"); } - - if ($id) { - User::update($id, [ - 'full_name'=>$full,'email'=>$email,'role'=>$role,'active'=>$active,'weight_loss_consultant'=>$wlc,'nursing_encounters_enabled'=>$nec,'pto_bank_minutes'=>$ptoBankMin - ]); - } else { - $pw = (string)($_POST['password'] ?? ''); - if (strlen($pw) < 8) { flash_set('error','Password must be at least 8 characters.'); redirect('/admin/employees/new'); } - $hash = password_hash($pw, PASSWORD_DEFAULT); - User::create([ - 'full_name'=>$full,'email'=>$email,'role'=>$role,'active'=>$active,'weight_loss_consultant'=>$wlc,'nursing_encounters_enabled'=>$nec,'pto_bank_minutes'=>$ptoBankMin,'password_hash'=>$hash - ]); - } - flash_set('ok','Saved.'); - redirect('/admin/employees'); - } - - public static function employeeResetPassword(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $admin = Auth::user(); - $adminPass = (string)($_POST['admin_password'] ?? ''); - if (!password_verify($adminPass, $admin['password_hash'])) { - flash_set('error','Admin password incorrect.'); - redirect('/admin/employees'); - } - - $empId = (int)($_POST['emp_id'] ?? 0); - $emp = User::findById($empId); - if (!$emp) { flash_set('error','Employee not found.'); redirect('/admin/employees'); } - - $confirmEmail = trim((string)($_POST['confirm_email'] ?? '')); - if (strtolower($confirmEmail) !== strtolower($emp['email'])) { - flash_set('error','Confirmation email does not match.'); - redirect('/admin/employees/' . $empId); - } - - $newPw = (string)($_POST['new_password'] ?? ''); - if (strlen($newPw) < 8) { flash_set('error','New password must be at least 8 characters.'); redirect('/admin/employees/' . $empId); } - - User::setPassword($empId, password_hash($newPw, PASSWORD_DEFAULT)); - flash_set('ok','Password updated.'); - redirect('/admin/employees/' . $empId); - } - - public static function employeeDelete(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $admin = Auth::user(); - $adminPass = (string)($_POST['admin_password'] ?? ''); - if (!password_verify($adminPass, $admin['password_hash'])) { - flash_set('error','Admin password incorrect.'); - redirect('/admin/employees'); - } - - $empId = (int)($_POST['emp_id'] ?? 0); - $emp = User::findById($empId); - if (!$emp) { flash_set('error','Employee not found.'); redirect('/admin/employees'); } - - $confirm = trim((string)($_POST['confirm_text'] ?? '')); - if ($confirm !== 'DELETE') { - flash_set('error','Type DELETE to confirm hard delete.'); - redirect('/admin/employees/' . $empId); - } - - // Prevent deleting self - if ((int)$emp['id'] === (int)$admin['id']) { - flash_set('error','You cannot delete your own account.'); - redirect('/admin/employees/' . $empId); - } - - User::deleteHard($empId); - flash_set('ok','Employee deleted (hard delete).'); - redirect('/admin/employees'); - } - - public static function timecards(): void { - Auth::requireLogin(); - $u = Auth::user(); - $pp = self::getPayPeriodFromRequest(); - $employeeStatus = Timecard::statusForPayPeriod((int)$pp['id']); - $providerStatus = ProviderProduction::statusForPayPeriod((int)$pp['id']); - - $accessibleProviderIds = []; - - View::render('admin/timecards', [ - 'title' => 'Time Cards', - 'user' => $u, - 'payPeriod' => $pp, - 'recentPeriods' => self::listRecentPeriodsWithSelected($pp, 8), - 'employeeStatus' => $employeeStatus, - 'providerStatus' => $providerStatus, - 'accessibleProviderIds' => $accessibleProviderIds, - 'isSuper' => ($u && $u['role'] === 'super'), - ]); - } - - public static function timecardEdit(array $params): void { - Auth::requireRole('super'); - $empId = (int)($params['id'] ?? 0); - $emp = User::findById($empId); - if (!$emp) { http_response_code(404); echo "Employee not found."; return; } - - $pp = self::getPayPeriodFromRequest(); - $tc = Timecard::ensure($empId, (int)$pp['id']); - $sv = Settings::settingsVersion((int)$pp['settings_version_id']); - $cfg = $sv['config']; - - $entries = TimeEntry::byUserPeriod($empId, (int)$pp['id']); - - $daysToShow = $cfg['days_to_show'] ?? [1,2,3,4,5,6]; - $rows = []; - $d = new \DateTimeImmutable($pp['start_date']); - $endD = new \DateTimeImmutable($pp['end_date']); - while ($d <= $endD) { - $dow = (int)$d->format('N'); - if (in_array($dow, $daysToShow, true)) { - $date = $d->format('Y-m-d'); - $e = $entries[$date] ?? null; - $rows[] = [ - 'date'=>$date, - 'day_name'=>$d->format('l'), - 'time_in'=>$e['time_in'] ?? null, - 'time_out'=>$e['time_out'] ?? null, - 'minutes'=>$e ? TimeService::durationMinutes($e['time_in'], $e['time_out']) : 0, - ]; - } - $d = $d->modify('+1 day'); - } - $year = (int)(new \DateTimeImmutable('today'))->format('Y'); - $usedYtd = Timecard::sumPtoUsedYtd((int)$emp['id'], $year); - $bank = (int)$emp['pto_bank_minutes']; - $avail = max(0, $bank - $usedYtd); - - View::render('admin/timecard_edit', [ - 'title' => 'Edit Timecard', - 'user' => Auth::user(), - 'emp' => $emp, - 'payPeriod' => $pp, - 'pto_bank' => $bank, - 'pto_used_ytd' => $usedYtd, - 'pto_available' => $avail, - 'timecard' => $tc, - 'settings' => $cfg, - 'dayRows' => $rows, - ]); - } - - public static function timecardSave(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $empId = (int)($_POST['emp_id'] ?? 0); - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - - $pp = PayPeriod::findById($ppId); - if (!$pp) { http_response_code(400); echo "Invalid pay period."; return; } - - $tc = Timecard::ensure($empId, $ppId); - if (!empty($tc['locked_at'])) { flash_set('error','Timecard is locked.'); redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); } - - $sv = Settings::settingsVersion((int)$pp['settings_version_id']); - $cfg = $sv['config']; - - $rows = $_POST['rows'] ?? []; - foreach ($rows as $workDate => $vals) { - $tin = trim((string)($vals['in'] ?? '')); - $tout = trim((string)($vals['out'] ?? '')); - - $tin = $tin !== '' ? TimeService::roundTime($tin, $cfg) : null; - $tout = $tout !== '' ? TimeService::roundTime($tout, $cfg) : null; - - if ($tin === null && $tout === null) { - TimeEntry::deleteForDate($empId, $ppId, $workDate); - } else { - TimeEntry::upsert($empId, $ppId, $workDate, $tin, $tout); - } - } - - $ptoHrs = trim((string)($_POST['pto_hours'] ?? '')); - $ptoMin = 0; - if ($ptoHrs !== '') { - $ptoMin = (int)round(((float)$ptoHrs) * 60); - $inc = (int)($cfg['time_increment_minutes'] ?? 15); - if ($inc > 0) $ptoMin = (int)round($ptoMin / $inc) * $inc; - $ptoMin = max(0, $ptoMin); - } - - -$wlUnits = 0; -$nursingEncounters = 0; -$emp = User::findById($empId); -if ($emp && !empty($emp['weight_loss_consultant'])) { - $wlUnits = (int)($_POST['weight_loss_units'] ?? 0); - if ($wlUnits < 0) $wlUnits = 0; -} -if ($emp && !empty($emp['nursing_encounters_enabled'])) { - $nursingEncounters = (int)($_POST['nursing_encounters'] ?? 0); - if ($nursingEncounters < 0) $nursingEncounters = 0; -} - Timecard::update($empId, $ppId, ['pto_minutes' => $ptoMin, 'weight_loss_units' => $wlUnits, 'nursing_encounters' => $nursingEncounters]); - - flash_set('ok','Saved.'); - redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); - } - - public static function timecardLock(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - $empId = (int)($_POST['emp_id'] ?? 0); - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - // When an admin locks a card, treat it as "final" for the period. - Timecard::ensure($empId, $ppId); - Timecard::markSubmittedIfNull($empId, $ppId); - Timecard::setLocked($empId, $ppId, (int)Auth::user()['id']); - flash_set('ok','Locked.'); - redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); - } - - public static function timecardUnlock(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - $empId = (int)($_POST['emp_id'] ?? 0); - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - Timecard::setUnlocked($empId, $ppId); - flash_set('ok','Unlocked.'); - redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); - } - - private static function lockEverythingForReport(array $pp): void { - $ppId = (int)$pp['id']; - $adminId = (int)Auth::user()['id']; - - // Lock all employee timecards, including any corrected card that was unlocked for editing. - $users = DB::pdo()->query("SELECT id FROM users WHERE active=1")->fetchAll(); - foreach ($users as $r) { - $uid = (int)$r['id']; - Timecard::ensure($uid, $ppId); - // Treat an admin-locked card as final/submitted without overwriting an employee's original submit time. - Timecard::markSubmittedIfNull($uid, $ppId); - Timecard::setLocked($uid, $ppId, $adminId); - } - - // Lock provider production and PTO as well, so provider corrections are included in the regenerated report. - ProviderProduction::lockAllForPayPeriod($ppId, $adminId, (string)$pp['end_date']); - ProviderPto::lockAllForPayPeriod($ppId, $adminId); - - // Lock the pay period globally if it is not already locked. - PayPeriod::lock($ppId, $adminId); - } - - private static function generateEmailAndStoreReport(int $ppId, array $pp, bool $isUpdate): bool { - $report = ReportService::generatePayPeriodPdf($ppId, __DIR__ . '/../../storage/reports'); - - $range = \fmt_date($pp['start_date']) . " through " . \fmt_date($pp['end_date']); - $subject = ($isUpdate ? "UPDATED Timecard Report " : "Timecard Report ") . $range; - $body = $isUpdate - ? "Attached is the UPDATED timecard report for {$range}. This replaces the previously sent report for this pay period." - : "Attached is the timecard report for {$range}."; - - $ok = MailerService::sendWithAttachment( - $report['email_to'], - $subject, - $body, - $report['path'], - $report['filename'] - ); - - // The PDF filename is period-based, so ReportService overwrites the old PDF on disk. - // Keep the Reports table to one current row per pay period so downloads point to the latest file. - $pdo = DB::pdo(); - $pdo->prepare("DELETE FROM reports WHERE pay_period_id=?")->execute([$ppId]); - $pdo->prepare("INSERT INTO reports(pay_period_id,filename,filepath,created_at,emailed_to,email_status) - VALUES(?,?,?,?,?,?)")->execute([ - $ppId, - $report['filename'], - $report['path'], - date('Y-m-d H:i:s'), - $report['email_to'], - $ok ? ($isUpdate ? 'resent' : 'sent') : 'failed' - ]); - - return $ok; - } - - public static function lockGenerateReport(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - $pp = PayPeriod::findById($ppId); - if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards?pp=' . $ppId); } - - self::lockEverythingForReport($pp); - $ok = self::generateEmailAndStoreReport($ppId, $pp, false); - - flash_set('ok', $ok ? 'Pay period locked. PDF generated and emailed.' : 'Pay period locked. PDF generated, but email failed (check server mail settings).'); - redirect('/timecards?pp=' . $ppId); - } - - public static function regeneratePayPeriodReport(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - $pp = PayPeriod::findById($ppId); - if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards?pp=' . $ppId); } - - self::lockEverythingForReport($pp); - $ok = self::generateEmailAndStoreReport($ppId, $pp, true); - - flash_set('ok', $ok ? 'Updated PDF regenerated, stored, and emailed.' : 'Updated PDF regenerated and stored, but email failed (check server mail settings).'); - redirect('/timecards?pp=' . $ppId); - } -} + 'Employees', + 'user' => Auth::user(), + 'employees' => User::all(), + ]); + } + + public static function employeeEdit(array $params): void { + Auth::requireRole('super'); + $id = (int)($params['id'] ?? 0); + $emp = $id ? User::findById($id) : null; + View::render('admin/employee_edit', [ + 'title' => $id ? 'Edit Employee' : 'Add Employee', + 'user' => Auth::user(), + 'emp' => $emp, + ]); + } + + public static function employeeSave(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $id = (int)($_POST['id'] ?? 0); + $full = trim((string)($_POST['full_name'] ?? '')); + $email = trim((string)($_POST['email'] ?? '')); + $role = ($_POST['role'] ?? 'employee') === 'super' ? 'super' : 'employee'; + $active = isset($_POST['active']) ? 1 : 0; + $wlc = isset($_POST['weight_loss_consultant']) ? 1 : 0; + $nec = isset($_POST['nursing_encounters_enabled']) ? 1 : 0; + $ptoBankHrs = (float)($_POST['pto_bank_hours'] ?? 0); + $ptoBankMin = (int)round(max(0, $ptoBankHrs) * 60); + + if ($full === '' || strlen($full) > 190 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 190) { + flash_set('error','Enter a valid name and email address.'); + redirect($id?"/admin/employees/$id":"/admin/employees/new"); + } + $existing = User::findByEmail($email); + if ($existing && (int)$existing['id'] !== $id) { + flash_set('error','That email address is already in use.'); + redirect($id?"/admin/employees/$id":"/admin/employees/new"); + } + + if ($id) { + User::update($id, [ + 'full_name'=>$full,'email'=>$email,'role'=>$role,'active'=>$active,'weight_loss_consultant'=>$wlc,'nursing_encounters_enabled'=>$nec,'pto_bank_minutes'=>$ptoBankMin + ]); + } else { + $pw = (string)($_POST['password'] ?? ''); + if (strlen($pw) < 12 || strlen($pw) > 4096) { flash_set('error','Password must be between 12 and 4,096 characters.'); redirect('/admin/employees/new'); } + $hash = password_hash($pw, PASSWORD_DEFAULT); + User::create([ + 'full_name'=>$full,'email'=>$email,'role'=>$role,'active'=>$active,'weight_loss_consultant'=>$wlc,'nursing_encounters_enabled'=>$nec,'pto_bank_minutes'=>$ptoBankMin,'password_hash'=>$hash + ]); + } + flash_set('ok','Saved.'); + redirect('/admin/employees'); + } + + public static function employeeResetPassword(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $admin = Auth::user(); + $adminPass = (string)($_POST['admin_password'] ?? ''); + if (!password_verify($adminPass, $admin['password_hash'])) { + flash_set('error','Admin password incorrect.'); + redirect('/admin/employees'); + } + + $empId = (int)($_POST['emp_id'] ?? 0); + $emp = User::findById($empId); + if (!$emp) { flash_set('error','Employee not found.'); redirect('/admin/employees'); } + + $confirmEmail = trim((string)($_POST['confirm_email'] ?? '')); + if (strtolower($confirmEmail) !== strtolower($emp['email'])) { + flash_set('error','Confirmation email does not match.'); + redirect('/admin/employees/' . $empId); + } + + $newPw = (string)($_POST['new_password'] ?? ''); + if (strlen($newPw) < 12 || strlen($newPw) > 4096) { flash_set('error','New password must be between 12 and 4,096 characters.'); redirect('/admin/employees/' . $empId); } + + User::setPassword($empId, password_hash($newPw, PASSWORD_DEFAULT)); + flash_set('ok','Password updated.'); + redirect('/admin/employees/' . $empId); + } + + public static function employeeDelete(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $admin = Auth::user(); + $adminPass = (string)($_POST['admin_password'] ?? ''); + if (!password_verify($adminPass, $admin['password_hash'])) { + flash_set('error','Admin password incorrect.'); + redirect('/admin/employees'); + } + + $empId = (int)($_POST['emp_id'] ?? 0); + $emp = User::findById($empId); + if (!$emp) { flash_set('error','Employee not found.'); redirect('/admin/employees'); } + + $confirm = trim((string)($_POST['confirm_text'] ?? '')); + if ($confirm !== 'DELETE') { + flash_set('error','Type DELETE to confirm hard delete.'); + redirect('/admin/employees/' . $empId); + } + + // Prevent deleting self + if ((int)$emp['id'] === (int)$admin['id']) { + flash_set('error','You cannot delete your own account.'); + redirect('/admin/employees/' . $empId); + } + + User::deleteHard($empId); + flash_set('ok','Employee deleted (hard delete).'); + redirect('/admin/employees'); + } + + public static function timecards(): void { + Auth::requireRole('super'); + $u = Auth::user(); + $pp = self::getPayPeriodFromRequest(); + $employeeStatus = Timecard::statusForPayPeriod((int)$pp['id']); + $providerStatus = ProviderProduction::statusForPayPeriod((int)$pp['id']); + + $accessibleProviderIds = []; + + View::render('admin/timecards', [ + 'title' => 'Time Cards', + 'user' => $u, + 'payPeriod' => $pp, + 'recentPeriods' => self::listRecentPeriodsWithSelected($pp, 8), + 'employeeStatus' => $employeeStatus, + 'providerStatus' => $providerStatus, + 'accessibleProviderIds' => $accessibleProviderIds, + 'isSuper' => ($u && $u['role'] === 'super'), + ]); + } + + public static function timecardEdit(array $params): void { + Auth::requireRole('super'); + $empId = (int)($params['id'] ?? 0); + $emp = User::findById($empId); + if (!$emp) { http_response_code(404); echo "Employee not found."; return; } + + $pp = self::getPayPeriodFromRequest(); + $tc = Timecard::ensure($empId, (int)$pp['id']); + $sv = Settings::settingsVersion((int)$pp['settings_version_id']); + $cfg = $sv['config']; + + $entries = TimeEntry::byUserPeriod($empId, (int)$pp['id']); + + $daysToShow = $cfg['days_to_show'] ?? [1,2,3,4,5,6]; + $rows = []; + $d = new \DateTimeImmutable($pp['start_date']); + $endD = new \DateTimeImmutable($pp['end_date']); + while ($d <= $endD) { + $dow = (int)$d->format('N'); + if (in_array($dow, $daysToShow, true)) { + $date = $d->format('Y-m-d'); + $e = $entries[$date] ?? null; + $rows[] = [ + 'date'=>$date, + 'day_name'=>$d->format('l'), + 'time_in'=>$e['time_in'] ?? null, + 'time_out'=>$e['time_out'] ?? null, + 'minutes'=>$e ? TimeService::durationMinutes($e['time_in'], $e['time_out']) : 0, + ]; + } + $d = $d->modify('+1 day'); + } + $year = (int)substr((string)$pp['start_date'], 0, 4); + $usedYtd = Timecard::sumPtoUsedYtdThroughDate((int)$emp['id'], $year, (string)$pp['end_date']); + $bank = (int)$emp['pto_bank_minutes']; + $avail = max(0, $bank - $usedYtd); + + View::render('admin/timecard_edit', [ + 'title' => 'Edit Timecard', + 'user' => Auth::user(), + 'emp' => $emp, + 'payPeriod' => $pp, + 'pto_bank' => $bank, + 'pto_used_ytd' => $usedYtd, + 'pto_available' => $avail, + 'timecard' => $tc, + 'settings' => $cfg, + 'dayRows' => $rows, + ]); + } + + public static function timecardSave(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $empId = (int)($_POST['emp_id'] ?? 0); + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + + $pp = PayPeriod::findById($ppId); + if (!$pp) { http_response_code(400); echo "Invalid pay period."; return; } + $emp = User::findById($empId); + if (!$emp) { http_response_code(404); echo "Employee not found."; return; } + + $tc = Timecard::ensure($empId, $ppId); + if (!empty($tc['locked_at'])) { flash_set('error','Timecard is locked.'); redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); } + + $sv = Settings::settingsVersion((int)$pp['settings_version_id']); + $cfg = $sv['config']; + + try { + $rows = TimeService::normalizeRows((array)($_POST['rows'] ?? []), $pp, $cfg); + } catch (\InvalidArgumentException $e) { + flash_set('error', $e->getMessage()); + redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); + } + + foreach ($rows as $workDate => $vals) { + if ($vals['in'] === null && $vals['out'] === null) { + TimeEntry::deleteForDate($empId, $ppId, $workDate); + } else { + TimeEntry::upsert($empId, $ppId, $workDate, $vals['in'], $vals['out']); + } + } + + $ptoHrs = trim((string)($_POST['pto_hours'] ?? '')); + $ptoMin = 0; + if ($ptoHrs !== '') { + $ptoValue = (float)$ptoHrs; + if (!is_finite($ptoValue) || $ptoValue < 0) { flash_set('error','Enter a valid PTO amount.'); redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); } + $ptoMin = (int)round($ptoValue * 60); + $inc = (int)($cfg['time_increment_minutes'] ?? 15); + if ($inc > 0) $ptoMin = (int)round($ptoMin / $inc) * $inc; + $ptoMin = max(0, $ptoMin); + } + $ptoYear = (int)substr((string)$pp['start_date'], 0, 4); + $usedYtd = Timecard::sumPtoUsedYtdThroughDate($empId, $ptoYear, (string)$pp['end_date']); + $usedExcludingCurrent = max(0, $usedYtd - (int)($tc['pto_minutes'] ?? 0)); + $ptoMin = min($ptoMin, max(0, (int)$emp['pto_bank_minutes'] - $usedExcludingCurrent)); + + +$wlUnits = 0; +$nursingEncounters = 0; +if ($emp && !empty($emp['weight_loss_consultant'])) { + $wlUnits = (int)($_POST['weight_loss_units'] ?? 0); + $wlUnits = max(0, min(1000000, $wlUnits)); +} +if ($emp && !empty($emp['nursing_encounters_enabled'])) { + $nursingEncounters = (int)($_POST['nursing_encounters'] ?? 0); + $nursingEncounters = max(0, min(1000000, $nursingEncounters)); +} + Timecard::update($empId, $ppId, ['pto_minutes' => $ptoMin, 'weight_loss_units' => $wlUnits, 'nursing_encounters' => $nursingEncounters]); + + flash_set('ok','Saved.'); + redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); + } + + public static function timecardLock(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + $empId = (int)($_POST['emp_id'] ?? 0); + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + if (!User::findById($empId) || !PayPeriod::findById($ppId)) { + flash_set('error','Invalid employee or pay period.'); + redirect('/timecards'); + } + // When an admin locks a card, treat it as "final" for the period. + Timecard::ensure($empId, $ppId); + Timecard::markSubmittedIfNull($empId, $ppId); + Timecard::setLocked($empId, $ppId, (int)Auth::user()['id']); + flash_set('ok','Locked.'); + redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); + } + + public static function timecardUnlock(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + $empId = (int)($_POST['emp_id'] ?? 0); + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + if (!User::findById($empId) || !PayPeriod::findById($ppId)) { + flash_set('error','Invalid employee or pay period.'); + redirect('/timecards'); + } + Timecard::setUnlocked($empId, $ppId); + flash_set('ok','Unlocked.'); + redirect('/admin/timecards/' . $empId . '?pp=' . $ppId); + } + + private static function lockEverythingForReport(array $pp): void { + $ppId = (int)$pp['id']; + $adminId = (int)Auth::user()['id']; + + // Lock all employee timecards, including any corrected card that was unlocked for editing. + $users = DB::pdo()->query("SELECT id FROM users WHERE active=1")->fetchAll(); + foreach ($users as $r) { + $uid = (int)$r['id']; + Timecard::ensure($uid, $ppId); + // Treat an admin-locked card as final/submitted without overwriting an employee's original submit time. + Timecard::markSubmittedIfNull($uid, $ppId); + Timecard::setLocked($uid, $ppId, $adminId); + } + + // Lock provider production and PTO as well, so provider corrections are included in the regenerated report. + ProviderProduction::lockAllForPayPeriod($ppId, $adminId, (string)$pp['end_date']); + ProviderPto::lockAllForPayPeriod($ppId, $adminId); + + // Lock the pay period globally if it is not already locked. + PayPeriod::lock($ppId, $adminId); + } + + private static function generateEmailAndStoreReport(int $ppId, array $pp, bool $isUpdate): bool { + $report = ReportService::generatePayPeriodPdf($ppId, __DIR__ . '/../../storage/reports'); + + $range = \fmt_date($pp['start_date']) . " through " . \fmt_date($pp['end_date']); + $subject = ($isUpdate ? "UPDATED Timecard Report " : "Timecard Report ") . $range; + $body = $isUpdate + ? "Attached is the UPDATED timecard report for {$range}. This replaces the previously sent report for this pay period." + : "Attached is the timecard report for {$range}."; + + $ok = MailerService::sendWithAttachment( + $report['email_to'], + $subject, + $body, + $report['path'], + $report['filename'] + ); + + // The PDF filename is period-based, so ReportService overwrites the old PDF on disk. + // Keep the Reports table to one current row per pay period so downloads point to the latest file. + $pdo = DB::pdo(); + $pdo->prepare("DELETE FROM reports WHERE pay_period_id=?")->execute([$ppId]); + $pdo->prepare("INSERT INTO reports(pay_period_id,filename,filepath,created_at,emailed_to,email_status) + VALUES(?,?,?,?,?,?)")->execute([ + $ppId, + $report['filename'], + $report['path'], + date('Y-m-d H:i:s'), + $report['email_to'], + $ok ? ($isUpdate ? 'resent' : 'sent') : 'failed' + ]); + + return $ok; + } + + public static function lockGenerateReport(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + $pp = PayPeriod::findById($ppId); + if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards?pp=' . $ppId); } + + self::lockEverythingForReport($pp); + $ok = self::generateEmailAndStoreReport($ppId, $pp, false); + + flash_set('ok', $ok ? 'Pay period locked. PDF generated and emailed.' : 'Pay period locked. PDF generated, but email failed (check server mail settings).'); + redirect('/timecards?pp=' . $ppId); + } + + public static function regeneratePayPeriodReport(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + $pp = PayPeriod::findById($ppId); + if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards?pp=' . $ppId); } + + self::lockEverythingForReport($pp); + $ok = self::generateEmailAndStoreReport($ppId, $pp, true); + + flash_set('ok', $ok ? 'Updated PDF regenerated, stored, and emailed.' : 'Updated PDF regenerated and stored, but email failed (check server mail settings).'); + redirect('/timecards?pp=' . $ppId); + } +} diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 50491a5..dc7c199 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -1,33 +1,54 @@ - 'Login', - ]); - } - - public static function login(): void { - Csrf::check($_POST['_csrf'] ?? null); - $email = trim((string)($_POST['email'] ?? '')); - $pass = (string)($_POST['password'] ?? ''); - if (Auth::login($email, $pass)) { - redirect('/timecards'); - } - \flash_set('error', 'Invalid login.'); - redirect('/login'); - } - - public static function logout(): void { - Auth::logout(); - redirect('/login'); - } -} + 'Login', + ]); + } + + public static function login(): void { + Csrf::check($_POST['_csrf'] ?? null); + + if (LoginThrottle::tooManyAttempts()) { + \flash_set('error', 'Too many unsuccessful login attempts. Please wait 15 minutes and try again.'); + redirect('/login'); + } + + $email = trim((string)($_POST['email'] ?? '')); + $pass = (string)($_POST['password'] ?? ''); + if (strlen($email) > 190 || strlen($pass) > 4096) { + LoginThrottle::recordFailure(); + \flash_set('error', 'Invalid login.'); + redirect('/login'); + } + if (Auth::login($email, $pass)) { + LoginThrottle::clear(); + redirect(Auth::homePath()); + } + LoginThrottle::recordFailure(); + usleep(250000); + \flash_set('error', 'Invalid login.'); + redirect('/login'); + } + + public static function logout(): void { + Csrf::check($_POST['_csrf'] ?? null); + Auth::logout(); + redirect('/login'); + } + + public static function home(): void { + Auth::requireLogin(); + redirect(Auth::homePath()); + } +} diff --git a/app/Controllers/DebugController.php b/app/Controllers/DebugController.php index 86e09bb..7c23e2b 100644 --- a/app/Controllers/DebugController.php +++ b/app/Controllers/DebugController.php @@ -1,32 +1,35 @@ - $raw]; - - app_log('CLIENT', 'Browser error', [ - 'data' => $data, - 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', - 'uri' => $_SERVER['HTTP_REFERER'] ?? ($_SERVER['REQUEST_URI'] ?? ''), - ]); - - header('Content-Type: application/json'); - echo json_encode(['ok' => true, 'rid' => request_id()]); - } -} + $raw]; + + app_log('CLIENT', 'Browser error', [ + 'data' => $data, + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'uri' => $_SERVER['HTTP_REFERER'] ?? ($_SERVER['REQUEST_URI'] ?? ''), + ]); + + header('Content-Type: application/json'); + echo json_encode(['ok' => true, 'rid' => request_id()]); + } +} diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php index eb8302c..a48d426 100644 --- a/app/Controllers/EmployeeController.php +++ b/app/Controllers/EmployeeController.php @@ -1,155 +1,164 @@ -format('N'); - if (in_array($dow, $daysToShow, true)) { - $date = $d->format('Y-m-d'); - $e = $entries[$date] ?? null; - $dayRows[] = [ - 'date' => $date, - 'day_name' => $d->format('l'), - 'time_in' => $e['time_in'] ?? null, - 'time_out' => $e['time_out'] ?? null, - 'minutes' => $e ? TimeService::durationMinutes($e['time_in'], $e['time_out']) : 0, - ]; - } - $d = $d->modify('+1 day'); - } - - $year = (int)(new \DateTimeImmutable('today'))->format('Y'); - $usedYtd = Timecard::sumPtoUsedYtd((int)$u['id'], $year); - $bank = (int)$u['pto_bank_minutes']; - $avail = max(0, $bank - $usedYtd); - $periods = \recent_pay_periods_with_selected($pp, 8); - - View::render('employee/timecard', [ - 'title' => 'Timecard', - 'user' => $u, - 'payPeriod' => $pp, - 'periods' => $periods, - 'settings' => $cfg, - 'pto_bank' => $bank, - 'pto_used_ytd' => $usedYtd, - 'pto_available' => $avail, - 'timecard' => $tc, - 'dayRows' => $dayRows, - ]); - } - - public static function saveTimecard(): void { - Auth::requireLogin(); - $u = Auth::user(); - Csrf::check($_POST['_csrf'] ?? null); - - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $pp = PayPeriod::findById($ppId); - if (!$pp) { http_response_code(400); echo "Invalid pay period."; return; } - $_SESSION['selected_pay_period_id'] = $ppId; - - $tc = Timecard::ensure((int)$u['id'], (int)$ppId); - if (!empty($tc['locked_at'])) { flash_set('error','Timecard is locked.'); redirect('/timecard?pp=' . $ppId); } - - $sv = Settings::settingsVersion((int)$pp['settings_version_id']); - $cfg = $sv['config']; - - $rows = $_POST['rows'] ?? []; - foreach ($rows as $workDate => $vals) { - $tin = trim((string)($vals['in'] ?? '')); - $tout = trim((string)($vals['out'] ?? '')); - - $tin = $tin !== '' ? TimeService::roundTime($tin, $cfg) : null; - $tout = $tout !== '' ? TimeService::roundTime($tout, $cfg) : null; - - if ($tin === null && $tout === null) { - TimeEntry::deleteForDate((int)$u['id'], (int)$ppId, $workDate); - } else { - TimeEntry::upsert((int)$u['id'], (int)$ppId, $workDate, $tin, $tout); - } - } - - $ptoHrs = trim((string)($_POST['pto_hours'] ?? '')); - $ptoMin = 0; - if ($ptoHrs !== '') { - $f = (float)$ptoHrs; - $ptoMin = (int)round($f * 60); - $inc = (int)($cfg['time_increment_minutes'] ?? 15); - if ($inc > 0) $ptoMin = (int)round($ptoMin / $inc) * $inc; - $ptoMin = max(0, $ptoMin); - } - - $wlUnits = 0; - if (!empty($u['weight_loss_consultant'])) { - $wlUnits = (int)($_POST['weight_loss_units'] ?? 0); - if ($wlUnits < 0) $wlUnits = 0; - } - - $nursingEncounters = 0; - if (!empty($u['nursing_encounters_enabled'])) { - $nursingEncounters = (int)($_POST['nursing_encounters'] ?? 0); - if ($nursingEncounters < 0) $nursingEncounters = 0; - } - - Timecard::update((int)$u['id'], (int)$ppId, ['pto_minutes' => $ptoMin, 'weight_loss_units' => $wlUnits, 'nursing_encounters' => $nursingEncounters]); - - flash_set('ok', 'Saved.'); - redirect('/timecard?pp=' . $ppId); - } - - public static function submitTimecard(): void { - Auth::requireLogin(); - $u = Auth::user(); - Csrf::check($_POST['_csrf'] ?? null); - - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - $tc = Timecard::ensure((int)$u['id'], $ppId); - if (!empty($tc['locked_at'])) { flash_set('error','Timecard is locked.'); redirect('/timecard?pp=' . $ppId); } - - Timecard::setSubmitted((int)$u['id'], $ppId); - flash_set('ok', 'Submitted for approval.'); - redirect('/timecard?pp=' . $ppId); - } -} +format('N'); + if (in_array($dow, $daysToShow, true)) { + $date = $d->format('Y-m-d'); + $e = $entries[$date] ?? null; + $dayRows[] = [ + 'date' => $date, + 'day_name' => $d->format('l'), + 'time_in' => $e['time_in'] ?? null, + 'time_out' => $e['time_out'] ?? null, + 'minutes' => $e ? TimeService::durationMinutes($e['time_in'], $e['time_out']) : 0, + ]; + } + $d = $d->modify('+1 day'); + } + + $year = (int)substr((string)$pp['start_date'], 0, 4); + $usedYtd = Timecard::sumPtoUsedYtdThroughDate((int)$u['id'], $year, (string)$pp['end_date']); + $bank = (int)$u['pto_bank_minutes']; + $avail = max(0, $bank - $usedYtd); + $periods = \recent_pay_periods_with_selected($pp, 8); + + View::render('employee/timecard', [ + 'title' => 'Timecard', + 'user' => $u, + 'payPeriod' => $pp, + 'periods' => $periods, + 'settings' => $cfg, + 'pto_bank' => $bank, + 'pto_used_ytd' => $usedYtd, + 'pto_available' => $avail, + 'timecard' => $tc, + 'dayRows' => $dayRows, + ]); + } + + public static function saveTimecard(): void { + Auth::requireLogin(); + $u = Auth::user(); + Csrf::check($_POST['_csrf'] ?? null); + + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $pp = PayPeriod::findById($ppId); + if (!$pp) { http_response_code(400); echo "Invalid pay period."; return; } + if (!empty($pp['locked_at'])) { flash_set('error','This pay period is locked.'); redirect('/timecard?pp=' . $ppId); } + $_SESSION['selected_pay_period_id'] = $ppId; + + $tc = Timecard::ensure((int)$u['id'], (int)$ppId); + if (!empty($tc['locked_at'])) { flash_set('error','Timecard is locked.'); redirect('/timecard?pp=' . $ppId); } + + $sv = Settings::settingsVersion((int)$pp['settings_version_id']); + $cfg = $sv['config']; + + try { + $rows = TimeService::normalizeRows((array)($_POST['rows'] ?? []), $pp, $cfg); + } catch (\InvalidArgumentException $e) { + flash_set('error', $e->getMessage()); + redirect('/timecard?pp=' . $ppId); + } + + foreach ($rows as $workDate => $vals) { + if ($vals['in'] === null && $vals['out'] === null) { + TimeEntry::deleteForDate((int)$u['id'], (int)$ppId, $workDate); + } else { + TimeEntry::upsert((int)$u['id'], (int)$ppId, $workDate, $vals['in'], $vals['out']); + } + } + + $ptoHrs = trim((string)($_POST['pto_hours'] ?? '')); + $ptoMin = 0; + if ($ptoHrs !== '') { + $f = (float)$ptoHrs; + if (!is_finite($f) || $f < 0) { flash_set('error','Enter a valid PTO amount.'); redirect('/timecard?pp=' . $ppId); } + $ptoMin = (int)round($f * 60); + $inc = (int)($cfg['time_increment_minutes'] ?? 15); + if ($inc > 0) $ptoMin = (int)round($ptoMin / $inc) * $inc; + $ptoMin = max(0, $ptoMin); + } + $ptoYear = (int)substr((string)$pp['start_date'], 0, 4); + $usedYtd = Timecard::sumPtoUsedYtdThroughDate((int)$u['id'], $ptoYear, (string)$pp['end_date']); + $usedExcludingCurrent = max(0, $usedYtd - (int)($tc['pto_minutes'] ?? 0)); + $ptoMin = min($ptoMin, max(0, (int)$u['pto_bank_minutes'] - $usedExcludingCurrent)); + + $wlUnits = 0; + if (!empty($u['weight_loss_consultant'])) { + $wlUnits = (int)($_POST['weight_loss_units'] ?? 0); + $wlUnits = max(0, min(1000000, $wlUnits)); + } + + $nursingEncounters = 0; + if (!empty($u['nursing_encounters_enabled'])) { + $nursingEncounters = (int)($_POST['nursing_encounters'] ?? 0); + $nursingEncounters = max(0, min(1000000, $nursingEncounters)); + } + + Timecard::update((int)$u['id'], (int)$ppId, ['pto_minutes' => $ptoMin, 'weight_loss_units' => $wlUnits, 'nursing_encounters' => $nursingEncounters]); + + flash_set('ok', 'Saved.'); + redirect('/timecard?pp=' . $ppId); + } + + public static function submitTimecard(): void { + Auth::requireLogin(); + $u = Auth::user(); + Csrf::check($_POST['_csrf'] ?? null); + + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $pp = PayPeriod::findById($ppId); + if (!$pp) { http_response_code(400); echo "Invalid pay period."; return; } + if (!empty($pp['locked_at'])) { flash_set('error','This pay period is locked.'); redirect('/timecard?pp=' . $ppId); } + $_SESSION['selected_pay_period_id'] = $ppId; + $tc = Timecard::ensure((int)$u['id'], $ppId); + if (!empty($tc['locked_at'])) { flash_set('error','Timecard is locked.'); redirect('/timecard?pp=' . $ppId); } + + Timecard::setSubmitted((int)$u['id'], $ppId); + flash_set('ok', 'Submitted for approval.'); + redirect('/timecard?pp=' . $ppId); + } +} diff --git a/app/Controllers/ProviderController.php b/app/Controllers/ProviderController.php index 04db247..810ce43 100644 --- a/app/Controllers/ProviderController.php +++ b/app/Controllers/ProviderController.php @@ -1,425 +1,449 @@ -403

Forbidden

"; - exit; - } - } - - public static function providers(): void { - Auth::requireRole('super'); - View::render('admin/providers', [ - 'title' => 'Providers', - 'user' => Auth::user(), - 'providers' => Provider::all(true), - ]); - } - - public static function providerEdit(array $params): void { - Auth::requireRole('super'); - $id = (int)($params['id'] ?? 0); - $prov = $id ? Provider::findById($id) : null; - View::render('admin/provider_edit', [ - 'title' => $id ? 'Edit Provider' : 'Add Provider', - 'user' => Auth::user(), - 'prov' => $prov, - ]); - } - - public static function providerSave(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $id = (int)($_POST['id'] ?? 0); - $name = trim((string)($_POST['full_name'] ?? '')); - $ptype = (string)($_POST['provider_type'] ?? 'chiro'); - $ptype = in_array($ptype, ['chiro','massage'], true) ? $ptype : 'chiro'; - $active = isset($_POST['active']) ? 1 : 0; - - // Provider PTO bank (hours, decimal). Stored as minutes. - $ptoBankHours = (string)($_POST['pto_bank_hours'] ?? ''); - $ptoBankHours = trim($ptoBankHours); - $ptoBankMin = 0; - if ($ptoBankHours !== '') { - $h = (float)$ptoBankHours; - if ($h < 0) $h = 0; - // keep quarter-hour granularity - $ptoBankMin = (int)round($h * 60); - } - - if ($name === '') { - flash_set('error','Provider name is required.'); - redirect('/admin/providers'); - } - - if ($id) { - Provider::update($id, ['full_name'=>$name,'provider_type'=>$ptype,'active'=>$active,'pto_bank_minutes'=>$ptoBankMin]); - } else { - Provider::create(['full_name'=>$name,'provider_type'=>$ptype,'active'=>$active,'pto_bank_minutes'=>$ptoBankMin]); - } - - flash_set('ok','Saved.'); - redirect('/admin/providers'); - } - - public static function appointmentTypes(): void { - Auth::requireRole('super'); - - $editId = (int)($_GET['edit_id'] ?? 0); - $editType = $editId > 0 ? AppointmentType::findById($editId) : null; - - View::render('admin/appointment_types', [ - 'title' => 'Appointment Types', - 'user' => Auth::user(), - 'types_chiro' => AppointmentType::listByProviderType('chiro', true), - 'types_massage' => AppointmentType::listByProviderType('massage', true), - 'editType' => $editType, - ]); -} - - public static function appointmentTypeSave(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $id = (int)($_POST['id'] ?? 0); - $ptype = (string)($_POST['provider_type'] ?? 'chiro'); - $ptype = in_array($ptype, ['chiro','massage'], true) ? $ptype : 'chiro'; - $name = trim((string)($_POST['name'] ?? '')); - $sort = (int)($_POST['sort_order'] ?? 0); - $active = isset($_POST['active']) ? 1 : 0; - - // Provider PTO bank (hours, decimal). Stored as minutes. - $ptoBankHours = (string)($_POST['pto_bank_hours'] ?? ''); - $ptoBankHours = trim($ptoBankHours); - $ptoBankMin = 0; - if ($ptoBankHours !== '') { - $h = (float)$ptoBankHours; - if ($h < 0) $h = 0; - // keep quarter-hour granularity - $ptoBankMin = (int)round($h * 60); - } - - if ($name === '') { - flash_set('error','Type name is required.'); - redirect('/admin/appointment-types'); - } - - if ($id) { - AppointmentType::update($id, ['provider_type'=>$ptype,'name'=>$name,'sort_order'=>$sort,'active'=>$active]); - } else { - AppointmentType::create(['provider_type'=>$ptype,'name'=>$name,'sort_order'=>$sort,'active'=>$active]); - } - - flash_set('ok','Saved.'); - redirect('/admin/appointment-types'); - } - - public static function appointmentTypeToggle(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $id = (int)($_POST['id'] ?? 0); - $active = (int)($_POST['active'] ?? 0) === 1 ? 1 : 0; - AppointmentType::setActive($id, $active); - flash_set('ok','Updated.'); - redirect('/admin/appointment-types'); - } - - -public static function appointmentTypeDelete(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $id = (int)($_POST['id'] ?? 0); - if ($id <= 0) { - flash_set('error','Invalid appointment type.'); - redirect('/admin/appointment-types'); - } - - if (!AppointmentType::canDelete($id)) { - flash_set('error','This appointment type is already in use (rates or production). Deactivate it instead of deleting.'); - redirect('/admin/appointment-types'); - } - - AppointmentType::delete($id); - flash_set('ok','Deleted.'); - redirect('/admin/appointment-types'); -} - - public static function providerRates(array $params): void { - Auth::requireRole('super'); - $providerId = (int)($params['id'] ?? 0); - $prov = Provider::findById($providerId); - if (!$prov) { http_response_code(404); echo "Provider not found."; return; } - - $types = AppointmentType::listByProviderType((string)$prov['provider_type'], false); - $rates = ProviderRate::listForProvider($providerId); - - View::render('admin/provider_rates', [ - 'title' => 'Provider Rates', - 'user' => Auth::user(), - 'prov' => $prov, - 'types' => $types, - 'rates' => $rates, - ]); - } - - public static function providerRateAdd(array $params): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - $providerId = (int)($params['id'] ?? 0); - $prov = Provider::findById($providerId); - if (!$prov) { flash_set('error','Provider not found.'); redirect('/admin/providers'); } - - $typeId = (int)($_POST['appointment_type_id'] ?? 0); - $rate = (float)($_POST['rate'] ?? 0); - $effFrom = (string)($_POST['effective_from'] ?? date('Y-m-d')); - $effTo = trim((string)($_POST['effective_to'] ?? '')); - $effTo = $effTo !== '' ? $effTo : null; - - if ($typeId <= 0) { flash_set('error','Select an appointment type.'); redirect('/admin/providers/' . $providerId . '/rates'); } - - ProviderRate::addRate($providerId, $typeId, $rate, $effFrom, $effTo); - flash_set('ok','Rate added (previous open rate auto-closed).'); - redirect('/admin/providers/' . $providerId . '/rates'); - } - - public static function providerRateEnd(array $params): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - $providerId = (int)($params['id'] ?? 0); - $rateId = (int)($_POST['rate_id'] ?? 0); - $effTo = trim((string)($_POST['effective_to'] ?? '')); - $effTo = $effTo !== '' ? $effTo : null; - ProviderRate::setEffectiveTo($rateId, $effTo); - flash_set('ok','Rate updated.'); - redirect('/admin/providers/' . $providerId . '/rates'); - } - - -public static function providerRateUpdate(array $params): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $providerId = (int)($params['id'] ?? 0); - $rateId = (int)($_POST['rate_id'] ?? 0); - $rate = (float)($_POST['rate'] ?? 0); - - if ($providerId <= 0 || $rateId <= 0) { - flash_set('error','Invalid rate.'); - redirect('/admin/providers/' . $providerId . '/rates'); - } - - if ($rate < 0) $rate = 0; - ProviderRate::updateRate($rateId, $rate); - - flash_set('ok','Rate updated.'); - redirect('/admin/providers/' . $providerId . '/rates'); -} - -public static function providerRateDelete(array $params): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $providerId = (int)($params['id'] ?? 0); - $rateId = (int)($_POST['rate_id'] ?? 0); - - if ($providerId <= 0 || $rateId <= 0) { - flash_set('error','Invalid rate.'); - redirect('/admin/providers/' . $providerId . '/rates'); - } - - ProviderRate::deleteRate($rateId); - - flash_set('ok','Rate deleted.'); - redirect('/admin/providers/' . $providerId . '/rates'); -} - - public static function production(): void { - Auth::requireRole('super'); - $pp = self::getPayPeriodFromRequest(); - $periods = self::recentPeriodsWithSelected($pp, 8); - - $status = ProviderProduction::statusForPayPeriod((int)$pp['id']); - - View::render('admin/provider_production', [ - 'title' => 'Provider Production', - 'user' => Auth::user(), - 'payPeriod' => $pp, - 'periods' => $periods, - 'status' => $status, - ]); - } - - public static function productionEdit(array $params): void { - Auth::requireLogin(); - - $providerId = (int)($params['id'] ?? 0); - self::requireProviderAccess($providerId); - $prov = Provider::findById($providerId); - if (!$prov) { http_response_code(404); echo "Provider not found."; return; } - - $pp = self::getPayPeriodFromRequest(); - $periods = self::recentPeriodsWithSelected($pp, 8); - - ProviderProduction::ensureRows($providerId, (int)$pp['id']); - $rows = ProviderProduction::rowsForProviderPeriod($providerId, (int)$pp['id']); - - // Resolve effective rates as-of pay period end date (used for preview if rate_used not set) - $asOf = (string)$pp['end_date']; - foreach ($rows as &$r) { - $eff = ProviderRate::effectiveRate($providerId, (int)$r['appointment_type_id'], $asOf); - $r['effective_rate'] = $eff !== null ? $eff : 0.00; - } - unset($r); - - - // Provider PTO for this pay period (admin-entered) - ProviderPto::ensureRow($providerId, (int)$pp['id']); - $ptoMinutes = ProviderPto::minutesForProviderPeriod($providerId, (int)$pp['id']); - $ptoBank = (int)($prov['pto_bank_minutes'] ?? 0); - $year = (int)substr((string)$pp['start_date'], 0, 4); - // YTD should be "as of" the selected pay period end date so future entries don't affect remaining. - $ptoUsedYtd = ProviderPto::sumUsedYtdThroughDate($providerId, $year, (string)$pp['end_date']); - $ptoAvail = max(0, $ptoBank - $ptoUsedYtd); - $ptoUsedExclCurrent = max(0, $ptoUsedYtd - $ptoMinutes); - View::render('admin/provider_production_edit', [ - 'title' => 'Edit Provider Production', - 'user' => Auth::user(), - 'prov' => $prov, - 'payPeriod' => $pp, - 'periods' => $periods, - 'rows' => $rows, - 'pto_bank' => $ptoBank, - 'pto_used_ytd' => $ptoUsedYtd, - 'pto_available' => $ptoAvail, - 'pto_minutes' => $ptoMinutes, - 'pto_used_excl_current' => $ptoUsedExclCurrent, - 'isSuper' => (Auth::user() && Auth::user()['role'] === 'super'), - 'backUrl' => '/timecards?pp=' . (string)$pp['id'], - ]); - } - - public static function productionSave(): void { - Auth::requireLogin(); - Csrf::check($_POST['_csrf'] ?? null); - - $providerId = (int)($_POST['provider_id'] ?? 0); - self::requireProviderAccess($providerId); - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - $pp = PayPeriod::findById($ppId); - if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards'); } - - ProviderProduction::ensureRows($providerId, $ppId); - $rows = ProviderProduction::rowsForProviderPeriod($providerId, $ppId); - // If locked, do not save - $locked = false; - foreach ($rows as $r) { if (!empty($r['locked_at'])) { $locked = true; break; } } - if ($locked) { - flash_set('error','This provider production card is locked.'); - redirect('/provider-production/' . $providerId . '?pp=' . $ppId); - } - - $counts = $_POST['counts'] ?? []; - ProviderProduction::saveCounts($providerId, $ppId, $counts); - - if (Auth::user()['role'] === 'super') { - // Provider PTO (hours) for this pay period (admin-entered) - $ptoHours = trim((string)($_POST['pto_hours'] ?? '')); - $ptoMin = 0; - if ($ptoHours !== '') { - $h = (float)$ptoHours; - if ($h < 0) $h = 0; - $ptoMin = (int)round($h * 60); - } - - // Clamp to available bank if this provider accrues PTO - $prov = Provider::findById($providerId); - $ptoBank = (int)($prov['pto_bank_minutes'] ?? 0); - if ($ptoBank > 0) { - $year = (int)substr((string)$pp['start_date'], 0, 4); - // Compute YTD used through this pay period end date (ignore PTO entered on later pay periods). - $usedYtd = ProviderPto::sumUsedYtdThroughDate($providerId, $year, (string)$pp['end_date']); - $current = ProviderPto::minutesForProviderPeriod($providerId, $ppId); - $usedExcl = max(0, $usedYtd - $current); - $maxForPeriod = max(0, $ptoBank - $usedExcl); - if ($ptoMin > $maxForPeriod) $ptoMin = $maxForPeriod; - } - - ProviderPto::saveMinutes($providerId, $ppId, $ptoMin); - } - - flash_set('ok','Saved.'); - redirect('/provider-production/' . $providerId . '?pp=' . $ppId); - } - - public static function productionLock(): void { - Auth::requireLogin(); - Csrf::check($_POST['_csrf'] ?? null); - - $providerId = (int)($_POST['provider_id'] ?? 0); - self::requireProviderAccess($providerId); - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - $pp = PayPeriod::findById($ppId); - if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards'); } - - ProviderProduction::ensureRows($providerId, $ppId); - ProviderProduction::lockProviderPeriod($providerId, $ppId, (int)Auth::user()['id'], (string)$pp['end_date']); - - ProviderPto::lockProviderPeriod($providerId, $ppId, (int)Auth::user()['id']); - - flash_set('ok','Locked.'); - redirect('/provider-production/' . $providerId . '?pp=' . $ppId); - } - - public static function productionUnlock(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $providerId = (int)($_POST['provider_id'] ?? 0); - $ppId = (int)($_POST['pay_period_id'] ?? 0); - $_SESSION['selected_pay_period_id'] = $ppId; - ProviderProduction::unlockProviderPeriod($providerId, $ppId); - - ProviderPto::unlockProviderPeriod($providerId, $ppId); - - flash_set('ok','Unlocked.'); - redirect('/provider-production/' . $providerId . '?pp=' . $ppId); - } -} +format('Y-m-d') === $value; + } + + + private static function getPayPeriodFromRequest(): array { + return \selected_pay_period((int)($_GET["pp"] ?? 0)); + } + + private static function recentPeriodsWithSelected(array $selected, int $limit = 8): array { + return \recent_pay_periods_with_selected($selected, $limit); + } + + private static function canAccessProvider(int $providerId): bool { + Auth::requireLogin(); + $u = Auth::user(); + return $u && $u['role'] === 'super'; + } + + private static function requireProviderAccess(int $providerId): void { + if (!self::canAccessProvider($providerId)) { + http_response_code(403); + echo "

403

Forbidden

"; + exit; + } + } + + public static function providers(): void { + Auth::requireRole('super'); + View::render('admin/providers', [ + 'title' => 'Providers', + 'user' => Auth::user(), + 'providers' => Provider::all(true), + ]); + } + + public static function providerEdit(array $params): void { + Auth::requireRole('super'); + $id = (int)($params['id'] ?? 0); + $prov = $id ? Provider::findById($id) : null; + View::render('admin/provider_edit', [ + 'title' => $id ? 'Edit Provider' : 'Add Provider', + 'user' => Auth::user(), + 'prov' => $prov, + ]); + } + + public static function providerSave(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $id = (int)($_POST['id'] ?? 0); + $name = trim((string)($_POST['full_name'] ?? '')); + $ptype = (string)($_POST['provider_type'] ?? 'chiro'); + $ptype = in_array($ptype, ['chiro','massage'], true) ? $ptype : 'chiro'; + $active = isset($_POST['active']) ? 1 : 0; + + // Provider PTO bank (hours, decimal). Stored as minutes. + $ptoBankHours = (string)($_POST['pto_bank_hours'] ?? ''); + $ptoBankHours = trim($ptoBankHours); + $ptoBankMin = 0; + if ($ptoBankHours !== '') { + $h = (float)$ptoBankHours; + if ($h < 0) $h = 0; + // keep quarter-hour granularity + $ptoBankMin = (int)round($h * 60); + } + + if ($name === '' || strlen($name) > 190) { + flash_set('error','Provider name is required.'); + redirect('/admin/providers'); + } + + if ($id) { + Provider::update($id, ['full_name'=>$name,'provider_type'=>$ptype,'active'=>$active,'pto_bank_minutes'=>$ptoBankMin]); + } else { + Provider::create(['full_name'=>$name,'provider_type'=>$ptype,'active'=>$active,'pto_bank_minutes'=>$ptoBankMin]); + } + + flash_set('ok','Saved.'); + redirect('/admin/providers'); + } + + public static function appointmentTypes(): void { + Auth::requireRole('super'); + + $editId = (int)($_GET['edit_id'] ?? 0); + $editType = $editId > 0 ? AppointmentType::findById($editId) : null; + + View::render('admin/appointment_types', [ + 'title' => 'Appointment Types', + 'user' => Auth::user(), + 'types_chiro' => AppointmentType::listByProviderType('chiro', true), + 'types_massage' => AppointmentType::listByProviderType('massage', true), + 'editType' => $editType, + ]); +} + + public static function appointmentTypeSave(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $id = (int)($_POST['id'] ?? 0); + $ptype = (string)($_POST['provider_type'] ?? 'chiro'); + $ptype = in_array($ptype, ['chiro','massage'], true) ? $ptype : 'chiro'; + $name = trim((string)($_POST['name'] ?? '')); + $sort = (int)($_POST['sort_order'] ?? 0); + $active = isset($_POST['active']) ? 1 : 0; + + // Provider PTO bank (hours, decimal). Stored as minutes. + $ptoBankHours = (string)($_POST['pto_bank_hours'] ?? ''); + $ptoBankHours = trim($ptoBankHours); + $ptoBankMin = 0; + if ($ptoBankHours !== '') { + $h = (float)$ptoBankHours; + if ($h < 0) $h = 0; + // keep quarter-hour granularity + $ptoBankMin = (int)round($h * 60); + } + + if ($name === '' || strlen($name) > 190) { + flash_set('error','Type name is required.'); + redirect('/admin/appointment-types'); + } + + if ($id) { + AppointmentType::update($id, ['provider_type'=>$ptype,'name'=>$name,'sort_order'=>$sort,'active'=>$active]); + } else { + AppointmentType::create(['provider_type'=>$ptype,'name'=>$name,'sort_order'=>$sort,'active'=>$active]); + } + + flash_set('ok','Saved.'); + redirect('/admin/appointment-types'); + } + + public static function appointmentTypeToggle(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $id = (int)($_POST['id'] ?? 0); + $active = (int)($_POST['active'] ?? 0) === 1 ? 1 : 0; + AppointmentType::setActive($id, $active); + flash_set('ok','Updated.'); + redirect('/admin/appointment-types'); + } + + +public static function appointmentTypeDelete(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + flash_set('error','Invalid appointment type.'); + redirect('/admin/appointment-types'); + } + + if (!AppointmentType::canDelete($id)) { + flash_set('error','This appointment type is already in use (rates or production). Deactivate it instead of deleting.'); + redirect('/admin/appointment-types'); + } + + AppointmentType::delete($id); + flash_set('ok','Deleted.'); + redirect('/admin/appointment-types'); +} + + public static function providerRates(array $params): void { + Auth::requireRole('super'); + $providerId = (int)($params['id'] ?? 0); + $prov = Provider::findById($providerId); + if (!$prov) { http_response_code(404); echo "Provider not found."; return; } + + $types = AppointmentType::listByProviderType((string)$prov['provider_type'], false); + $rates = ProviderRate::listForProvider($providerId); + + View::render('admin/provider_rates', [ + 'title' => 'Provider Rates', + 'user' => Auth::user(), + 'prov' => $prov, + 'types' => $types, + 'rates' => $rates, + ]); + } + + public static function providerRateAdd(array $params): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + $providerId = (int)($params['id'] ?? 0); + $prov = Provider::findById($providerId); + if (!$prov) { flash_set('error','Provider not found.'); redirect('/admin/providers'); } + + $typeId = (int)($_POST['appointment_type_id'] ?? 0); + $rate = (float)($_POST['rate'] ?? 0); + $effFrom = (string)($_POST['effective_from'] ?? date('Y-m-d')); + $effTo = trim((string)($_POST['effective_to'] ?? '')); + $effTo = $effTo !== '' ? $effTo : null; + + $type = AppointmentType::findById($typeId); + if (!$type || (string)$type['provider_type'] !== (string)$prov['provider_type']) { + flash_set('error','Select an appointment type for this provider.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + if (!is_finite($rate) || $rate < 0 || $rate > 1000000) { + flash_set('error','Enter a valid rate.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + if (!self::validDate($effFrom) || ($effTo !== null && (!self::validDate($effTo) || $effTo < $effFrom))) { + flash_set('error','Enter a valid effective date range.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + + ProviderRate::addRate($providerId, $typeId, $rate, $effFrom, $effTo); + flash_set('ok','Rate added (previous open rate auto-closed).'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + + public static function providerRateEnd(array $params): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + $providerId = (int)($params['id'] ?? 0); + $rateId = (int)($_POST['rate_id'] ?? 0); + $effTo = trim((string)($_POST['effective_to'] ?? '')); + $effTo = $effTo !== '' ? $effTo : null; + if (!ProviderRate::belongsToProvider($rateId, $providerId) || ($effTo !== null && !self::validDate($effTo))) { + flash_set('error','Invalid rate or end date.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + ProviderRate::setEffectiveTo($rateId, $effTo); + flash_set('ok','Rate updated.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + + +public static function providerRateUpdate(array $params): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $providerId = (int)($params['id'] ?? 0); + $rateId = (int)($_POST['rate_id'] ?? 0); + $rate = (float)($_POST['rate'] ?? 0); + + if ($providerId <= 0 || $rateId <= 0 || !ProviderRate::belongsToProvider($rateId, $providerId)) { + flash_set('error','Invalid rate.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + + if (!is_finite($rate) || $rate < 0 || $rate > 1000000) { + flash_set('error','Enter a valid rate.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + ProviderRate::updateRate($rateId, $rate); + + flash_set('ok','Rate updated.'); + redirect('/admin/providers/' . $providerId . '/rates'); +} + +public static function providerRateDelete(array $params): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $providerId = (int)($params['id'] ?? 0); + $rateId = (int)($_POST['rate_id'] ?? 0); + + if ($providerId <= 0 || $rateId <= 0 || !ProviderRate::belongsToProvider($rateId, $providerId)) { + flash_set('error','Invalid rate.'); + redirect('/admin/providers/' . $providerId . '/rates'); + } + + ProviderRate::deleteRate($rateId); + + flash_set('ok','Rate deleted.'); + redirect('/admin/providers/' . $providerId . '/rates'); +} + + public static function production(): void { + Auth::requireRole('super'); + $pp = self::getPayPeriodFromRequest(); + $periods = self::recentPeriodsWithSelected($pp, 8); + + $status = ProviderProduction::statusForPayPeriod((int)$pp['id']); + + View::render('admin/provider_production', [ + 'title' => 'Provider Production', + 'user' => Auth::user(), + 'payPeriod' => $pp, + 'periods' => $periods, + 'status' => $status, + ]); + } + + public static function productionEdit(array $params): void { + Auth::requireLogin(); + + $providerId = (int)($params['id'] ?? 0); + self::requireProviderAccess($providerId); + $prov = Provider::findById($providerId); + if (!$prov) { http_response_code(404); echo "Provider not found."; return; } + + $pp = self::getPayPeriodFromRequest(); + $periods = self::recentPeriodsWithSelected($pp, 8); + + ProviderProduction::ensureRows($providerId, (int)$pp['id']); + $rows = ProviderProduction::rowsForProviderPeriod($providerId, (int)$pp['id']); + + // Resolve effective rates as-of pay period end date (used for preview if rate_used not set) + $asOf = (string)$pp['end_date']; + foreach ($rows as &$r) { + $eff = ProviderRate::effectiveRate($providerId, (int)$r['appointment_type_id'], $asOf); + $r['effective_rate'] = $eff !== null ? $eff : 0.00; + } + unset($r); + + + // Provider PTO for this pay period (admin-entered) + ProviderPto::ensureRow($providerId, (int)$pp['id']); + $ptoMinutes = ProviderPto::minutesForProviderPeriod($providerId, (int)$pp['id']); + $ptoBank = (int)($prov['pto_bank_minutes'] ?? 0); + $year = (int)substr((string)$pp['start_date'], 0, 4); + // YTD should be "as of" the selected pay period end date so future entries don't affect remaining. + $ptoUsedYtd = ProviderPto::sumUsedYtdThroughDate($providerId, $year, (string)$pp['end_date']); + $ptoAvail = max(0, $ptoBank - $ptoUsedYtd); + $ptoUsedExclCurrent = max(0, $ptoUsedYtd - $ptoMinutes); + View::render('admin/provider_production_edit', [ + 'title' => 'Edit Provider Production', + 'user' => Auth::user(), + 'prov' => $prov, + 'payPeriod' => $pp, + 'periods' => $periods, + 'rows' => $rows, + 'pto_bank' => $ptoBank, + 'pto_used_ytd' => $ptoUsedYtd, + 'pto_available' => $ptoAvail, + 'pto_minutes' => $ptoMinutes, + 'pto_used_excl_current' => $ptoUsedExclCurrent, + 'isSuper' => (Auth::user() && Auth::user()['role'] === 'super'), + 'backUrl' => '/timecards?pp=' . (string)$pp['id'], + ]); + } + + public static function productionSave(): void { + Auth::requireLogin(); + Csrf::check($_POST['_csrf'] ?? null); + + $providerId = (int)($_POST['provider_id'] ?? 0); + self::requireProviderAccess($providerId); + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + $pp = PayPeriod::findById($ppId); + if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards'); } + + ProviderProduction::ensureRows($providerId, $ppId); + $rows = ProviderProduction::rowsForProviderPeriod($providerId, $ppId); + // If locked, do not save + $locked = false; + foreach ($rows as $r) { if (!empty($r['locked_at'])) { $locked = true; break; } } + if ($locked) { + flash_set('error','This provider production card is locked.'); + redirect('/provider-production/' . $providerId . '?pp=' . $ppId); + } + + $counts = $_POST['counts'] ?? []; + ProviderProduction::saveCounts($providerId, $ppId, $counts); + + if (Auth::user()['role'] === 'super') { + // Provider PTO (hours) for this pay period (admin-entered) + $ptoHours = trim((string)($_POST['pto_hours'] ?? '')); + $ptoMin = 0; + if ($ptoHours !== '') { + $h = (float)$ptoHours; + if ($h < 0) $h = 0; + $ptoMin = (int)round($h * 60); + } + + // Clamp to available bank if this provider accrues PTO + $prov = Provider::findById($providerId); + $ptoBank = (int)($prov['pto_bank_minutes'] ?? 0); + if ($ptoBank > 0) { + $year = (int)substr((string)$pp['start_date'], 0, 4); + // Compute YTD used through this pay period end date (ignore PTO entered on later pay periods). + $usedYtd = ProviderPto::sumUsedYtdThroughDate($providerId, $year, (string)$pp['end_date']); + $current = ProviderPto::minutesForProviderPeriod($providerId, $ppId); + $usedExcl = max(0, $usedYtd - $current); + $maxForPeriod = max(0, $ptoBank - $usedExcl); + if ($ptoMin > $maxForPeriod) $ptoMin = $maxForPeriod; + } + + ProviderPto::saveMinutes($providerId, $ppId, $ptoMin); + } + + flash_set('ok','Saved.'); + redirect('/provider-production/' . $providerId . '?pp=' . $ppId); + } + + public static function productionLock(): void { + Auth::requireLogin(); + Csrf::check($_POST['_csrf'] ?? null); + + $providerId = (int)($_POST['provider_id'] ?? 0); + self::requireProviderAccess($providerId); + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + $pp = PayPeriod::findById($ppId); + if (!$pp) { flash_set('error','Invalid pay period.'); redirect('/timecards'); } + + ProviderProduction::ensureRows($providerId, $ppId); + ProviderProduction::lockProviderPeriod($providerId, $ppId, (int)Auth::user()['id'], (string)$pp['end_date']); + + ProviderPto::lockProviderPeriod($providerId, $ppId, (int)Auth::user()['id']); + + flash_set('ok','Locked.'); + redirect('/provider-production/' . $providerId . '?pp=' . $ppId); + } + + public static function productionUnlock(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $providerId = (int)($_POST['provider_id'] ?? 0); + $ppId = (int)($_POST['pay_period_id'] ?? 0); + $_SESSION['selected_pay_period_id'] = $ppId; + ProviderProduction::unlockProviderPeriod($providerId, $ppId); + + ProviderPto::unlockProviderPeriod($providerId, $ppId); + + flash_set('ok','Unlocked.'); + redirect('/provider-production/' . $providerId . '?pp=' . $ppId); + } +} diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 9e1d93b..6a87b9f 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1,124 +1,139 @@ -diff($today)->format('%r%a'); - $idx = (int)floor($diffDays / $len); - if ($diffDays < 0) $idx = (int)ceil($diffDays / $len) - 1; - $start = $anchor->modify('+' . ($idx*$len) . ' days'); - $end = $start->modify('+' . ($len-1) . ' days'); - $sv = (int)$state['current_settings_version_id']; - return PayPeriod::ensure($start->format('Y-m-d'), $end->format('Y-m-d'), $sv); - } - - public static function settings(): void { - Auth::requireRole('super'); - $state = Settings::appState(); - $cur = Settings::currentSettingsVersion(); - View::render('admin/settings', [ - 'title' => 'Settings', - 'user' => Auth::user(), - 'state' => $state, - 'current' => $cur, - ]); - } - - public static function save(): void { - Auth::requireRole('super'); - Csrf::check($_POST['_csrf'] ?? null); - - $actor = (int)Auth::user()['id']; - $state = Settings::appState(); - $current = Settings::currentSettingsVersion(); - $cfg = $current['config']; - - $inc = max(1, (int)($_POST['time_increment_minutes'] ?? ($cfg['time_increment_minutes'] ?? 15))); - $mode = in_array($_POST['rounding_mode'] ?? '', ['nearest','down','up'], true) ? $_POST['rounding_mode'] : ($cfg['rounding_mode'] ?? 'nearest'); - - $daysToShow = $_POST['days_to_show'] ?? []; - $daysToShow = array_values(array_filter(array_map('intval', (array)$daysToShow), fn($n)=>$n>=1 && $n<=7)); - if (!$daysToShow) $daysToShow = $cfg['days_to_show'] ?? [1,2,3,4,5,6]; - - // allowed minutes derived from increment - $allowed = []; - for ($m=0; $m<60; $m+=$inc) $allowed[] = $m; - - $newCfg = $cfg; - $newCfg['time_increment_minutes'] = $inc; - $newCfg['allowed_minutes'] = $allowed; - $newCfg['rounding_mode'] = $mode; - $newCfg['days_to_show'] = $daysToShow; - - // App state items - $companyName = trim((string)($_POST['company_name'] ?? $state['company_name'])); - $companyEmail = trim((string)($_POST['company_email'] ?? $state['company_email'])); - $anchor = trim((string)($_POST['pay_period_anchor_date'] ?? $state['pay_period_anchor_date'])); - $length = max(7, (int)($_POST['pay_period_length_days'] ?? $state['pay_period_length_days'])); - - $scope = ($_POST['apply_scope'] ?? 'next') === 'immediate' ? 'immediate' : 'next'; - $reround = isset($_POST['reround_existing']) && $scope === 'immediate'; - - $newVersionId = Settings::createSettingsVersion($actor, $newCfg); - - // Update app_state defaults for next periods - Settings::setCurrentSettingsVersion($newVersionId); - Settings::updateAppState([ - 'company_name' => $companyName, - 'company_email' => $companyEmail, - 'pay_period_anchor_date' => $anchor, - 'pay_period_length_days' => $length, - ]); - - if ($scope === 'immediate') { - $pp = self::getCurrentPayPeriod(); - // Only if the pay period is not globally locked - if (empty($pp['locked_at'])) { - PayPeriod::setSettingsVersion((int)$pp['id'], $newVersionId); - - if ($reround) { - // Reround all time_entries in this pay period that belong to unlocked timecards - $sql = "SELECT te.* FROM time_entries te - JOIN timecards tc ON tc.user_id=te.user_id AND tc.pay_period_id=te.pay_period_id - WHERE te.pay_period_id=? AND (tc.locked_at IS NULL)"; - $st = DB::pdo()->prepare($sql); - $st->execute([(int)$pp['id']]); - $rows = $st->fetchAll(); - foreach ($rows as $r) { - $tin = $r['time_in'] ? TimeService::roundTime(substr($r['time_in'],0,5), $newCfg) : null; - $tout = $r['time_out'] ? TimeService::roundTime(substr($r['time_out'],0,5), $newCfg) : null; - DB::pdo()->prepare("UPDATE time_entries SET time_in=?, time_out=?, updated_at=NOW() WHERE id=?") - ->execute([$tin, $tout, (int)$r['id']]); - } - } - } - } - - // Audit - DB::pdo()->prepare("INSERT INTO audit_log(actor_user_id,action,entity,entity_id,payload_json,created_at) - VALUES(?,?,?,?,?,NOW())")->execute([ - $actor, 'settings_saved', 'settings_versions', $newVersionId, - json_encode(['apply_scope'=>$scope,'reround_existing'=>$reround], JSON_UNESCAPED_SLASHES) - ]); - - flash_set('ok', 'Settings saved.'); - redirect('/admin/settings'); - } -} +diff($today)->format('%r%a'); + $idx = (int)floor($diffDays / $len); + if ($diffDays < 0) $idx = (int)ceil($diffDays / $len) - 1; + $start = $anchor->modify('+' . ($idx*$len) . ' days'); + $end = $start->modify('+' . ($len-1) . ' days'); + $sv = (int)$state['current_settings_version_id']; + return PayPeriod::ensure($start->format('Y-m-d'), $end->format('Y-m-d'), $sv); + } + + public static function settings(): void { + Auth::requireRole('super'); + $state = Settings::appState(); + $cur = Settings::currentSettingsVersion(); + View::render('admin/settings', [ + 'title' => 'Settings', + 'user' => Auth::user(), + 'state' => $state, + 'current' => $cur, + ]); + } + + public static function save(): void { + Auth::requireRole('super'); + Csrf::check($_POST['_csrf'] ?? null); + + $actor = (int)Auth::user()['id']; + $state = Settings::appState(); + $current = Settings::currentSettingsVersion(); + $cfg = $current['config']; + + $inc = (int)($_POST['time_increment_minutes'] ?? ($cfg['time_increment_minutes'] ?? 15)); + if (!in_array($inc, [5,10,15,20,30], true)) $inc = 15; + $mode = in_array($_POST['rounding_mode'] ?? '', ['nearest','down','up'], true) ? $_POST['rounding_mode'] : ($cfg['rounding_mode'] ?? 'nearest'); + + $daysToShow = $_POST['days_to_show'] ?? []; + $daysToShow = array_values(array_filter(array_map('intval', (array)$daysToShow), fn($n)=>$n>=1 && $n<=7)); + if (!$daysToShow) $daysToShow = $cfg['days_to_show'] ?? [1,2,3,4,5,6]; + + // allowed minutes derived from increment + $allowed = []; + for ($m=0; $m<60; $m+=$inc) $allowed[] = $m; + + $newCfg = $cfg; + $newCfg['time_increment_minutes'] = $inc; + $newCfg['allowed_minutes'] = $allowed; + $newCfg['rounding_mode'] = $mode; + $newCfg['days_to_show'] = $daysToShow; + + // App state items + $companyName = trim((string)($_POST['company_name'] ?? $state['company_name'])); + $companyEmail = trim((string)($_POST['company_email'] ?? $state['company_email'])); + $anchor = trim((string)($_POST['pay_period_anchor_date'] ?? $state['pay_period_anchor_date'])); + $length = max(7, min(31, (int)($_POST['pay_period_length_days'] ?? $state['pay_period_length_days']))); + + $anchorDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $anchor); + if ($companyName === '' || strlen($companyName) > 190) { + flash_set('error', 'Company name is required and must be 190 characters or fewer.'); + redirect('/admin/settings'); + } + if ($companyEmail !== '' && (!filter_var($companyEmail, FILTER_VALIDATE_EMAIL) || strlen($companyEmail) > 190)) { + flash_set('error', 'Enter a valid report email address.'); + redirect('/admin/settings'); + } + if (!$anchorDate || $anchorDate->format('Y-m-d') !== $anchor) { + flash_set('error', 'Enter a valid pay-period anchor date.'); + redirect('/admin/settings'); + } + + $scope = ($_POST['apply_scope'] ?? 'next') === 'immediate' ? 'immediate' : 'next'; + $reround = isset($_POST['reround_existing']) && $scope === 'immediate'; + + $newVersionId = Settings::createSettingsVersion($actor, $newCfg); + + // Update app_state defaults for next periods + Settings::setCurrentSettingsVersion($newVersionId); + Settings::updateAppState([ + 'company_name' => $companyName, + 'company_email' => $companyEmail, + 'pay_period_anchor_date' => $anchor, + 'pay_period_length_days' => $length, + ]); + + if ($scope === 'immediate') { + $pp = self::getCurrentPayPeriod(); + // Only if the pay period is not globally locked + if (empty($pp['locked_at'])) { + PayPeriod::setSettingsVersion((int)$pp['id'], $newVersionId); + + if ($reround) { + // Reround all time_entries in this pay period that belong to unlocked timecards + $sql = "SELECT te.* FROM time_entries te + JOIN timecards tc ON tc.user_id=te.user_id AND tc.pay_period_id=te.pay_period_id + WHERE te.pay_period_id=? AND (tc.locked_at IS NULL)"; + $st = DB::pdo()->prepare($sql); + $st->execute([(int)$pp['id']]); + $rows = $st->fetchAll(); + foreach ($rows as $r) { + $tin = $r['time_in'] ? TimeService::roundTime(substr($r['time_in'],0,5), $newCfg) : null; + $tout = $r['time_out'] ? TimeService::roundTime(substr($r['time_out'],0,5), $newCfg) : null; + DB::pdo()->prepare("UPDATE time_entries SET time_in=?, time_out=?, updated_at=NOW() WHERE id=?") + ->execute([$tin, $tout, (int)$r['id']]); + } + } + } + } + + // Audit + DB::pdo()->prepare("INSERT INTO audit_log(actor_user_id,action,entity,entity_id,payload_json,created_at) + VALUES(?,?,?,?,?,NOW())")->execute([ + $actor, 'settings_saved', 'settings_versions', $newVersionId, + json_encode(['apply_scope'=>$scope,'reround_existing'=>$reround], JSON_UNESCAPED_SLASHES) + ]); + + flash_set('ok', 'Settings saved.'); + redirect('/admin/settings'); + } +} diff --git a/app/Core/Auth.php b/app/Core/Auth.php index 988c651..5a978a3 100644 --- a/app/Core/Auth.php +++ b/app/Core/Auth.php @@ -1,52 +1,67 @@ -403

Forbidden

"; - exit; - } - } - - public static function login(string $email, string $password): bool { - $u = User::findByEmail($email); - if (!$u || (int)$u['active'] !== 1) return false; - if (!password_verify($password, $u['password_hash'])) return false; - $_SESSION['user_id'] = (int)$u['id']; - return true; - } - - public static function logout(): void { - $_SESSION = []; - if (ini_get("session.use_cookies")) { - $params = session_get_cookie_params(); - setcookie(session_name(), '', time() - 42000, - $params["path"], $params["domain"], $params["secure"], $params["httponly"] - ); - } - session_destroy(); - } -} +403

Forbidden

"; + exit; + } + } + + public static function login(string $email, string $password): bool { + $u = User::findByEmail($email); + if (!$u || (int)$u['active'] !== 1 || !password_verify($password, $u['password_hash'])) { + return false; + } + + session_regenerate_id(true); + $_SESSION['user_id'] = (int)$u['id']; + unset($_SESSION['selected_pay_period_id']); + Csrf::rotate(); + + if (password_needs_rehash((string)$u['password_hash'], PASSWORD_DEFAULT)) { + User::setPassword((int)$u['id'], password_hash($password, PASSWORD_DEFAULT)); + } + + return true; + } + + public static function homePath(): string { + $u = self::user(); + return $u && $u['role'] === 'super' ? '/timecards' : '/timecard'; + } + + public static function logout(): void { + $_SESSION = []; + if (ini_get("session.use_cookies")) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, + $params["path"], $params["domain"], $params["secure"], $params["httponly"] + ); + } + session_destroy(); + } +} diff --git a/app/Core/Config.php b/app/Core/Config.php index 5ad0815..5857be1 100644 --- a/app/Core/Config.php +++ b/app/Core/Config.php @@ -1,17 +1,17 @@ -419

Invalid CSRF token. Please refresh and try again.

"; - exit; - } - } -} +419

Invalid CSRF token. Please refresh and try again.

"; + exit; + } + } +} diff --git a/app/Core/DB.php b/app/Core/DB.php index 3b8c609..60155cd 100644 --- a/app/Core/DB.php +++ b/app/Core/DB.php @@ -1,34 +1,35 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - return self::$pdo; - } - - public static function tx(callable $fn) { - $pdo = self::pdo(); - try { - $pdo->beginTransaction(); - $res = $fn($pdo); - $pdo->commit(); - return $res; - } catch (\Throwable $e) { - if ($pdo->inTransaction()) $pdo->rollBack(); - throw $e; - } - } -} + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return self::$pdo; + } + + public static function tx(callable $fn) { + $pdo = self::pdo(); + try { + $pdo->beginTransaction(); + $res = $fn($pdo); + $pdo->commit(); + return $res; + } catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php index dd38249..aa7ad98 100644 --- a/app/Core/Router.php +++ b/app/Core/Router.php @@ -1,32 +1,32 @@ -add('GET', $path, $handler); } - public function post(string $path, callable $handler): void { $this->add('POST', $path, $handler); } - - private function add(string $method, string $path, callable $handler): void { - $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path); - $pattern = '#^' . $pattern . '$#'; - $this->routes[] = [$method, $pattern, $handler]; - } - - public function dispatch(string $method, string $uri): void { - $path = parse_url($uri, PHP_URL_PATH) ?: '/'; - foreach ($this->routes as [$m, $pattern, $handler]) { - if ($m !== $method) continue; - if (preg_match($pattern, $path, $matches)) { - $params = []; - foreach ($matches as $k => $v) if (!is_int($k)) $params[$k] = $v; - $handler($params); - return; - } - } - http_response_code(404); - echo "

404

Not found

"; - } -} +add('GET', $path, $handler); } + public function post(string $path, callable $handler): void { $this->add('POST', $path, $handler); } + + private function add(string $method, string $path, callable $handler): void { + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path); + $pattern = '#^' . $pattern . '$#'; + $this->routes[] = [$method, $pattern, $handler]; + } + + public function dispatch(string $method, string $uri): void { + $path = parse_url($uri, PHP_URL_PATH) ?: '/'; + foreach ($this->routes as [$m, $pattern, $handler]) { + if ($m !== $method) continue; + if (preg_match($pattern, $path, $matches)) { + $params = []; + foreach ($matches as $k => $v) if (!is_int($k)) $params[$k] = $v; + $handler($params); + return; + } + } + http_response_code(404); + echo "

404

Not found

"; + } +} diff --git a/app/Core/View.php b/app/Core/View.php index 50ac491..cdf8517 100644 --- a/app/Core/View.php +++ b/app/Core/View.php @@ -1,13 +1,13 @@ -prepare("SELECT * FROM appointment_types WHERE id=? LIMIT 1"); - $st->execute([$id]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function listByProviderType(string $providerType, bool $includeInactive = true): array { - $sql = "SELECT * FROM appointment_types WHERE provider_type=?"; - if (!$includeInactive) $sql .= " AND active=1"; - $sql .= " ORDER BY sort_order ASC, name ASC"; - $st = DB::pdo()->prepare($sql); - $st->execute([$providerType]); - return $st->fetchAll(); - } - - public static function all(bool $includeInactive = true): array { - $sql = "SELECT * FROM appointment_types"; - if (!$includeInactive) $sql .= " WHERE active=1"; - $sql .= " ORDER BY provider_type ASC, sort_order ASC, name ASC"; - return DB::pdo()->query($sql)->fetchAll(); - } - - public static function create(array $data): int { - $st = DB::pdo()->prepare("INSERT INTO appointment_types(provider_type,name,active,sort_order,created_at,updated_at) - VALUES(?,?,?,?,NOW(),NOW())"); - $st->execute([ - $data['provider_type'], - $data['name'], - $data['active'] ?? 1, - $data['sort_order'] ?? 0, - ]); - return (int)DB::pdo()->lastInsertId(); - } - - public static function update(int $id, array $data): void { - $fields = []; - $vals = []; - foreach (['provider_type','name','active','sort_order'] as $k) { - if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } - } - if (!$fields) return; - $vals[] = $id; - $sql = "UPDATE appointment_types SET " . implode(',', $fields) . ", updated_at=NOW() WHERE id=?"; - DB::pdo()->prepare($sql)->execute($vals); - } - - public static function setActive(int $id, int $active): void { - DB::pdo()->prepare("UPDATE appointment_types SET active=?, updated_at=NOW() WHERE id=?")->execute([$active, $id]); - } - - -public static function canDelete(int $id): bool { - $pdo = DB::pdo(); - $st = $pdo->prepare("SELECT COUNT(*) c FROM provider_rates WHERE appointment_type_id=?"); - $st->execute([$id]); - $c1 = (int)($st->fetch()['c'] ?? 0); - - $st = $pdo->prepare("SELECT COUNT(*) c FROM provider_production WHERE appointment_type_id=?"); - $st->execute([$id]); - $c2 = (int)($st->fetch()['c'] ?? 0); - - return ($c1 + $c2) === 0; -} - -public static function delete(int $id): void { - DB::pdo()->prepare("DELETE FROM appointment_types WHERE id=?")->execute([$id]); -} -} +prepare("SELECT * FROM appointment_types WHERE id=? LIMIT 1"); + $st->execute([$id]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function listByProviderType(string $providerType, bool $includeInactive = true): array { + $sql = "SELECT * FROM appointment_types WHERE provider_type=?"; + if (!$includeInactive) $sql .= " AND active=1"; + $sql .= " ORDER BY sort_order ASC, name ASC"; + $st = DB::pdo()->prepare($sql); + $st->execute([$providerType]); + return $st->fetchAll(); + } + + public static function all(bool $includeInactive = true): array { + $sql = "SELECT * FROM appointment_types"; + if (!$includeInactive) $sql .= " WHERE active=1"; + $sql .= " ORDER BY provider_type ASC, sort_order ASC, name ASC"; + return DB::pdo()->query($sql)->fetchAll(); + } + + public static function create(array $data): int { + $st = DB::pdo()->prepare("INSERT INTO appointment_types(provider_type,name,active,sort_order,created_at,updated_at) + VALUES(?,?,?,?,NOW(),NOW())"); + $st->execute([ + $data['provider_type'], + $data['name'], + $data['active'] ?? 1, + $data['sort_order'] ?? 0, + ]); + return (int)DB::pdo()->lastInsertId(); + } + + public static function update(int $id, array $data): void { + $fields = []; + $vals = []; + foreach (['provider_type','name','active','sort_order'] as $k) { + if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } + } + if (!$fields) return; + $vals[] = $id; + $sql = "UPDATE appointment_types SET " . implode(',', $fields) . ", updated_at=NOW() WHERE id=?"; + DB::pdo()->prepare($sql)->execute($vals); + } + + public static function setActive(int $id, int $active): void { + DB::pdo()->prepare("UPDATE appointment_types SET active=?, updated_at=NOW() WHERE id=?")->execute([$active, $id]); + } + + +public static function canDelete(int $id): bool { + $pdo = DB::pdo(); + $st = $pdo->prepare("SELECT COUNT(*) c FROM provider_rates WHERE appointment_type_id=?"); + $st->execute([$id]); + $c1 = (int)($st->fetch()['c'] ?? 0); + + $st = $pdo->prepare("SELECT COUNT(*) c FROM provider_production WHERE appointment_type_id=?"); + $st->execute([$id]); + $c2 = (int)($st->fetch()['c'] ?? 0); + + return ($c1 + $c2) === 0; +} + +public static function delete(int $id): void { + DB::pdo()->prepare("DELETE FROM appointment_types WHERE id=?")->execute([$id]); +} +} diff --git a/app/Models/PayPeriod.php b/app/Models/PayPeriod.php index 26c4a9b..593d947 100644 --- a/app/Models/PayPeriod.php +++ b/app/Models/PayPeriod.php @@ -1,46 +1,46 @@ -prepare("SELECT * FROM pay_periods WHERE start_date=? AND end_date=? LIMIT 1"); - $st->execute([$start, $end]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function findById(int $id): ?array { - $st = DB::pdo()->prepare("SELECT * FROM pay_periods WHERE id=? LIMIT 1"); - $st->execute([$id]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function ensure(string $start, string $end, int $settingsVersionId): array { - $existing = self::findByDates($start, $end); - if ($existing) return $existing; - $st = DB::pdo()->prepare("INSERT INTO pay_periods(start_date,end_date,settings_version_id,created_at) VALUES(?,?,?,NOW())"); - $st->execute([$start, $end, $settingsVersionId]); - return self::findById((int)DB::pdo()->lastInsertId()); - } - - public static function listRecent(int $limit = 10): array { - $st = DB::pdo()->prepare("SELECT * FROM pay_periods ORDER BY start_date DESC LIMIT ?"); - $st->bindValue(1, $limit, \PDO::PARAM_INT); - $st->execute(); - return $st->fetchAll(); - } - - public static function setSettingsVersion(int $payPeriodId, int $settingsVersionId): void { - DB::pdo()->prepare("UPDATE pay_periods SET settings_version_id=? WHERE id=? AND locked_at IS NULL")->execute([$settingsVersionId, $payPeriodId]); - } - - public static function lock(int $payPeriodId, int $lockedBy): void { - DB::pdo()->prepare("UPDATE pay_periods SET locked_at=NOW(), locked_by=? WHERE id=? AND locked_at IS NULL")->execute([$lockedBy, $payPeriodId]); - } -} +prepare("SELECT * FROM pay_periods WHERE start_date=? AND end_date=? LIMIT 1"); + $st->execute([$start, $end]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function findById(int $id): ?array { + $st = DB::pdo()->prepare("SELECT * FROM pay_periods WHERE id=? LIMIT 1"); + $st->execute([$id]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function ensure(string $start, string $end, int $settingsVersionId): array { + $existing = self::findByDates($start, $end); + if ($existing) return $existing; + $st = DB::pdo()->prepare("INSERT INTO pay_periods(start_date,end_date,settings_version_id,created_at) VALUES(?,?,?,NOW())"); + $st->execute([$start, $end, $settingsVersionId]); + return self::findById((int)DB::pdo()->lastInsertId()); + } + + public static function listRecent(int $limit = 10): array { + $st = DB::pdo()->prepare("SELECT * FROM pay_periods ORDER BY start_date DESC LIMIT ?"); + $st->bindValue(1, $limit, \PDO::PARAM_INT); + $st->execute(); + return $st->fetchAll(); + } + + public static function setSettingsVersion(int $payPeriodId, int $settingsVersionId): void { + DB::pdo()->prepare("UPDATE pay_periods SET settings_version_id=? WHERE id=? AND locked_at IS NULL")->execute([$settingsVersionId, $payPeriodId]); + } + + public static function lock(int $payPeriodId, int $lockedBy): void { + DB::pdo()->prepare("UPDATE pay_periods SET locked_at=NOW(), locked_by=? WHERE id=? AND locked_at IS NULL")->execute([$lockedBy, $payPeriodId]); + } +} diff --git a/app/Models/Provider.php b/app/Models/Provider.php index 4a9c89c..e322f9d 100644 --- a/app/Models/Provider.php +++ b/app/Models/Provider.php @@ -1,55 +1,55 @@ -prepare("SELECT * FROM providers WHERE id=? LIMIT 1"); - $st->execute([$id]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function all(bool $includeInactive = true): array { - $sql = "SELECT * FROM providers"; - if (!$includeInactive) $sql .= " WHERE active=1"; - $sql .= " ORDER BY active DESC, provider_type ASC, full_name ASC"; - return DB::pdo()->query($sql)->fetchAll(); - } - - public static function activeMatchingFullName(string $fullName): array { - $name = strtolower(trim($fullName)); - if ($name === '') return []; - $st = DB::pdo()->prepare("SELECT * FROM providers WHERE active=1 AND LOWER(TRIM(full_name))=? ORDER BY provider_type ASC, full_name ASC"); - $st->execute([$name]); - return $st->fetchAll(); - } - - public static function create(array $data): int { - $st = DB::pdo()->prepare("INSERT INTO providers(provider_type,full_name,active,pto_bank_minutes,created_at,updated_at) - VALUES(?,?,?, ?,NOW(),NOW())"); - $st->execute([ - $data['provider_type'], - $data['full_name'], - $data['active'] ?? 1, - $data['pto_bank_minutes'] ?? 0, - ]); - return (int)DB::pdo()->lastInsertId(); - } - - public static function update(int $id, array $data): void { - $fields = []; - $vals = []; - foreach (['provider_type','full_name','active','pto_bank_minutes'] as $k) { - if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } - } - if (!$fields) return; - $vals[] = $id; - $sql = "UPDATE providers SET " . implode(',', $fields) . ", updated_at=NOW() WHERE id=?"; - DB::pdo()->prepare($sql)->execute($vals); - } -} +prepare("SELECT * FROM providers WHERE id=? LIMIT 1"); + $st->execute([$id]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function all(bool $includeInactive = true): array { + $sql = "SELECT * FROM providers"; + if (!$includeInactive) $sql .= " WHERE active=1"; + $sql .= " ORDER BY active DESC, provider_type ASC, full_name ASC"; + return DB::pdo()->query($sql)->fetchAll(); + } + + public static function activeMatchingFullName(string $fullName): array { + $name = strtolower(trim($fullName)); + if ($name === '') return []; + $st = DB::pdo()->prepare("SELECT * FROM providers WHERE active=1 AND LOWER(TRIM(full_name))=? ORDER BY provider_type ASC, full_name ASC"); + $st->execute([$name]); + return $st->fetchAll(); + } + + public static function create(array $data): int { + $st = DB::pdo()->prepare("INSERT INTO providers(provider_type,full_name,active,pto_bank_minutes,created_at,updated_at) + VALUES(?,?,?, ?,NOW(),NOW())"); + $st->execute([ + $data['provider_type'], + $data['full_name'], + $data['active'] ?? 1, + $data['pto_bank_minutes'] ?? 0, + ]); + return (int)DB::pdo()->lastInsertId(); + } + + public static function update(int $id, array $data): void { + $fields = []; + $vals = []; + foreach (['provider_type','full_name','active','pto_bank_minutes'] as $k) { + if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } + } + if (!$fields) return; + $vals[] = $id; + $sql = "UPDATE providers SET " . implode(',', $fields) . ", updated_at=NOW() WHERE id=?"; + DB::pdo()->prepare($sql)->execute($vals); + } +} diff --git a/app/Models/ProviderProduction.php b/app/Models/ProviderProduction.php index 7785179..891f430 100644 --- a/app/Models/ProviderProduction.php +++ b/app/Models/ProviderProduction.php @@ -1,90 +1,90 @@ -prepare($sql)->execute([$providerId, $payPeriodId, (int)$t['id']]); - } - } - - public static function rowsForProviderPeriod(int $providerId, int $payPeriodId): array { - $sql = "SELECT pp.*, at.name as type_name, at.sort_order - FROM provider_production pp - JOIN appointment_types at ON at.id=pp.appointment_type_id - WHERE pp.provider_id=? AND pp.pay_period_id=? - ORDER BY at.sort_order ASC, at.name ASC"; - $st = DB::pdo()->prepare($sql); - $st->execute([$providerId, $payPeriodId]); - return $st->fetchAll(); - } - - public static function statusForPayPeriod(int $payPeriodId): array { - $sql = "SELECT p.id as provider_id, p.full_name, p.provider_type, p.active, - MAX(pp.submitted_at) as submitted_at, - MAX(pp.locked_at) as locked_at - FROM providers p - LEFT JOIN provider_production pp ON pp.provider_id=p.id AND pp.pay_period_id=? - GROUP BY p.id, p.full_name, p.provider_type, p.active - ORDER BY p.active DESC, p.provider_type ASC, p.full_name ASC"; - $st = DB::pdo()->prepare($sql); - $st->execute([$payPeriodId]); - return $st->fetchAll(); - } - - public static function saveCounts(int $providerId, int $payPeriodId, array $counts): void { - foreach ($counts as $appointmentTypeId => $count) { - $c = (int)$count; - if ($c < 0) $c = 0; - DB::pdo()->prepare("UPDATE provider_production SET count=?, updated_at=NOW() - WHERE provider_id=? AND pay_period_id=? AND appointment_type_id=?") - ->execute([$c, $providerId, $payPeriodId, (int)$appointmentTypeId]); - } - } - - public static function lockProviderPeriod(int $providerId, int $payPeriodId, int $lockedBy, string $asOfYmd): void { - $rows = self::rowsForProviderPeriod($providerId, $payPeriodId); - foreach ($rows as $r) { - $rateUsed = $r['rate_used']; - if ($rateUsed === null) { - $eff = ProviderRate::effectiveRate($providerId, (int)$r['appointment_type_id'], $asOfYmd); - $rateUsed = $eff !== null ? $eff : 0.00; - DB::pdo()->prepare("UPDATE provider_production SET rate_used=?, updated_at=NOW() WHERE id=?") - ->execute([$rateUsed, (int)$r['id']]); - } - } - DB::pdo()->prepare("UPDATE provider_production - SET submitted_at=COALESCE(submitted_at,NOW()), - locked_at=COALESCE(locked_at,NOW()), - locked_by=COALESCE(locked_by,?), - updated_at=NOW() - WHERE provider_id=? AND pay_period_id=?") - ->execute([$lockedBy, $providerId, $payPeriodId]); - } - - public static function unlockProviderPeriod(int $providerId, int $payPeriodId): void { - DB::pdo()->prepare("UPDATE provider_production SET locked_at=NULL, locked_by=NULL, updated_at=NOW() - WHERE provider_id=? AND pay_period_id=?") - ->execute([$providerId, $payPeriodId]); - } - - public static function lockAllForPayPeriod(int $payPeriodId, int $lockedBy, string $asOfYmd): void { - $providers = DB::pdo()->query("SELECT id FROM providers WHERE active=1")->fetchAll(); - foreach ($providers as $p) { - $pid = (int)$p['id']; - self::ensureRows($pid, $payPeriodId); - self::lockProviderPeriod($pid, $payPeriodId, $lockedBy, $asOfYmd); - } - } -} +prepare($sql)->execute([$providerId, $payPeriodId, (int)$t['id']]); + } + } + + public static function rowsForProviderPeriod(int $providerId, int $payPeriodId): array { + $sql = "SELECT pp.*, at.name as type_name, at.sort_order + FROM provider_production pp + JOIN appointment_types at ON at.id=pp.appointment_type_id + WHERE pp.provider_id=? AND pp.pay_period_id=? + ORDER BY at.sort_order ASC, at.name ASC"; + $st = DB::pdo()->prepare($sql); + $st->execute([$providerId, $payPeriodId]); + return $st->fetchAll(); + } + + public static function statusForPayPeriod(int $payPeriodId): array { + $sql = "SELECT p.id as provider_id, p.full_name, p.provider_type, p.active, + MAX(pp.submitted_at) as submitted_at, + MAX(pp.locked_at) as locked_at + FROM providers p + LEFT JOIN provider_production pp ON pp.provider_id=p.id AND pp.pay_period_id=? + GROUP BY p.id, p.full_name, p.provider_type, p.active + ORDER BY p.active DESC, p.provider_type ASC, p.full_name ASC"; + $st = DB::pdo()->prepare($sql); + $st->execute([$payPeriodId]); + return $st->fetchAll(); + } + + public static function saveCounts(int $providerId, int $payPeriodId, array $counts): void { + foreach ($counts as $appointmentTypeId => $count) { + $c = (int)$count; + $c = max(0, min(1000000, $c)); + DB::pdo()->prepare("UPDATE provider_production SET count=?, updated_at=NOW() + WHERE provider_id=? AND pay_period_id=? AND appointment_type_id=?") + ->execute([$c, $providerId, $payPeriodId, (int)$appointmentTypeId]); + } + } + + public static function lockProviderPeriod(int $providerId, int $payPeriodId, int $lockedBy, string $asOfYmd): void { + $rows = self::rowsForProviderPeriod($providerId, $payPeriodId); + foreach ($rows as $r) { + $rateUsed = $r['rate_used']; + if ($rateUsed === null) { + $eff = ProviderRate::effectiveRate($providerId, (int)$r['appointment_type_id'], $asOfYmd); + $rateUsed = $eff !== null ? $eff : 0.00; + DB::pdo()->prepare("UPDATE provider_production SET rate_used=?, updated_at=NOW() WHERE id=?") + ->execute([$rateUsed, (int)$r['id']]); + } + } + DB::pdo()->prepare("UPDATE provider_production + SET submitted_at=COALESCE(submitted_at,NOW()), + locked_at=COALESCE(locked_at,NOW()), + locked_by=COALESCE(locked_by,?), + updated_at=NOW() + WHERE provider_id=? AND pay_period_id=?") + ->execute([$lockedBy, $providerId, $payPeriodId]); + } + + public static function unlockProviderPeriod(int $providerId, int $payPeriodId): void { + DB::pdo()->prepare("UPDATE provider_production SET locked_at=NULL, locked_by=NULL, updated_at=NOW() + WHERE provider_id=? AND pay_period_id=?") + ->execute([$providerId, $payPeriodId]); + } + + public static function lockAllForPayPeriod(int $payPeriodId, int $lockedBy, string $asOfYmd): void { + $providers = DB::pdo()->query("SELECT id FROM providers WHERE active=1")->fetchAll(); + foreach ($providers as $p) { + $pid = (int)$p['id']; + self::ensureRows($pid, $payPeriodId); + self::lockProviderPeriod($pid, $payPeriodId, $lockedBy, $asOfYmd); + } + } +} diff --git a/app/Models/ProviderPto.php b/app/Models/ProviderPto.php index 4c0feef..a34b278 100644 --- a/app/Models/ProviderPto.php +++ b/app/Models/ProviderPto.php @@ -1,92 +1,92 @@ -prepare($sql)->execute([$providerId, $payPeriodId]); - } - - public static function findForProviderPeriod(int $providerId, int $payPeriodId): ?array { - $st = DB::pdo()->prepare("SELECT * FROM provider_pto WHERE provider_id=? AND pay_period_id=? LIMIT 1"); - $st->execute([$providerId, $payPeriodId]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function minutesForProviderPeriod(int $providerId, int $payPeriodId): int { - $st = DB::pdo()->prepare("SELECT COALESCE(pto_minutes,0) as m FROM provider_pto WHERE provider_id=? AND pay_period_id=? LIMIT 1"); - $st->execute([$providerId, $payPeriodId]); - $row = $st->fetch(); - return (int)($row['m'] ?? 0); - } - - public static function saveMinutes(int $providerId, int $payPeriodId, int $minutes): void { - if ($minutes < 0) $minutes = 0; - self::ensureRow($providerId, $payPeriodId); - DB::pdo()->prepare("UPDATE provider_pto SET pto_minutes=?, updated_at=NOW() WHERE provider_id=? AND pay_period_id=?") - ->execute([$minutes, $providerId, $payPeriodId]); - } - - public static function sumUsedYtd(int $providerId, int $year): int { - $sql = "SELECT COALESCE(SUM(ppto.pto_minutes),0) as m - FROM provider_pto ppto - JOIN pay_periods pp ON pp.id=ppto.pay_period_id - WHERE ppto.provider_id=? AND YEAR(pp.start_date)=?"; - $st = DB::pdo()->prepare($sql); - $st->execute([$providerId, $year]); - $row = $st->fetch(); - return (int)($row['m'] ?? 0); - } - - /** - * Sum PTO used for a provider for a given year, but only through a specific pay period end date. - * - * This prevents "future" PTO (entered on open timecards for later pay periods) from affecting - * historical reports and bank/remaining calculations. - */ - public static function sumUsedYtdThroughDate(int $providerId, int $year, string $throughEndDate): int { - $sql = "SELECT COALESCE(SUM(ppto.pto_minutes),0) as m - FROM provider_pto ppto - JOIN pay_periods pp ON pp.id=ppto.pay_period_id - WHERE ppto.provider_id=? - AND YEAR(pp.start_date)=? - AND pp.end_date <= ?"; - $st = DB::pdo()->prepare($sql); - $st->execute([$providerId, $year, $throughEndDate]); - $row = $st->fetch(); - return (int)($row['m'] ?? 0); - } - - public static function lockProviderPeriod(int $providerId, int $payPeriodId, int $lockedBy): void { - self::ensureRow($providerId, $payPeriodId); - DB::pdo()->prepare("UPDATE provider_pto - SET submitted_at=COALESCE(submitted_at,NOW()), - locked_at=COALESCE(locked_at,NOW()), - locked_by=COALESCE(locked_by,?), - updated_at=NOW() - WHERE provider_id=? AND pay_period_id=?") - ->execute([$lockedBy, $providerId, $payPeriodId]); - } - - public static function unlockProviderPeriod(int $providerId, int $payPeriodId): void { - DB::pdo()->prepare("UPDATE provider_pto SET locked_at=NULL, locked_by=NULL, updated_at=NOW() - WHERE provider_id=? AND pay_period_id=?") - ->execute([$providerId, $payPeriodId]); - } - - public static function lockAllForPayPeriod(int $payPeriodId, int $lockedBy): void { - $providers = DB::pdo()->query("SELECT id FROM providers WHERE active=1")->fetchAll(); - foreach ($providers as $p) { - $pid = (int)$p['id']; - self::ensureRow($pid, $payPeriodId); - self::lockProviderPeriod($pid, $payPeriodId, $lockedBy); - } - } -} +prepare($sql)->execute([$providerId, $payPeriodId]); + } + + public static function findForProviderPeriod(int $providerId, int $payPeriodId): ?array { + $st = DB::pdo()->prepare("SELECT * FROM provider_pto WHERE provider_id=? AND pay_period_id=? LIMIT 1"); + $st->execute([$providerId, $payPeriodId]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function minutesForProviderPeriod(int $providerId, int $payPeriodId): int { + $st = DB::pdo()->prepare("SELECT COALESCE(pto_minutes,0) as m FROM provider_pto WHERE provider_id=? AND pay_period_id=? LIMIT 1"); + $st->execute([$providerId, $payPeriodId]); + $row = $st->fetch(); + return (int)($row['m'] ?? 0); + } + + public static function saveMinutes(int $providerId, int $payPeriodId, int $minutes): void { + if ($minutes < 0) $minutes = 0; + self::ensureRow($providerId, $payPeriodId); + DB::pdo()->prepare("UPDATE provider_pto SET pto_minutes=?, updated_at=NOW() WHERE provider_id=? AND pay_period_id=?") + ->execute([$minutes, $providerId, $payPeriodId]); + } + + public static function sumUsedYtd(int $providerId, int $year): int { + $sql = "SELECT COALESCE(SUM(ppto.pto_minutes),0) as m + FROM provider_pto ppto + JOIN pay_periods pp ON pp.id=ppto.pay_period_id + WHERE ppto.provider_id=? AND YEAR(pp.start_date)=?"; + $st = DB::pdo()->prepare($sql); + $st->execute([$providerId, $year]); + $row = $st->fetch(); + return (int)($row['m'] ?? 0); + } + + /** + * Sum PTO used for a provider for a given year, but only through a specific pay period end date. + * + * This prevents "future" PTO (entered on open timecards for later pay periods) from affecting + * historical reports and bank/remaining calculations. + */ + public static function sumUsedYtdThroughDate(int $providerId, int $year, string $throughEndDate): int { + $sql = "SELECT COALESCE(SUM(ppto.pto_minutes),0) as m + FROM provider_pto ppto + JOIN pay_periods pp ON pp.id=ppto.pay_period_id + WHERE ppto.provider_id=? + AND YEAR(pp.start_date)=? + AND pp.end_date <= ?"; + $st = DB::pdo()->prepare($sql); + $st->execute([$providerId, $year, $throughEndDate]); + $row = $st->fetch(); + return (int)($row['m'] ?? 0); + } + + public static function lockProviderPeriod(int $providerId, int $payPeriodId, int $lockedBy): void { + self::ensureRow($providerId, $payPeriodId); + DB::pdo()->prepare("UPDATE provider_pto + SET submitted_at=COALESCE(submitted_at,NOW()), + locked_at=COALESCE(locked_at,NOW()), + locked_by=COALESCE(locked_by,?), + updated_at=NOW() + WHERE provider_id=? AND pay_period_id=?") + ->execute([$lockedBy, $providerId, $payPeriodId]); + } + + public static function unlockProviderPeriod(int $providerId, int $payPeriodId): void { + DB::pdo()->prepare("UPDATE provider_pto SET locked_at=NULL, locked_by=NULL, updated_at=NOW() + WHERE provider_id=? AND pay_period_id=?") + ->execute([$providerId, $payPeriodId]); + } + + public static function lockAllForPayPeriod(int $payPeriodId, int $lockedBy): void { + $providers = DB::pdo()->query("SELECT id FROM providers WHERE active=1")->fetchAll(); + foreach ($providers as $p) { + $pid = (int)$p['id']; + self::ensureRow($pid, $payPeriodId); + self::lockProviderPeriod($pid, $payPeriodId, $lockedBy); + } + } +} diff --git a/app/Models/ProviderRate.php b/app/Models/ProviderRate.php index b0d80b0..c04e1a0 100644 --- a/app/Models/ProviderRate.php +++ b/app/Models/ProviderRate.php @@ -1,67 +1,73 @@ -= ?) - ORDER BY effective_from DESC - LIMIT 1"; - $st = DB::pdo()->prepare($sql); - $st->execute([$providerId, $appointmentTypeId, $asOfYmd, $asOfYmd]); - $row = $st->fetch(); - if (!$row) return null; - return (float)$row['rate']; - } - - public static function listForProvider(int $providerId): array { - $sql = "SELECT pr.*, at.name as type_name, at.provider_type - FROM provider_rates pr - JOIN appointment_types at ON at.id=pr.appointment_type_id - WHERE pr.provider_id=? - ORDER BY at.sort_order ASC, at.name ASC, pr.effective_from DESC"; - $st = DB::pdo()->prepare($sql); - $st->execute([$providerId]); - return $st->fetchAll(); - } - - public static function addRate(int $providerId, int $appointmentTypeId, float $rate, string $effectiveFrom, ?string $effectiveTo): void { - // Auto-close any open-ended rate that overlaps the new effectiveFrom (same provider+type) - DB::tx(function($pdo) use ($providerId,$appointmentTypeId,$rate,$effectiveFrom,$effectiveTo) { - $dayBefore = (new \DateTimeImmutable($effectiveFrom))->modify('-1 day')->format('Y-m-d'); - - // Close open-ended rows that start before the new effective date - $pdo->prepare("UPDATE provider_rates - SET effective_to=?, updated_at=NOW() - WHERE provider_id=? AND appointment_type_id=? - AND effective_to IS NULL - AND effective_from < ?") - ->execute([$dayBefore, $providerId, $appointmentTypeId, $effectiveFrom]); - - // Insert new row - $pdo->prepare("INSERT INTO provider_rates(provider_id,appointment_type_id,rate,effective_from,effective_to,created_at,updated_at) - VALUES(?,?,?,?,?,NOW(),NOW())") - ->execute([$providerId, $appointmentTypeId, $rate, $effectiveFrom, $effectiveTo]); - }); - } - - public static function setEffectiveTo(int $id, ?string $effectiveTo): void { - DB::pdo()->prepare("UPDATE provider_rates SET effective_to=?, updated_at=NOW() WHERE id=?")->execute([$effectiveTo, $id]); - } - - -public static function updateRate(int $id, float $rate): void { - DB::pdo()->prepare("UPDATE provider_rates SET rate=?, updated_at=NOW() WHERE id=?")->execute([$rate, $id]); -} - -public static function deleteRate(int $id): void { - DB::pdo()->prepare("DELETE FROM provider_rates WHERE id=?")->execute([$id]); -} -} +prepare("SELECT 1 FROM provider_rates WHERE id=? AND provider_id=? LIMIT 1"); + $st->execute([$id, $providerId]); + return (bool)$st->fetchColumn(); + } + + public static function effectiveRate(int $providerId, int $appointmentTypeId, string $asOfYmd): ?float { + $sql = "SELECT rate FROM provider_rates + WHERE provider_id=? AND appointment_type_id=? + AND effective_from <= ? + AND (effective_to IS NULL OR effective_to >= ?) + ORDER BY effective_from DESC + LIMIT 1"; + $st = DB::pdo()->prepare($sql); + $st->execute([$providerId, $appointmentTypeId, $asOfYmd, $asOfYmd]); + $row = $st->fetch(); + if (!$row) return null; + return (float)$row['rate']; + } + + public static function listForProvider(int $providerId): array { + $sql = "SELECT pr.*, at.name as type_name, at.provider_type + FROM provider_rates pr + JOIN appointment_types at ON at.id=pr.appointment_type_id + WHERE pr.provider_id=? + ORDER BY at.sort_order ASC, at.name ASC, pr.effective_from DESC"; + $st = DB::pdo()->prepare($sql); + $st->execute([$providerId]); + return $st->fetchAll(); + } + + public static function addRate(int $providerId, int $appointmentTypeId, float $rate, string $effectiveFrom, ?string $effectiveTo): void { + // Auto-close any open-ended rate that overlaps the new effectiveFrom (same provider+type) + DB::tx(function($pdo) use ($providerId,$appointmentTypeId,$rate,$effectiveFrom,$effectiveTo) { + $dayBefore = (new \DateTimeImmutable($effectiveFrom))->modify('-1 day')->format('Y-m-d'); + + // Close open-ended rows that start before the new effective date + $pdo->prepare("UPDATE provider_rates + SET effective_to=?, updated_at=NOW() + WHERE provider_id=? AND appointment_type_id=? + AND effective_to IS NULL + AND effective_from < ?") + ->execute([$dayBefore, $providerId, $appointmentTypeId, $effectiveFrom]); + + // Insert new row + $pdo->prepare("INSERT INTO provider_rates(provider_id,appointment_type_id,rate,effective_from,effective_to,created_at,updated_at) + VALUES(?,?,?,?,?,NOW(),NOW())") + ->execute([$providerId, $appointmentTypeId, $rate, $effectiveFrom, $effectiveTo]); + }); + } + + public static function setEffectiveTo(int $id, ?string $effectiveTo): void { + DB::pdo()->prepare("UPDATE provider_rates SET effective_to=?, updated_at=NOW() WHERE id=?")->execute([$effectiveTo, $id]); + } + + +public static function updateRate(int $id, float $rate): void { + DB::pdo()->prepare("UPDATE provider_rates SET rate=?, updated_at=NOW() WHERE id=?")->execute([$rate, $id]); +} + +public static function deleteRate(int $id): void { + DB::pdo()->prepare("DELETE FROM provider_rates WHERE id=?")->execute([$id]); +} +} diff --git a/app/Models/Settings.php b/app/Models/Settings.php index fede6e2..e446c1f 100644 --- a/app/Models/Settings.php +++ b/app/Models/Settings.php @@ -1,50 +1,50 @@ -query("SELECT * FROM app_state WHERE id=1")->fetch(); - if (!$row) throw new \RuntimeException("Missing app_state row. Run installer."); - return $row; - } - - public static function currentSettingsVersion(): array { - $state = self::appState(); - $id = (int)$state['current_settings_version_id']; - return self::settingsVersion($id); - } - - public static function settingsVersion(int $id): array { - $st = DB::pdo()->prepare("SELECT * FROM settings_versions WHERE id=?"); - $st->execute([$id]); - $row = $st->fetch(); - if (!$row) throw new \RuntimeException("Settings version not found: $id"); - $row['config'] = json_decode($row['config_json'], true) ?: []; - return $row; - } - - public static function createSettingsVersion(int $createdBy, array $config): int { - $st = DB::pdo()->prepare("INSERT INTO settings_versions(created_by,created_at,config_json) VALUES(?,NOW(),?)"); - $st->execute([$createdBy, json_encode($config, JSON_UNESCAPED_SLASHES)]); - return (int)DB::pdo()->lastInsertId(); - } - - public static function setCurrentSettingsVersion(int $id): void { - DB::pdo()->prepare("UPDATE app_state SET current_settings_version_id=? WHERE id=1")->execute([$id]); - } - - public static function updateAppState(array $data): void { - $fields = []; - $vals = []; - foreach (['company_name','company_email','pay_period_anchor_date','pay_period_length_days'] as $k) { - if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } - } - if (!$fields) return; - $sql = "UPDATE app_state SET " . implode(',', $fields) . " WHERE id=1"; - DB::pdo()->prepare($sql)->execute($vals); - } -} +query("SELECT * FROM app_state WHERE id=1")->fetch(); + if (!$row) throw new \RuntimeException("Missing app_state row. Run installer."); + return $row; + } + + public static function currentSettingsVersion(): array { + $state = self::appState(); + $id = (int)$state['current_settings_version_id']; + return self::settingsVersion($id); + } + + public static function settingsVersion(int $id): array { + $st = DB::pdo()->prepare("SELECT * FROM settings_versions WHERE id=?"); + $st->execute([$id]); + $row = $st->fetch(); + if (!$row) throw new \RuntimeException("Settings version not found: $id"); + $row['config'] = json_decode($row['config_json'], true) ?: []; + return $row; + } + + public static function createSettingsVersion(int $createdBy, array $config): int { + $st = DB::pdo()->prepare("INSERT INTO settings_versions(created_by,created_at,config_json) VALUES(?,NOW(),?)"); + $st->execute([$createdBy, json_encode($config, JSON_UNESCAPED_SLASHES)]); + return (int)DB::pdo()->lastInsertId(); + } + + public static function setCurrentSettingsVersion(int $id): void { + DB::pdo()->prepare("UPDATE app_state SET current_settings_version_id=? WHERE id=1")->execute([$id]); + } + + public static function updateAppState(array $data): void { + $fields = []; + $vals = []; + foreach (['company_name','company_email','pay_period_anchor_date','pay_period_length_days'] as $k) { + if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } + } + if (!$fields) return; + $sql = "UPDATE app_state SET " . implode(',', $fields) . " WHERE id=1"; + DB::pdo()->prepare($sql)->execute($vals); + } +} diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 782587c..dec7f85 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -1,28 +1,28 @@ -prepare("SELECT * FROM time_entries WHERE user_id=? AND pay_period_id=?"); - $st->execute([$userId, $payPeriodId]); - $rows = $st->fetchAll(); - $map = []; - foreach ($rows as $r) $map[$r['work_date']] = $r; - return $map; - } - - public static function upsert(int $userId, int $payPeriodId, string $workDate, ?string $timeIn, ?string $timeOut): void { - $sql = "INSERT INTO time_entries(user_id,pay_period_id,work_date,time_in,time_out,created_at,updated_at) - VALUES(?,?,?,?,?,NOW(),NOW()) - ON DUPLICATE KEY UPDATE time_in=VALUES(time_in), time_out=VALUES(time_out), updated_at=NOW()"; - DB::pdo()->prepare($sql)->execute([$userId, $payPeriodId, $workDate, $timeIn, $timeOut]); - } - - public static function deleteForDate(int $userId, int $payPeriodId, string $workDate): void { - DB::pdo()->prepare("DELETE FROM time_entries WHERE user_id=? AND pay_period_id=? AND work_date=?")->execute([$userId,$payPeriodId,$workDate]); - } -} +prepare("SELECT * FROM time_entries WHERE user_id=? AND pay_period_id=?"); + $st->execute([$userId, $payPeriodId]); + $rows = $st->fetchAll(); + $map = []; + foreach ($rows as $r) $map[$r['work_date']] = $r; + return $map; + } + + public static function upsert(int $userId, int $payPeriodId, string $workDate, ?string $timeIn, ?string $timeOut): void { + $sql = "INSERT INTO time_entries(user_id,pay_period_id,work_date,time_in,time_out,created_at,updated_at) + VALUES(?,?,?,?,?,NOW(),NOW()) + ON DUPLICATE KEY UPDATE time_in=VALUES(time_in), time_out=VALUES(time_out), updated_at=NOW()"; + DB::pdo()->prepare($sql)->execute([$userId, $payPeriodId, $workDate, $timeIn, $timeOut]); + } + + public static function deleteForDate(int $userId, int $payPeriodId, string $workDate): void { + DB::pdo()->prepare("DELETE FROM time_entries WHERE user_id=? AND pay_period_id=? AND work_date=?")->execute([$userId,$payPeriodId,$workDate]); + } +} diff --git a/app/Models/Timecard.php b/app/Models/Timecard.php index a48f514..d83d08e 100644 --- a/app/Models/Timecard.php +++ b/app/Models/Timecard.php @@ -1,77 +1,89 @@ -prepare("SELECT * FROM timecards WHERE user_id=? AND pay_period_id=? LIMIT 1"); - $st->execute([$userId, $payPeriodId]); - $row = $st->fetch(); - if ($row) return $row; - DB::pdo()->prepare("INSERT INTO timecards(user_id,pay_period_id,pto_minutes,weight_loss_units,nursing_encounters,created_at,updated_at) VALUES(?,?,0,0,0,NOW(),NOW())") - ->execute([$userId, $payPeriodId]); - $st->execute([$userId, $payPeriodId]); - return $st->fetch(); - } - - public static function update(int $userId, int $payPeriodId, array $data): void { - $fields = []; - $vals = []; - foreach (['pto_minutes','weight_loss_units','nursing_encounters','submitted_at','locked_at','locked_by'] as $k) { - if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } - } - if (!$fields) return; - $vals[] = $userId; $vals[] = $payPeriodId; - $sql = "UPDATE timecards SET " . implode(',', $fields) . ", updated_at=NOW() WHERE user_id=? AND pay_period_id=?"; - DB::pdo()->prepare($sql)->execute($vals); - } - - public static function setSubmitted(int $userId, int $payPeriodId): void { - DB::pdo()->prepare("UPDATE timecards SET submitted_at=NOW(), updated_at=NOW() WHERE user_id=? AND pay_period_id=? AND locked_at IS NULL") - ->execute([$userId, $payPeriodId]); - } - - /** - * Admin helper: ensure submitted_at is set even if the card is already locked. - * We only fill it when missing so we don't overwrite an employee's original submit time. - */ - public static function markSubmittedIfNull(int $userId, int $payPeriodId): void { - DB::pdo()->prepare( - "UPDATE timecards SET submitted_at = COALESCE(submitted_at, NOW()), updated_at=NOW() WHERE user_id=? AND pay_period_id=?" - )->execute([$userId, $payPeriodId]); - } - - public static function setLocked(int $userId, int $payPeriodId, int $lockedBy): void { - DB::pdo()->prepare("UPDATE timecards SET locked_at=NOW(), locked_by=?, updated_at=NOW() WHERE user_id=? AND pay_period_id=? AND locked_at IS NULL") - ->execute([$lockedBy, $userId, $payPeriodId]); - } - - public static function setUnlocked(int $userId, int $payPeriodId): void { - DB::pdo()->prepare("UPDATE timecards SET locked_at=NULL, locked_by=NULL, updated_at=NOW() WHERE user_id=? AND pay_period_id=?") - ->execute([$userId, $payPeriodId]); - } - - public static function statusForPayPeriod(int $payPeriodId): array { - $sql = "SELECT u.id as user_id, u.full_name, u.email, u.role, u.active, - tc.submitted_at, tc.locked_at - FROM users u - LEFT JOIN timecards tc ON tc.user_id=u.id AND tc.pay_period_id=? - ORDER BY u.role DESC, u.full_name ASC"; - $st = DB::pdo()->prepare($sql); - $st->execute([$payPeriodId]); - return $st->fetchAll(); - } - - public static function sumPtoUsedYtd(int $userId, int $year): int { - $sql = "SELECT COALESCE(SUM(tc.pto_minutes),0) as m - FROM timecards tc - JOIN pay_periods pp ON pp.id=tc.pay_period_id - WHERE tc.user_id=? AND YEAR(pp.start_date)=?"; - $st = DB::pdo()->prepare($sql); - $st->execute([$userId, $year]); - return (int)($st->fetch()['m'] ?? 0); - } -} +prepare("SELECT * FROM timecards WHERE user_id=? AND pay_period_id=? LIMIT 1"); + $st->execute([$userId, $payPeriodId]); + $row = $st->fetch(); + if ($row) return $row; + DB::pdo()->prepare("INSERT INTO timecards(user_id,pay_period_id,pto_minutes,weight_loss_units,nursing_encounters,created_at,updated_at) VALUES(?,?,0,0,0,NOW(),NOW())") + ->execute([$userId, $payPeriodId]); + $st->execute([$userId, $payPeriodId]); + return $st->fetch(); + } + + public static function update(int $userId, int $payPeriodId, array $data): void { + $fields = []; + $vals = []; + foreach (['pto_minutes','weight_loss_units','nursing_encounters','submitted_at','locked_at','locked_by'] as $k) { + if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = $data[$k]; } + } + if (!$fields) return; + $vals[] = $userId; $vals[] = $payPeriodId; + $sql = "UPDATE timecards SET " . implode(',', $fields) . ", updated_at=NOW() WHERE user_id=? AND pay_period_id=?"; + DB::pdo()->prepare($sql)->execute($vals); + } + + public static function setSubmitted(int $userId, int $payPeriodId): void { + DB::pdo()->prepare("UPDATE timecards SET submitted_at=NOW(), updated_at=NOW() WHERE user_id=? AND pay_period_id=? AND locked_at IS NULL") + ->execute([$userId, $payPeriodId]); + } + + /** + * Admin helper: ensure submitted_at is set even if the card is already locked. + * We only fill it when missing so we don't overwrite an employee's original submit time. + */ + public static function markSubmittedIfNull(int $userId, int $payPeriodId): void { + DB::pdo()->prepare( + "UPDATE timecards SET submitted_at = COALESCE(submitted_at, NOW()), updated_at=NOW() WHERE user_id=? AND pay_period_id=?" + )->execute([$userId, $payPeriodId]); + } + + public static function setLocked(int $userId, int $payPeriodId, int $lockedBy): void { + DB::pdo()->prepare("UPDATE timecards SET locked_at=NOW(), locked_by=?, updated_at=NOW() WHERE user_id=? AND pay_period_id=? AND locked_at IS NULL") + ->execute([$lockedBy, $userId, $payPeriodId]); + } + + public static function setUnlocked(int $userId, int $payPeriodId): void { + DB::pdo()->prepare("UPDATE timecards SET locked_at=NULL, locked_by=NULL, updated_at=NOW() WHERE user_id=? AND pay_period_id=?") + ->execute([$userId, $payPeriodId]); + } + + public static function statusForPayPeriod(int $payPeriodId): array { + $sql = "SELECT u.id as user_id, u.full_name, u.email, u.role, u.active, + tc.submitted_at, tc.locked_at + FROM users u + LEFT JOIN timecards tc ON tc.user_id=u.id AND tc.pay_period_id=? + ORDER BY u.role DESC, u.full_name ASC"; + $st = DB::pdo()->prepare($sql); + $st->execute([$payPeriodId]); + return $st->fetchAll(); + } + + public static function sumPtoUsedYtd(int $userId, int $year): int { + $sql = "SELECT COALESCE(SUM(tc.pto_minutes),0) as m + FROM timecards tc + JOIN pay_periods pp ON pp.id=tc.pay_period_id + WHERE tc.user_id=? AND YEAR(pp.start_date)=?"; + $st = DB::pdo()->prepare($sql); + $st->execute([$userId, $year]); + return (int)($st->fetch()['m'] ?? 0); + } + + public static function sumPtoUsedYtdThroughDate(int $userId, int $year, string $throughEndDate): int { + $sql = "SELECT COALESCE(SUM(tc.pto_minutes),0) as m + FROM timecards tc + JOIN pay_periods pp ON pp.id=tc.pay_period_id + WHERE tc.user_id=? + AND YEAR(pp.start_date)=? + AND pp.end_date <= ?"; + $st = DB::pdo()->prepare($sql); + $st->execute([$userId, $year, $throughEndDate]); + return (int)($st->fetch()['m'] ?? 0); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 698ca91..09ae1cb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,62 +1,62 @@ -prepare("SELECT * FROM users WHERE id=? LIMIT 1"); - $st->execute([$id]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function findByEmail(string $email): ?array { - $st = DB::pdo()->prepare("SELECT * FROM users WHERE email=? LIMIT 1"); - $st->execute([strtolower(trim($email))]); - $row = $st->fetch(); - return $row ?: null; - } - - public static function all(): array { - return DB::pdo()->query("SELECT * FROM users ORDER BY role DESC, full_name ASC")->fetchAll(); - } - - public static function create(array $data): int { - $st = DB::pdo()->prepare("INSERT INTO users(full_name,email,password_hash,role,active,weight_loss_consultant,nursing_encounters_enabled,pto_bank_minutes,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,NOW(),NOW())"); - $st->execute([ - $data['full_name'], - strtolower(trim($data['email'])), - $data['password_hash'], - $data['role'] ?? 'employee', - $data['active'] ?? 1, - $data['weight_loss_consultant'] ?? 0, - $data['nursing_encounters_enabled'] ?? 0, - $data['pto_bank_minutes'] ?? 0, - ]); - return (int)DB::pdo()->lastInsertId(); - } - - public static function update(int $id, array $data): void { - $fields = []; - $vals = []; - foreach (['full_name','email','role','active','weight_loss_consultant','nursing_encounters_enabled','pto_bank_minutes'] as $k) { - if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = ($k==='email'? strtolower(trim((string)$data[$k])) : $data[$k]); } - } - if (!$fields) return; - $vals[] = $id; - $sql = "UPDATE users SET " . implode(',', $fields) . ", updated_at=NOW() WHERE id=?"; - DB::pdo()->prepare($sql)->execute($vals); - } - - public static function setPassword(int $id, string $password_hash): void { - DB::pdo()->prepare("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?")->execute([$password_hash, $id]); - } - - public static function deleteHard(int $id): void { - DB::pdo()->prepare("DELETE FROM users WHERE id=?")->execute([$id]); - } -} +prepare("SELECT * FROM users WHERE id=? LIMIT 1"); + $st->execute([$id]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function findByEmail(string $email): ?array { + $st = DB::pdo()->prepare("SELECT * FROM users WHERE email=? LIMIT 1"); + $st->execute([strtolower(trim($email))]); + $row = $st->fetch(); + return $row ?: null; + } + + public static function all(): array { + return DB::pdo()->query("SELECT * FROM users ORDER BY role DESC, full_name ASC")->fetchAll(); + } + + public static function create(array $data): int { + $st = DB::pdo()->prepare("INSERT INTO users(full_name,email,password_hash,role,active,weight_loss_consultant,nursing_encounters_enabled,pto_bank_minutes,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,NOW(),NOW())"); + $st->execute([ + $data['full_name'], + strtolower(trim($data['email'])), + $data['password_hash'], + $data['role'] ?? 'employee', + $data['active'] ?? 1, + $data['weight_loss_consultant'] ?? 0, + $data['nursing_encounters_enabled'] ?? 0, + $data['pto_bank_minutes'] ?? 0, + ]); + return (int)DB::pdo()->lastInsertId(); + } + + public static function update(int $id, array $data): void { + $fields = []; + $vals = []; + foreach (['full_name','email','role','active','weight_loss_consultant','nursing_encounters_enabled','pto_bank_minutes'] as $k) { + if (array_key_exists($k, $data)) { $fields[] = "$k=?"; $vals[] = ($k==='email'? strtolower(trim((string)$data[$k])) : $data[$k]); } + } + if (!$fields) return; + $vals[] = $id; + $sql = "UPDATE users SET " . implode(',', $fields) . ", updated_at=NOW() WHERE id=?"; + DB::pdo()->prepare($sql)->execute($vals); + } + + public static function setPassword(int $id, string $password_hash): void { + DB::pdo()->prepare("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?")->execute([$password_hash, $id]); + } + + public static function deleteHard(int $id): void { + DB::pdo()->prepare("DELETE FROM users WHERE id=?")->execute([$id]); + } +} diff --git a/app/Services/LoginThrottle.php b/app/Services/LoginThrottle.php new file mode 100644 index 0000000..a1d846b --- /dev/null +++ b/app/Services/LoginThrottle.php @@ -0,0 +1,57 @@ += $cutoff; + })); + } + + private static function store(array $timestamps): void { + $dir = self::directory(); + if (!is_dir($dir)) @mkdir($dir, 0755, true); + if (!is_dir($dir) || !is_writable($dir)) return; + + @file_put_contents(self::filePath(), json_encode(array_values($timestamps)), LOCK_EX); + } + + public static function tooManyAttempts(): bool { + $failures = self::recentFailures(); + self::store($failures); + return count($failures) >= self::MAX_FAILURES; + } + + public static function recordFailure(): void { + $failures = self::recentFailures(); + $failures[] = time(); + self::store($failures); + } + + public static function clear(): void { + $path = self::filePath(); + if (is_file($path)) @unlink($path); + } +} diff --git a/app/Services/MailerService.php b/app/Services/MailerService.php index 0e7e966..26a2b1c 100644 --- a/app/Services/MailerService.php +++ b/app/Services/MailerService.php @@ -1,32 +1,32 @@ -format('m/d/Y') : $ymd; - } - - /** - * Times are stored as HH:MM or HH:MM:SS (24h). Display as h:mm AM/PM. - */ - private static function fmtTime(?string $t): string { - if (!$t) return ''; - $t = trim($t); - if ($t === '') return ''; - // Already formatted (e.g., "6:30 AM") - if (preg_match('/\b(AM|PM)\b/i', $t)) return $t; - $hhmm = substr($t, 0, 5); - $dt = \DateTimeImmutable::createFromFormat('H:i', $hhmm); - return $dt ? $dt->format('g:i A') : $hhmm; - } - - private static function hoursDecimal(int $minutes): string { - // Keep 2 decimals for payroll friendliness, e.g. 67.75 - return number_format($minutes / 60, 2, '.', ''); - } - - private static function pdfEscape(string $s): string { - return str_replace(['\\', '(', ')', "\r"], ['\\\\', '\(', '\)', ''], $s); - } - - private static function makePdf(array $linesByPage): string { - // Minimal PDF 1.4 generator with Helvetica Type1 - $objects = []; - $offsets = []; - - $addObj = function(string $obj) use (&$objects) { - $objects[] = $obj; - return count($objects); - }; - - // Use a monospaced font so columns line up (spaces align). - $fontObjNum = $addObj("<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"); - - $pageKids = []; - $contentsObjNums = []; - foreach ($linesByPage as $pageLines) { - $text = "BT\n/F1 10 Tf\n72 760 Td\n12 TL\n"; - foreach ($pageLines as $i => $line) { - if ($i === 0) { - $text .= "(" . self::pdfEscape($line) . ") Tj\n"; - } else { - $text .= "T*\n(" . self::pdfEscape($line) . ") Tj\n"; - } - } - $text .= "ET\n"; - $stream = "<< /Length " . strlen($text) . " >>\nstream\n" . $text . "endstream"; - $contentsObjNums[] = $addObj($stream); - } - - $pagesObjNum = 0; // placeholder - // Create page objects - foreach ($linesByPage as $idx => $_) { - $contentNum = $contentsObjNums[$idx]; - $pageObj = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 {$fontObjNum} 0 R >> >> /Contents {$contentNum} 0 R >>"; - $pageKids[] = $addObj($pageObj); - } - - // Pages object (must be object 2 for the hard-coded Parent above) - // We'll insert as object #2 by building final list carefully. - // Easiest: rebuild objects with fixed numbering. - // We'll rebuild now: - - $rebuilt = []; - $rebuilt[] = null; // index 0 unused - $rebuilt[] = "<< /Type /Catalog /Pages 2 0 R >>"; // 1 - // 2 pages object - $kidsRefs = implode(' ', array_map(fn($n) => "{$n} 0 R", range(4, 3 + count($linesByPage)))); - $rebuilt[] = "<< /Type /Pages /Count " . count($linesByPage) . " /Kids [ {$kidsRefs} ] >>"; // 2 - // 3 font - // 3 font (monospace) - $rebuilt[] = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"; // 3 - - // Page objects start at 4 - $pageCount = count($linesByPage); - for ($i=0; $i<$pageCount; $i++){ - $contentObjNum = 4 + $pageCount + $i; // contents start after all pages - $rebuilt[] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents {$contentObjNum} 0 R >>"; - } - - // Contents objects - foreach ($linesByPage as $pageLines) { - $text = "BT\n/F1 10 Tf\n72 760 Td\n12 TL\n"; - foreach ($pageLines as $i => $line) { - if ($i === 0) $text .= "(" . self::pdfEscape($line) . ") Tj\n"; - else $text .= "T*\n(" . self::pdfEscape($line) . ") Tj\n"; - } - $text .= "ET\n"; - $rebuilt[] = "<< /Length " . strlen($text) . " >>\nstream\n" . $text . "endstream"; - } - - // Build PDF with xref - $pdf = "%PDF-1.4\n"; - $xref = "xref\n0 " . count($rebuilt) . "\n"; - $xref .= "0000000000 65535 f \n"; - $offset = strlen($pdf); - for ($i=1; $i $off) { - $xref .= str_pad((string)$off, 10, '0', STR_PAD_LEFT) . " 00000 n \n"; - } - $trailer = "trailer\n<< /Size " . count($rebuilt) . " /Root 1 0 R >>\nstartxref\n{$offset}\n%%EOF"; - return $pdf . $xref . $trailer; - } - - public static function generatePayPeriodPdf(int $payPeriodId, string $saveDir): array { - $pp = PayPeriod::findById($payPeriodId); - if (!$pp) throw new \RuntimeException("Pay period not found."); - $start = $pp['start_date']; - $end = $pp['end_date']; - - $state = Settings::appState(); - $company = $state['company_name'] ?: 'TimeClock'; - $emailTo = $state['company_email'] ?: ''; - - $users = DB::pdo()->query("SELECT * FROM users WHERE active=1 ORDER BY role DESC, full_name ASC")->fetchAll(); - - $lines = []; - $lines[] = "{$company} - Timecard Report"; - $lines[] = "Pay Period: " . self::fmtDate((string)$start) . " through " . self::fmtDate((string)$end); - $lines[] = str_repeat('-', 72); - $lines[] = ""; - - foreach ($users as $u) { - if ($u['role'] !== 'employee' && $u['role'] !== 'super') continue; - $uid = (int)$u['id']; - $tc = \App\Models\Timecard::ensure($uid, $payPeriodId); - $entries = \App\Models\TimeEntry::byUserPeriod($uid, $payPeriodId); - - $lines[] = "Employee: " . $u['full_name'] . " <" . $u['email'] . ">"; - $lines[] = "Submitted: " . ($tc['submitted_at'] ? (string)$tc['submitted_at'] : 'No') . " Locked: " . ($tc['locked_at'] ? (string)$tc['locked_at'] : 'No'); - - // Fixed-width columns for consistent alignment. - // Date=10 (MM/DD/YYYY), In=8 ("12:00 PM"), Out=8, Hours=6 ("100.00") - $lines[] = sprintf('%-10s %-8s %-8s %6s', 'Date', 'In', 'Out', 'Hours'); - $lines[] = sprintf('%-10s %-8s %-8s %6s', str_repeat('-', 10), str_repeat('-', 8), str_repeat('-', 8), str_repeat('-', 6)); - - $sum = 0; - $d = new \DateTimeImmutable($start); - $endD = new \DateTimeImmutable($end); - while ($d <= $endD) { - $day = $d->format('Y-m-d'); - $e = $entries[$day] ?? null; - $in = $e ? self::fmtTime($e['time_in']) : ''; - $out = $e ? self::fmtTime($e['time_out']) : ''; - $dur = $e ? \App\Services\TimeService::durationMinutes($e['time_in'], $e['time_out']) : 0; - $sum += $dur; - $h = $dur ? self::hoursDecimal($dur) : ''; - $lines[] = sprintf('%-10s %-8s %-8s %6s', self::fmtDate($day), $in, $out, $h); - $d = $d->modify('+1 day'); - } - - $pto = (int)($tc['pto_minutes'] ?? 0); - $grand = $sum + $pto; - $lines[] = ""; - $lines[] = "Work Hours (decimal): " . self::hoursDecimal($sum); - $lines[] = "PTO Hours (decimal): " . self::hoursDecimal($pto); - - $nursingEncounters = (int)($tc['nursing_encounters'] ?? 0); - if (!empty($u['nursing_encounters_enabled'])) { - $lines[] = "Nursing Encounters: " . $nursingEncounters . " x rate = ____________"; - $lines[] = "Grand Total(decimal): " . self::hoursDecimal($grand) . " + Nursing Encounters Total"; - } else { - $lines[] = "Grand Total(decimal): " . self::hoursDecimal($grand); - } - - $wlUnits = (int)($tc['weight_loss_units'] ?? 0); - if (!empty($u['weight_loss_consultant']) && $wlUnits > 0) { - $lines[] = "Weight Loss Programs: " . $wlUnits; - $lines[] = "Weight Loss Bonus: $" . number_format($wlUnits * 20, 2); - } - - $lines[] = str_repeat('-', 72); - $lines[] = ""; - } - - -// --- Provider Production (admin-only) --- -try { - $providers = DB::pdo()->query("SELECT * FROM providers WHERE active=1 ORDER BY provider_type ASC, full_name ASC")->fetchAll(); -} catch (\Throwable $e) { - $providers = []; -} -if ($providers) { - $lines[] = ""; - $lines[] = "PROVIDER PRODUCTION"; - $lines[] = str_repeat('-', 72); - $lines[] = ""; - - foreach ($providers as $p) { - $pid = (int)$p['id']; - ProviderProduction::ensureRows($pid, $payPeriodId); - $rows = ProviderProduction::rowsForProviderPeriod($pid, $payPeriodId); - - $lines[] = "Provider: " . $p['full_name'] . " (" . strtoupper((string)$p['provider_type']) . ")"; - // Fixed-width columns: Type=20, Count=5, Rate=8, Total=10 - $lines[] = sprintf('%-20s %5s %8s %10s', 'Type', 'Count', 'Rate', 'Line Total'); - $lines[] = sprintf('%-20s %5s %8s %10s', str_repeat('-', 20), str_repeat('-', 5), str_repeat('-', 8), str_repeat('-', 10)); - - $provTotal = 0.0; - foreach ($rows as $r) { - $typeName = (string)$r['type_name']; - $count = (int)$r['count']; - - $rate = $r['rate_used'] !== null ? (float)$r['rate_used'] : (ProviderRate::effectiveRate($pid, (int)$r['appointment_type_id'], (string)$end) ?? 0.0); - $lineTotal = $count * $rate; - $provTotal += $lineTotal; - - $lines[] = sprintf('%-20s %5d %8s %10s', - (function_exists('mb_strimwidth') ? mb_strimwidth($typeName, 0, 20, '') : substr($typeName, 0, 20)), - $count, - number_format($rate, 2, '.', ''), - number_format($lineTotal, 2, '.', '') - ); - } - - $lines[] = ""; - $lines[] = "Provider Total: " . number_format($provTotal, 2, '.', ''); - - // Provider PTO (hours) is tracked separately from production dollars - $ptoBank = (int)($p['pto_bank_minutes'] ?? 0); - $ptoThis = ProviderPto::minutesForProviderPeriod($pid, $payPeriodId); - if ($ptoBank > 0 || $ptoThis > 0) { - $year = (int)substr((string)$start, 0, 4); - // YTD should be "as of" this report's pay period end date (ignore PTO entered on future periods) - $ptoUsedYtd = ProviderPto::sumUsedYtdThroughDate($pid, $year, (string)$end); - $ptoAvail = max(0, $ptoBank - $ptoUsedYtd); - - $lines[] = "PTO This Period (hrs): " . minutes_to_hours_decimal($ptoThis); - $lines[] = "PTO Bank (hrs): " . minutes_to_hours_decimal($ptoBank); - $lines[] = "PTO Used YTD (hrs): " . minutes_to_hours_decimal($ptoUsedYtd); - $lines[] = "PTO Remaining (hrs): " . minutes_to_hours_decimal($ptoAvail); - } - $lines[] = str_repeat('-', 72); - $lines[] = ""; - } -} - -// paginate - $linesPerPage = 52; - $pages = []; - for ($i=0; $i $filename, 'path' => $path, 'email_to' => $emailTo]; - } -} +format('m/d/Y') : $ymd; + } + + /** + * Times are stored as HH:MM or HH:MM:SS (24h). Display as h:mm AM/PM. + */ + private static function fmtTime(?string $t): string { + if (!$t) return ''; + $t = trim($t); + if ($t === '') return ''; + // Already formatted (e.g., "6:30 AM") + if (preg_match('/\b(AM|PM)\b/i', $t)) return $t; + $hhmm = substr($t, 0, 5); + $dt = \DateTimeImmutable::createFromFormat('H:i', $hhmm); + return $dt ? $dt->format('g:i A') : $hhmm; + } + + private static function hoursDecimal(int $minutes): string { + // Keep 2 decimals for payroll friendliness, e.g. 67.75 + return number_format($minutes / 60, 2, '.', ''); + } + + private static function pdfEscape(string $s): string { + return str_replace(['\\', '(', ')', "\r"], ['\\\\', '\(', '\)', ''], $s); + } + + private static function makePdf(array $linesByPage): string { + // Minimal PDF 1.4 generator with Helvetica Type1 + $objects = []; + $offsets = []; + + $addObj = function(string $obj) use (&$objects) { + $objects[] = $obj; + return count($objects); + }; + + // Use a monospaced font so columns line up (spaces align). + $fontObjNum = $addObj("<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"); + + $pageKids = []; + $contentsObjNums = []; + foreach ($linesByPage as $pageLines) { + $text = "BT\n/F1 10 Tf\n72 760 Td\n12 TL\n"; + foreach ($pageLines as $i => $line) { + if ($i === 0) { + $text .= "(" . self::pdfEscape($line) . ") Tj\n"; + } else { + $text .= "T*\n(" . self::pdfEscape($line) . ") Tj\n"; + } + } + $text .= "ET\n"; + $stream = "<< /Length " . strlen($text) . " >>\nstream\n" . $text . "endstream"; + $contentsObjNums[] = $addObj($stream); + } + + $pagesObjNum = 0; // placeholder + // Create page objects + foreach ($linesByPage as $idx => $_) { + $contentNum = $contentsObjNums[$idx]; + $pageObj = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 {$fontObjNum} 0 R >> >> /Contents {$contentNum} 0 R >>"; + $pageKids[] = $addObj($pageObj); + } + + // Pages object (must be object 2 for the hard-coded Parent above) + // We'll insert as object #2 by building final list carefully. + // Easiest: rebuild objects with fixed numbering. + // We'll rebuild now: + + $rebuilt = []; + $rebuilt[] = null; // index 0 unused + $rebuilt[] = "<< /Type /Catalog /Pages 2 0 R >>"; // 1 + // 2 pages object + $kidsRefs = implode(' ', array_map(fn($n) => "{$n} 0 R", range(4, 3 + count($linesByPage)))); + $rebuilt[] = "<< /Type /Pages /Count " . count($linesByPage) . " /Kids [ {$kidsRefs} ] >>"; // 2 + // 3 font + // 3 font (monospace) + $rebuilt[] = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"; // 3 + + // Page objects start at 4 + $pageCount = count($linesByPage); + for ($i=0; $i<$pageCount; $i++){ + $contentObjNum = 4 + $pageCount + $i; // contents start after all pages + $rebuilt[] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents {$contentObjNum} 0 R >>"; + } + + // Contents objects + foreach ($linesByPage as $pageLines) { + $text = "BT\n/F1 10 Tf\n72 760 Td\n12 TL\n"; + foreach ($pageLines as $i => $line) { + if ($i === 0) $text .= "(" . self::pdfEscape($line) . ") Tj\n"; + else $text .= "T*\n(" . self::pdfEscape($line) . ") Tj\n"; + } + $text .= "ET\n"; + $rebuilt[] = "<< /Length " . strlen($text) . " >>\nstream\n" . $text . "endstream"; + } + + // Build PDF with xref + $pdf = "%PDF-1.4\n"; + $xref = "xref\n0 " . count($rebuilt) . "\n"; + $xref .= "0000000000 65535 f \n"; + $offset = strlen($pdf); + for ($i=1; $i $off) { + $xref .= str_pad((string)$off, 10, '0', STR_PAD_LEFT) . " 00000 n \n"; + } + $trailer = "trailer\n<< /Size " . count($rebuilt) . " /Root 1 0 R >>\nstartxref\n{$offset}\n%%EOF"; + return $pdf . $xref . $trailer; + } + + public static function generatePayPeriodPdf(int $payPeriodId, string $saveDir): array { + $pp = PayPeriod::findById($payPeriodId); + if (!$pp) throw new \RuntimeException("Pay period not found."); + $start = $pp['start_date']; + $end = $pp['end_date']; + + $state = Settings::appState(); + $company = $state['company_name'] ?: 'TimeClock'; + $emailTo = $state['company_email'] ?: ''; + + $users = DB::pdo()->query("SELECT * FROM users WHERE active=1 ORDER BY role DESC, full_name ASC")->fetchAll(); + + $lines = []; + $lines[] = "{$company} - Timecard Report"; + $lines[] = "Pay Period: " . self::fmtDate((string)$start) . " through " . self::fmtDate((string)$end); + $lines[] = str_repeat('-', 72); + $lines[] = ""; + + foreach ($users as $u) { + if ($u['role'] !== 'employee' && $u['role'] !== 'super') continue; + $uid = (int)$u['id']; + $tc = \App\Models\Timecard::ensure($uid, $payPeriodId); + $entries = \App\Models\TimeEntry::byUserPeriod($uid, $payPeriodId); + + $lines[] = "Employee: " . $u['full_name'] . " <" . $u['email'] . ">"; + $lines[] = "Submitted: " . ($tc['submitted_at'] ? (string)$tc['submitted_at'] : 'No') . " Locked: " . ($tc['locked_at'] ? (string)$tc['locked_at'] : 'No'); + + // Fixed-width columns for consistent alignment. + // Date=10 (MM/DD/YYYY), In=8 ("12:00 PM"), Out=8, Hours=6 ("100.00") + $lines[] = sprintf('%-10s %-8s %-8s %6s', 'Date', 'In', 'Out', 'Hours'); + $lines[] = sprintf('%-10s %-8s %-8s %6s', str_repeat('-', 10), str_repeat('-', 8), str_repeat('-', 8), str_repeat('-', 6)); + + $sum = 0; + $d = new \DateTimeImmutable($start); + $endD = new \DateTimeImmutable($end); + while ($d <= $endD) { + $day = $d->format('Y-m-d'); + $e = $entries[$day] ?? null; + $in = $e ? self::fmtTime($e['time_in']) : ''; + $out = $e ? self::fmtTime($e['time_out']) : ''; + $dur = $e ? \App\Services\TimeService::durationMinutes($e['time_in'], $e['time_out']) : 0; + $sum += $dur; + $h = $dur ? self::hoursDecimal($dur) : ''; + $lines[] = sprintf('%-10s %-8s %-8s %6s', self::fmtDate($day), $in, $out, $h); + $d = $d->modify('+1 day'); + } + + $pto = (int)($tc['pto_minutes'] ?? 0); + $grand = $sum + $pto; + $lines[] = ""; + $lines[] = "Work Hours (decimal): " . self::hoursDecimal($sum); + $lines[] = "PTO Hours (decimal): " . self::hoursDecimal($pto); + + $nursingEncounters = (int)($tc['nursing_encounters'] ?? 0); + if (!empty($u['nursing_encounters_enabled'])) { + $lines[] = "Nursing Encounters: " . $nursingEncounters . " x rate = ____________"; + $lines[] = "Grand Total(decimal): " . self::hoursDecimal($grand) . " + Nursing Encounters Total"; + } else { + $lines[] = "Grand Total(decimal): " . self::hoursDecimal($grand); + } + + $wlUnits = (int)($tc['weight_loss_units'] ?? 0); + if (!empty($u['weight_loss_consultant']) && $wlUnits > 0) { + $lines[] = "Weight Loss Programs: " . $wlUnits; + $lines[] = "Weight Loss Bonus: $" . number_format($wlUnits * 20, 2); + } + + $lines[] = str_repeat('-', 72); + $lines[] = ""; + } + + +// --- Provider Production (admin-only) --- +try { + $providers = DB::pdo()->query("SELECT * FROM providers WHERE active=1 ORDER BY provider_type ASC, full_name ASC")->fetchAll(); +} catch (\Throwable $e) { + $providers = []; +} +if ($providers) { + $lines[] = ""; + $lines[] = "PROVIDER PRODUCTION"; + $lines[] = str_repeat('-', 72); + $lines[] = ""; + + foreach ($providers as $p) { + $pid = (int)$p['id']; + ProviderProduction::ensureRows($pid, $payPeriodId); + $rows = ProviderProduction::rowsForProviderPeriod($pid, $payPeriodId); + + $lines[] = "Provider: " . $p['full_name'] . " (" . strtoupper((string)$p['provider_type']) . ")"; + // Fixed-width columns: Type=20, Count=5, Rate=8, Total=10 + $lines[] = sprintf('%-20s %5s %8s %10s', 'Type', 'Count', 'Rate', 'Line Total'); + $lines[] = sprintf('%-20s %5s %8s %10s', str_repeat('-', 20), str_repeat('-', 5), str_repeat('-', 8), str_repeat('-', 10)); + + $provTotal = 0.0; + foreach ($rows as $r) { + $typeName = (string)$r['type_name']; + $count = (int)$r['count']; + + $rate = $r['rate_used'] !== null ? (float)$r['rate_used'] : (ProviderRate::effectiveRate($pid, (int)$r['appointment_type_id'], (string)$end) ?? 0.0); + $lineTotal = $count * $rate; + $provTotal += $lineTotal; + + $lines[] = sprintf('%-20s %5d %8s %10s', + (function_exists('mb_strimwidth') ? mb_strimwidth($typeName, 0, 20, '') : substr($typeName, 0, 20)), + $count, + number_format($rate, 2, '.', ''), + number_format($lineTotal, 2, '.', '') + ); + } + + $lines[] = ""; + $lines[] = "Provider Total: " . number_format($provTotal, 2, '.', ''); + + // Provider PTO (hours) is tracked separately from production dollars + $ptoBank = (int)($p['pto_bank_minutes'] ?? 0); + $ptoThis = ProviderPto::minutesForProviderPeriod($pid, $payPeriodId); + if ($ptoBank > 0 || $ptoThis > 0) { + $year = (int)substr((string)$start, 0, 4); + // YTD should be "as of" this report's pay period end date (ignore PTO entered on future periods) + $ptoUsedYtd = ProviderPto::sumUsedYtdThroughDate($pid, $year, (string)$end); + $ptoAvail = max(0, $ptoBank - $ptoUsedYtd); + + $lines[] = "PTO This Period (hrs): " . minutes_to_hours_decimal($ptoThis); + $lines[] = "PTO Bank (hrs): " . minutes_to_hours_decimal($ptoBank); + $lines[] = "PTO Used YTD (hrs): " . minutes_to_hours_decimal($ptoUsedYtd); + $lines[] = "PTO Remaining (hrs): " . minutes_to_hours_decimal($ptoAvail); + } + $lines[] = str_repeat('-', 72); + $lines[] = ""; + } +} + +// paginate + $linesPerPage = 52; + $pages = []; + for ($i=0; $i $filename, 'path' => $path, 'email_to' => $emailTo]; + } +} diff --git a/app/Services/TimeService.php b/app/Services/TimeService.php index 5bd253f..761c485 100644 --- a/app/Services/TimeService.php +++ b/app/Services/TimeService.php @@ -1,87 +1,135 @@ - 59) continue; - $candidates[] = $h*60 + $am; - } - sort($candidates); - - if (!$candidates) return $minutes; - - if ($mode === 'down') { - $best = $candidates[0]; - foreach ($candidates as $c) if ($c <= $minutes) $best = $c; - // if minutes is before first candidate, go to previous hour last candidate - if ($minutes < $candidates[0]) { - $prevh = max(0, $h-1); - $best = $prevh*60 + (int)max($allowed); - } - return $best; - } - - if ($mode === 'up') { - foreach ($candidates as $c) if ($c >= $minutes) return $c; - // after last candidate, go to next hour first candidate - $nexth = min(23, $h+1); - return $nexth*60 + (int)min($allowed); - } - - // nearest - $best = $candidates[0]; - $bestDist = abs($best - $minutes); - foreach ($candidates as $c) { - $d = abs($c - $minutes); - if ($d < $bestDist) { $best = $c; $bestDist = $d; } - } - // if before first candidate and nearest would be that candidate, ok; no cross-hour nearest for simplicity - return $best; - } - - public static function roundTime(?string $timeHHMM, array $cfg): ?string { - $m = self::timeToMinutes($timeHHMM); - if ($m === null) return null; - $rm = self::roundMinutes($m, $cfg); - return self::minutesToTime($rm); - } - - public static function durationMinutes(?string $in, ?string $out): int { - $mi = self::timeToMinutes($in); - $mo = self::timeToMinutes($out); - if ($mi === null || $mo === null) return 0; - $d = $mo - $mi; - if ($d < 0) return 0; - return $d; - } - - public static function minutesToDecimalHours(int $minutes): float { - return round($minutes / 60.0, 2); - } -} + 23 || $minutes > 59) return null; + return $hours * 60 + $minutes; + } + + public static function minutesToTime(?int $m): ?string { + if ($m === null) return null; + $m = max(0, min(1439, $m)); + $h = intdiv($m, 60); + $mm = $m % 60; + return sprintf('%02d:%02d:00', $h, $mm); + } + + public static function roundMinutes(int $minutes, array $cfg): int { + $allowed = $cfg['allowed_minutes'] ?? [0,15,30,45]; + $mode = $cfg['rounding_mode'] ?? 'nearest'; // nearest|down|up + $h = intdiv($minutes, 60); + $m = $minutes % 60; + + // Find closest allowed minute for this hour. + $candidates = []; + foreach ($allowed as $am) { + $am = (int)$am; + if ($am < 0 || $am > 59) continue; + $candidates[] = $h*60 + $am; + } + sort($candidates); + + if (!$candidates) return $minutes; + + if ($mode === 'down') { + $best = $candidates[0]; + foreach ($candidates as $c) if ($c <= $minutes) $best = $c; + // if minutes is before first candidate, go to previous hour last candidate + if ($minutes < $candidates[0]) { + $prevh = max(0, $h-1); + $best = $prevh*60 + (int)max($allowed); + } + return $best; + } + + if ($mode === 'up') { + foreach ($candidates as $c) if ($c >= $minutes) return $c; + // after last candidate, go to next hour first candidate + $nexth = min(23, $h+1); + return $nexth*60 + (int)min($allowed); + } + + // nearest + $best = $candidates[0]; + $bestDist = abs($best - $minutes); + foreach ($candidates as $c) { + $d = abs($c - $minutes); + if ($d < $bestDist) { $best = $c; $bestDist = $d; } + } + // if before first candidate and nearest would be that candidate, ok; no cross-hour nearest for simplicity + return $best; + } + + public static function roundTime(?string $timeHHMM, array $cfg): ?string { + $m = self::timeToMinutes($timeHHMM); + if ($m === null) return null; + $rm = self::roundMinutes($m, $cfg); + return self::minutesToTime($rm); + } + + /** + * Validate and normalize submitted time-entry rows before any database writes. + * Unknown dates and malformed time values are rejected rather than silently saved. + */ + public static function normalizeRows(array $rows, array $payPeriod, array $cfg): array { + $startText = (string)($payPeriod['start_date'] ?? ''); + $endText = (string)($payPeriod['end_date'] ?? ''); + $start = \DateTimeImmutable::createFromFormat('!Y-m-d', $startText); + $end = \DateTimeImmutable::createFromFormat('!Y-m-d', $endText); + if (!$start || !$end || $start->format('Y-m-d') !== $startText || $end->format('Y-m-d') !== $endText || $end < $start) { + throw new \InvalidArgumentException('The selected pay period is invalid.'); + } + + $daysToShow = array_values(array_filter( + array_map('intval', (array)($cfg['days_to_show'] ?? [1,2,3,4,5,6])), + static fn(int $day): bool => $day >= 1 && $day <= 7 + )); + $allowedDates = []; + for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) { + if (in_array((int)$date->format('N'), $daysToShow, true)) { + $allowedDates[$date->format('Y-m-d')] = true; + } + } + + $normalized = []; + foreach ($rows as $workDate => $values) { + if (!is_string($workDate) || !isset($allowedDates[$workDate]) || !is_array($values)) { + throw new \InvalidArgumentException('A submitted timecard row is outside the selected pay period.'); + } + + $timeInText = trim((string)($values['in'] ?? '')); + $timeOutText = trim((string)($values['out'] ?? '')); + $timeIn = $timeInText === '' ? null : self::roundTime($timeInText, $cfg); + $timeOut = $timeOutText === '' ? null : self::roundTime($timeOutText, $cfg); + + if (($timeInText !== '' && $timeIn === null) || ($timeOutText !== '' && $timeOut === null)) { + throw new \InvalidArgumentException('A submitted time value is invalid.'); + } + + $normalized[$workDate] = ['in' => $timeIn, 'out' => $timeOut]; + } + + return $normalized; + } + + public static function durationMinutes(?string $in, ?string $out): int { + $mi = self::timeToMinutes($in); + $mo = self::timeToMinutes($out); + if ($mi === null || $mo === null) return 0; + $d = $mo - $mi; + if ($d < 0) return 0; + return $d; + } + + public static function minutesToDecimalHours(int $minutes): float { + return round($minutes / 60.0, 2); + } +} diff --git a/app/Views/admin/appointment_types.php b/app/Views/admin/appointment_types.php index 2ae7ce8..55eeb94 100644 --- a/app/Views/admin/appointment_types.php +++ b/app/Views/admin/appointment_types.php @@ -1,135 +1,135 @@ - -
-
-
-
Appointment Types
-
Add and manage appointment categories (future-proof)
-
- -
- -
-
-
-
Chiropractor
-
- - - - - - - - - - - - -
NameSortActive
Yes' : 'No' ?> -
- Edit - -
- - - - -
- -
- - - -
-
-
-
-
- -
-
Massage
-
- - - - - - - - - - - - -
NameSortActive
Yes' : 'No' ?> -
- Edit - -
- - - - -
- -
- - - -
-
-
-
-
-
- -
- -
-
Add / Update Type
-
-
- -
Editing: Cancel
- - - -
-
- - -
-
- - -
-
-
-
- - -
-
- -
-
-
- -
-
-
-
- -
-
+ +
+
+
+
Appointment Types
+
Add and manage appointment categories (future-proof)
+
+ +
+ +
+
+
+
Chiropractor
+
+ + + + + + + + + + + + +
NameSortActive
Yes' : 'No' ?> +
+ Edit + +
+ + + + +
+ +
+ + + +
+
+
+
+
+ +
+
Massage
+
+ + + + + + + + + + + + +
NameSortActive
Yes' : 'No' ?> +
+ Edit + +
+ + + + +
+ +
+ + + +
+
+
+
+
+
+ +
+ +
+
Add / Update Type
+
+
+ +
Editing: Cancel
+ + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+ +
+
diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php index 17b3069..b321303 100644 --- a/app/Views/admin/dashboard.php +++ b/app/Views/admin/dashboard.php @@ -1,109 +1,109 @@ - -
-
-
-
Admin Dashboard
-
Pay Period: through
-
- - -
- -
-
- - Pay Period Locked - - Pay Period Open - -
-
-
-
- Status: Below shows each employee and whether they have submitted their final timecard for the current two-week pay period. -
- - - - - - - - - - - - - - - - - - - - - - -
EmployeeEmailSubmittedLocked
- YesNo - - YesNo - - View/Edit -
- -
- - -
- - - -
- -
- - - -
-
- After correcting a past employee timecard or provider production card, use this to overwrite the saved PDF and email the updated report again. -
- - -
-
-
Recent Pay Periods
-
-
- - - - - - - - - - - -
StartEndLocked
Yes' : 'No' ?>
-
- Tip: Switch pay periods above to review late entries. For a locked period, correct the card, re-lock it, then use Regenerate PDF + Email. -
-
-
-
-
+ +
+
+
+
Admin Dashboard
+
Pay Period: through
+
+ + +
+ +
+
+ + Pay Period Locked + + Pay Period Open + +
+
+
+
+ Status: Below shows each employee and whether they have submitted their final timecard for the current two-week pay period. +
+ + + + + + + + + + + + + + + + + + + + + + +
EmployeeEmailSubmittedLocked
+ YesNo + + YesNo + + View/Edit +
+ +
+ + +
+ + + +
+ +
+ + + +
+
+ After correcting a past employee timecard or provider production card, use this to overwrite the saved PDF and email the updated report again. +
+ + +
+
+
Recent Pay Periods
+
+
+ + + + + + + + + + + +
StartEndLocked
Yes' : 'No' ?>
+
+ Tip: Switch pay periods above to review late entries. For a locked period, correct the card, re-lock it, then use Regenerate PDF + Email. +
+
+
+
+
diff --git a/app/Views/admin/employee_edit.php b/app/Views/admin/employee_edit.php index 091390b..2abf63c 100644 --- a/app/Views/admin/employee_edit.php +++ b/app/Views/admin/employee_edit.php @@ -1,110 +1,110 @@ - -
-
-
-
-
-
-
- Back -
-
-
-
- - - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - - -
- - -
- - -
- - -
- -
-
- - -
- -
-
-
Reset Password (2-step)
-
-
- - - - -
- - -
- - -
- -
-
-
-
- -
-
Hard Delete Employee (2-step)
-
-
Warning: This permanently deletes the user and related timecard data (cannot be undone).
-
- - - - -
- - -
- -
-
-
-
-
- -
-
+ +
+
+
+
+
+
+
+ Back +
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+ +
+
+
Reset Password (2-step)
+
+
+ + + + +
+ + +
+ + +
+ +
+
+
+
+ +
+
Hard Delete Employee (2-step)
+
+
Warning: This permanently deletes the user and related timecard data (cannot be undone).
+
+ + + + +
+ + +
+ +
+
+
+
+
+ +
+
diff --git a/app/Views/admin/employees.php b/app/Views/admin/employees.php index bfec086..ec16cc7 100644 --- a/app/Views/admin/employees.php +++ b/app/Views/admin/employees.php @@ -1,35 +1,35 @@ - -
-
-
-
Employees
-
Create, edit, reset passwords, assign roles, hard delete
-
- -
-
- - - - - - - - - - - - - - - - - - -
NameEmailRoleActivePTO Bank
Yes' : 'No' ?>Edit
-
-
+ +
+
+
+
Employees
+
Create, edit, reset passwords, assign roles, hard delete
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
NameEmailRoleActivePTO Bank
Yes' : 'No' ?>Edit
+
+
diff --git a/app/Views/admin/provider_edit.php b/app/Views/admin/provider_edit.php index f014eb9..ca2c968 100644 --- a/app/Views/admin/provider_edit.php +++ b/app/Views/admin/provider_edit.php @@ -1,66 +1,66 @@ - - - -
-
-
-
-
-
-
- Back -
-
- -
-
- - - -
-
- - -
-
- - -
-
- -
-
- - - Leave blank (or 0) if this provider does not accrue PTO. -
-
- -
- -
- -
- -
-
- - -
- - -
-
+ + + +
+
+
+
+
+
+
+ Back +
+
+ +
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ + + Leave blank (or 0) if this provider does not accrue PTO. +
+
+ +
+ +
+ +
+ +
+
+ + +
+ + +
+
diff --git a/app/Views/admin/provider_production.php b/app/Views/admin/provider_production.php index 22f26d5..406334b 100644 --- a/app/Views/admin/provider_production.php +++ b/app/Views/admin/provider_production.php @@ -1,64 +1,64 @@ - -
-
-
-
Provider Production
-
Pay Period: through
-
- -
- -
-
- - -
- - -
- - - -
-
- Use this after unlocking, editing, and re-locking provider production for a past pay period. -
- - - - - - - - - - - - - - - -
ProviderTypeSubmittedLocked
Yes' : 'No' ?>Yes' : 'No' ?> - View/Edit -
- -
- Providers are admin-only. Enter counts, then lock to snapshot rates used for reporting. -
-
-
+ +
+
+
+
Provider Production
+
Pay Period: through
+
+ +
+ +
+
+ + +
+ + +
+ + + +
+
+ Use this after unlocking, editing, and re-locking provider production for a past pay period. +
+ + + + + + + + + + + + + + + +
ProviderTypeSubmittedLocked
Yes' : 'No' ?>Yes' : 'No' ?> + View/Edit +
+ +
+ Providers are admin-only. Enter counts, then lock to snapshot rates used for reporting. +
+
+
diff --git a/app/Views/admin/provider_production_edit.php b/app/Views/admin/provider_production_edit.php index c4fd665..23e5cde 100644 --- a/app/Views/admin/provider_production_edit.php +++ b/app/Views/admin/provider_production_edit.php @@ -1,193 +1,193 @@ - 0 || $ptoCurrent > 0 || $ptoUsedYtd > 0); -$ptoDec = $ptoCurrent ? number_format($ptoCurrent/60, 2, '.', '') : ''; -$ptoMaxDec = ($ptoBank > 0) ? number_format(max(0, ($ptoBank - $ptoUsedExcl))/60, 2, '.', '') : ''; -$isSuper = (bool)($isSuper ?? false); -$backUrl = (string)($backUrl ?? ("/timecards?pp=" . (string)$payPeriod["id"])); -?> -
-
-
-
Provider Production
-
-
Pay Period: through
- - -
- PTO Remaining: -
-
PTO Bank:
-
PTO Used YTD:
- - - Locked - -
-
- Back -
-
- -
-
- - -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - -
TypeCountRateLine Total
- - - - - -
- - -
-
- - -
- - /> - - Max available this period: hours - - -
-
- - -
- Total: -
- Rates are resolved as-of the pay period end date, and are snapshotted into rate_used when locked. -
-
- - -
- - -
-
- - - - - - + 0 || $ptoCurrent > 0 || $ptoUsedYtd > 0); +$ptoDec = $ptoCurrent ? number_format($ptoCurrent/60, 2, '.', '') : ''; +$ptoMaxDec = ($ptoBank > 0) ? number_format(max(0, ($ptoBank - $ptoUsedExcl))/60, 2, '.', '') : ''; +$isSuper = (bool)($isSuper ?? false); +$backUrl = (string)($backUrl ?? ("/timecards?pp=" . (string)$payPeriod["id"])); +?> +
+
+
+
Provider Production
+
+
Pay Period: through
+ + +
+ PTO Remaining: +
+
PTO Bank:
+
PTO Used YTD:
+ + + Locked + +
+
+ Back +
+
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
TypeCountRateLine Total
+ + + + + +
+ + +
+
+ + +
+ + /> + + Max available this period: hours + + +
+
+ + +
+ Total: +
+ Rates are resolved as-of the pay period end date, and are snapshotted into rate_used when locked. +
+
+ + +
+ + +
+
+ + + + + + diff --git a/app/Views/admin/provider_rates.php b/app/Views/admin/provider_rates.php index 7a98000..c158539 100644 --- a/app/Views/admin/provider_rates.php +++ b/app/Views/admin/provider_rates.php @@ -1,113 +1,113 @@ - -
-
-
-
Provider Rates
-
-
- -
- -
-
Tip: To change a start date, add a new rate with a new Effective From. Use Set End to retire old rates. -

- Effective dates: add a new rate with an Effective From date. The app auto-closes the previous open-ended rate (same provider+type) the day before. -
- -
-
Add Rate
-
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- -
-
-
-
- -
-
Rate History
-
- - - - - - - - - - - - - -
TypeRateEffective FromEffective To
-
-
- - - - -
- -
- - - - -
- -
- - - -
-
-
-
- Tip: If you want rates to align cleanly, start new rates on a pay period boundary. -
-
-
-
-
+ +
+
+
+
Provider Rates
+
+
+ +
+ +
+
Tip: To change a start date, add a new rate with a new Effective From. Use Set End to retire old rates. +

+ Effective dates: add a new rate with an Effective From date. The app auto-closes the previous open-ended rate (same provider+type) the day before. +
+ +
+
Add Rate
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+ +
+
Rate History
+
+ + + + + + + + + + + + + +
TypeRateEffective FromEffective To
+
+
+ + + + +
+ +
+ + + + +
+ +
+ + + +
+
+
+
+ Tip: If you want rates to align cleanly, start new rates on a pay period boundary. +
+
+
+
+
diff --git a/app/Views/admin/providers.php b/app/Views/admin/providers.php index 18963ba..e9d114f 100644 --- a/app/Views/admin/providers.php +++ b/app/Views/admin/providers.php @@ -1,46 +1,46 @@ - -
-
-
-
Providers
-
Admin-only: manage providers, appointment types, and rates
-
- -
- -
- - - - - - - - - - - - - - - - - - - -
NameTypeActive
Yes' : 'No' ?> - Edit - Rates -
- -
- Note: Providers do not log in. Super users enter appointment counts per pay period and the report will include provider totals. -
-
-
+ +
+
+
+
Providers
+
Admin-only: manage providers, appointment types, and rates
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameTypeActive
Yes' : 'No' ?> + Edit + Rates +
+ +
+ Note: Providers do not log in. Super users enter appointment counts per pay period and the report will include provider totals. +
+
+
diff --git a/app/Views/admin/reports.php b/app/Views/admin/reports.php index c029367..8776039 100644 --- a/app/Views/admin/reports.php +++ b/app/Views/admin/reports.php @@ -1,95 +1,95 @@ - -
-
-
-
Reports
-
Search, sort, and download older PDF reports. Use “Timecards” to view/edit/resubmit past timecards.
-
-
- Back -
-
- -
-
-
- - - Example: 2025-12-01 or TIMECARD_ -
- -
- - -
- -
- - - Tip: for older periods, leave this on “All” and search by date. -
- -
- - Reset -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Pay PeriodCreatedFilenameEmailed ToStatus
No reports found.
- Download - Timecards -
-
- -
- Note: If you edit/resubmit a past timecard, you can use the Admin dashboard’s “Regenerate PDF + Email” button for that pay period to produce an updated report. -
-
-
+ +
+
+
+
Reports
+
Search, sort, and download older PDF reports. Use “Timecards” to view/edit/resubmit past timecards.
+
+
+ Back +
+
+ +
+
+
+ + + Example: 2025-12-01 or TIMECARD_ +
+ +
+ + +
+ +
+ + + Tip: for older periods, leave this on “All” and search by date. +
+ +
+ + Reset +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pay PeriodCreatedFilenameEmailed ToStatus
No reports found.
+ Download + Timecards +
+
+ +
+ Note: If you edit/resubmit a past timecard, you can use the Admin dashboard’s “Regenerate PDF + Email” button for that pay period to produce an updated report. +
+
+
diff --git a/app/Views/admin/settings.php b/app/Views/admin/settings.php index 90c6a3b..f03cf38 100644 --- a/app/Views/admin/settings.php +++ b/app/Views/admin/settings.php @@ -1,128 +1,128 @@ -'Mon',2=>'Tue',3=>'Wed',4=>'Thu',5=>'Fri',6=>'Sat',7=>'Sun']; -?> -
-
-
-
Program Settings
-
Preferences are versioned and can be applied immediately or next pay period
-
-
-
-
- - -
-
-
Time Rounding
-
-
-
- - -
-
- - -
-
- -
- -
- - - -
- Your current sheet uses Monday–Saturday. -
-
-
- -
-
Pay Period + Company
-
-
-
- - -
-
- - -
-
- -
-
- - - Bi-weekly periods are calculated forward/backward from this date. -
-
- - - Default is 14 for bi-weekly. -
-
-
-
-
- -
- -
-
Apply Scope
-
-
- Immediate updates the current (unlocked) pay period's rules. Next pay period leaves the current period unchanged. -
- -
- - -
- -
- -
- -
- -
-
-
- -
- Settings are saved as a new version each time (history is preserved). -
-
-
-
+'Mon',2=>'Tue',3=>'Wed',4=>'Thu',5=>'Fri',6=>'Sat',7=>'Sun']; +?> +
+
+
+
Program Settings
+
Preferences are versioned and can be applied immediately or next pay period
+
+
+
+
+ + +
+
+
Time Rounding
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + + +
+ Choose the days that should appear on each timecard. +
+
+
+ +
+
Pay Period + Company
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + + Bi-weekly periods are calculated forward/backward from this date. +
+
+ + + Default is 14 for bi-weekly. +
+
+
+
+
+ +
+ +
+
Apply Scope
+
+
+ Immediate updates the current (unlocked) pay period's rules. Next pay period leaves the current period unchanged. +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
+ +
+ Settings are saved as a new version each time (history is preserved). +
+
+
+
diff --git a/app/Views/admin/timecard_edit.php b/app/Views/admin/timecard_edit.php index 90e68fe..e5750c7 100644 --- a/app/Views/admin/timecard_edit.php +++ b/app/Views/admin/timecard_edit.php @@ -1,316 +1,316 @@ - - - 0 && $inc <= 60) ? $inc : 15; - -// Hard limits requested: 5:00 AM to 8:00 PM -$startHour = 5; // 05:00 -$endHour = 20; // 20:00 - -if (!function_exists('time_options')) { - function time_options(?string $selected, int $inc, int $startHour, int $endHour): void { - $sel = $selected ? substr($selected, 0, 5) : ''; - - $start = max(0, min(23, $startHour)) * 60; - $end = max(0, min(23, $endHour)) * 60; - - if ($end < $start) { [$start, $end] = [$end, $start]; } - - for ($mins = $start; $mins <= $end; $mins += $inc) { - $h = intdiv($mins, 60); - $m = $mins % 60; - - $value = sprintf('%02d:%02d', $h, $m); - - $h12 = $h % 12; - if ($h12 === 0) $h12 = 12; - $ampm = ($h < 12) ? 'AM' : 'PM'; - $label = sprintf('%d:%02d %s', $h12, $m, $ampm); - - $is = ($value === $sel) ? ' selected' : ''; - echo ''; - } - } -} - -// --- PTO display helpers (bank / used / remaining) --- -$ptoBank = (int)($pto_bank ?? 0); -$ptoUsedYtd = (int)($pto_used_ytd ?? 0); -$ptoAvail = (int)($pto_available ?? 0); - -// Exclude the currently-saved PTO minutes from YTD so “remaining” updates nicely while typing -$ptoCurrent = (int)($timecard['pto_minutes'] ?? 0); -$ptoUsedExclCurrent = max(0, $ptoUsedYtd - $ptoCurrent); -?> - -
-
-
-
Edit Timecard:
-
Pay Period: through
- - -
- PTO Remaining: -
-
- PTO Bank: -
-
- PTO Used YTD: -
-
- -
- Back -
-
- -
- - - - -
-
- Admin edit: Times will be rounded to your current settings (, min). -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Day / DateTime INTime OUTTotal
Week 1
Week 2
-
- -
- - - - - - - - - - - -
-
- -
-
- - - - - -
- - - - Bonus: $20 per program ( this period) -
- - - - -
- - - -
- - - - - - -
- - - - - - Max available this period: hours (). - -
- -
- -
-
Work Total:
-
PTO:
-
Grand Total:
- - -
Weight Loss Programs:
-
Weight Loss Bonus:
- - -
Nursing Encounters:
- -
-
-
-
- - -
- - -
- - - - - + + + 0 && $inc <= 60) ? $inc : 15; + +// Hard limits requested: 5:00 AM to 8:00 PM +$startHour = 5; // 05:00 +$endHour = 20; // 20:00 + +if (!function_exists('time_options')) { + function time_options(?string $selected, int $inc, int $startHour, int $endHour): void { + $sel = $selected ? substr($selected, 0, 5) : ''; + + $start = max(0, min(23, $startHour)) * 60; + $end = max(0, min(23, $endHour)) * 60; + + if ($end < $start) { [$start, $end] = [$end, $start]; } + + for ($mins = $start; $mins <= $end; $mins += $inc) { + $h = intdiv($mins, 60); + $m = $mins % 60; + + $value = sprintf('%02d:%02d', $h, $m); + + $h12 = $h % 12; + if ($h12 === 0) $h12 = 12; + $ampm = ($h < 12) ? 'AM' : 'PM'; + $label = sprintf('%d:%02d %s', $h12, $m, $ampm); + + $is = ($value === $sel) ? ' selected' : ''; + echo ''; + } + } +} + +// --- PTO display helpers (bank / used / remaining) --- +$ptoBank = (int)($pto_bank ?? 0); +$ptoUsedYtd = (int)($pto_used_ytd ?? 0); +$ptoAvail = (int)($pto_available ?? 0); + +// Exclude the currently-saved PTO minutes from YTD so “remaining” updates nicely while typing +$ptoCurrent = (int)($timecard['pto_minutes'] ?? 0); +$ptoUsedExclCurrent = max(0, $ptoUsedYtd - $ptoCurrent); +?> + +
+
+
+
Edit Timecard:
+
Pay Period: through
+ + +
+ PTO Remaining: +
+
+ PTO Bank: +
+
+ PTO Used YTD: +
+
+ +
+ Back +
+
+ +
+ + + + +
+
+ Admin edit: Times will be rounded to your current settings (, min). +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Day / DateTime INTime OUTTotal
Week 1
Week 2
+
+ +
+ + + + + + + + + + + +
+
+ +
+
+ + + + + +
+ + + + Bonus: $20 per program ( this period) +
+ + + + +
+ + + +
+ + + + + + +
+ + + + + + Max available this period: hours (). + +
+ +
+ +
+
Work Total:
+
PTO:
+
Grand Total:
+ + +
Weight Loss Programs:
+
Weight Loss Bonus:
+ + +
Nursing Encounters:
+ +
+
+
+
+ + +
+ + +
+ + + + + diff --git a/app/Views/admin/timecards.php b/app/Views/admin/timecards.php index 6940a1b..a2417ac 100644 --- a/app/Views/admin/timecards.php +++ b/app/Views/admin/timecards.php @@ -1,138 +1,138 @@ - -
-
-
-
Time Cards
-
Pay Period: through
- - Pay Period Locked - -
-
- -
-
- - -
- - -
- -
- - - -
-
Use this after unlocking, editing, and re-locking a past employee or provider time card.
- -
- - - -
- -
- -
-
- -
-
-
-
-
Employees
-
Hourly time cards
-
-
-
-
- - - - - - - - - - - - - -
EmployeeSubmittedLocked
Yes' : 'No' ?>Yes' : 'No' ?> - - View/Edit - - View only - -
-
-
-
- -
-
-
-
Providers
-
Production / quantity cards
-
-
-
-
- - - - - - - - - - - - - - -
ProviderTypeSubmittedLocked
Yes' : 'No' ?>Yes' : 'No' ?> - - View/Edit - - View only - -
-
-
-
-
+ +
+
+
+
Time Cards
+
Pay Period: through
+ + Pay Period Locked + +
+
+ +
+
+ + +
+ + +
+ +
+ + + +
+
Use this after unlocking, editing, and re-locking a past employee or provider time card.
+ +
+ + + +
+ +
+ +
+
+ +
+
+
+
+
Employees
+
Hourly time cards
+
+
+
+
+ + + + + + + + + + + + + +
EmployeeSubmittedLocked
Yes' : 'No' ?>Yes' : 'No' ?> + + View/Edit + + View only + +
+
+
+
+ +
+
+
+
Providers
+
Production / quantity cards
+
+
+
+
+ + + + + + + + + + + + + + +
ProviderTypeSubmittedLocked
Yes' : 'No' ?>Yes' : 'No' ?> + + View/Edit + + View only + +
+
+
+
+
diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php index 7c021fb..636437a 100644 --- a/app/Views/auth/login.php +++ b/app/Views/auth/login.php @@ -1,36 +1,34 @@ - -
-
-
-
Login
-
Standalone PHP Time Clock
-
-
- -
-
- - -
-
- - -
-
- - -
-
- -
- -
- -
- If you have not installed yet, run /install. -
-
-
-
+ +
+
+
+
Login
+
Standalone PHP Time Clock
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
Contact an administrator if you need an account or password reset.
+
+
+
diff --git a/app/Views/employee/dashboard.php b/app/Views/employee/dashboard.php index be4127e..f6e9e17 100644 --- a/app/Views/employee/dashboard.php +++ b/app/Views/employee/dashboard.php @@ -1,44 +1,44 @@ - -
-
-
-
Welcome,
-
Pay Period: through
-
-
- - Locked - - Submitted - - In Progress - -
-
-
-
-
-
PTO Bank
-
-
-
-
PTO Used YTD
-
-
-
-
PTO Available
-
-
-
- -
-
+ +
+
+
+
Welcome,
+
Pay Period: through
+
+
+ + Locked + + Submitted + + In Progress + +
+
+
+
+
+
PTO Bank
+
+
+
+
PTO Used YTD
+
+
+
+
PTO Available
+
+
+
+ +
+
diff --git a/app/Views/employee/timecard.php b/app/Views/employee/timecard.php index 538978b..b220b82 100644 --- a/app/Views/employee/timecard.php +++ b/app/Views/employee/timecard.php @@ -1,326 +1,326 @@ - - - 0 && $inc <= 60) ? $inc : 15; - -// Hard limits requested: 5:00 AM to 8:00 PM -$startHour = 5; // 05:00 -$endHour = 20; // 20:00 - -if (!function_exists('time_options')) { - function time_options(?string $selected, int $inc, int $startHour, int $endHour): void { - $sel = $selected ? substr($selected, 0, 5) : ''; - - $start = max(0, min(23, $startHour)) * 60; - $end = max(0, min(23, $endHour)) * 60; - - if ($end < $start) { [$start, $end] = [$end, $start]; } - - for ($mins = $start; $mins <= $end; $mins += $inc) { - $h = intdiv($mins, 60); - $m = $mins % 60; - - $value = sprintf('%02d:%02d', $h, $m); - - $h12 = $h % 12; - if ($h12 === 0) $h12 = 12; - $ampm = ($h < 12) ? 'AM' : 'PM'; - $label = sprintf('%d:%02d %s', $h12, $m, $ampm); - - $is = ($value === $sel) ? ' selected' : ''; - echo ''; - } - } -} - -// --- PTO display helpers (bank / used / remaining) --- -$ptoBank = (int)($pto_bank ?? 0); -$ptoUsedYtd = (int)($pto_used_ytd ?? 0); -$ptoAvail = (int)($pto_available ?? 0); - -// Exclude the currently-saved PTO minutes from YTD so the “remaining” can update nicely while typing -$ptoCurrent = (int)($timecard['pto_minutes'] ?? 0); -$ptoUsedExclCurrent = max(0, $ptoUsedYtd - $ptoCurrent); -?> - -
-
-
-
Timecard
-
Pay Period: through
- -
- - -
- - -
Rounding: • Increment: minutes
- - -
- PTO Remaining: -
-
- PTO Bank: -
-
- PTO Used YTD: -
-
- -
- - Locked - - Submitted - - Draft - -
-
- -
- - - -
-
- Tip: Your current paper sheet is organized as two 1-week blocks (Mon–Sat) with Time IN/OUT and totals, plus a PTO hours line. -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Day / DateTime INTime OUTTotal
Week 1
Week 2
-
- -
- - - - - - - - - - - -
-
- -
-
- - - - - -
- - - - Bonus: $20 per program ( this period) -
- - - - -
- - - -
- - - - - - -
- - - - - - Max available this period: hours (). - -
-
- -
-
Work Total:
-
PTO:
-
Grand Total:
- - -
Weight Loss Programs:
-
Weight Loss Bonus:
- - -
Nursing Encounters:
- -
-
-
-
- - -
- -
- - - -
-
- - - - - - + + + 0 && $inc <= 60) ? $inc : 15; + +// Hard limits requested: 5:00 AM to 8:00 PM +$startHour = 5; // 05:00 +$endHour = 20; // 20:00 + +if (!function_exists('time_options')) { + function time_options(?string $selected, int $inc, int $startHour, int $endHour): void { + $sel = $selected ? substr($selected, 0, 5) : ''; + + $start = max(0, min(23, $startHour)) * 60; + $end = max(0, min(23, $endHour)) * 60; + + if ($end < $start) { [$start, $end] = [$end, $start]; } + + for ($mins = $start; $mins <= $end; $mins += $inc) { + $h = intdiv($mins, 60); + $m = $mins % 60; + + $value = sprintf('%02d:%02d', $h, $m); + + $h12 = $h % 12; + if ($h12 === 0) $h12 = 12; + $ampm = ($h < 12) ? 'AM' : 'PM'; + $label = sprintf('%d:%02d %s', $h12, $m, $ampm); + + $is = ($value === $sel) ? ' selected' : ''; + echo ''; + } + } +} + +// --- PTO display helpers (bank / used / remaining) --- +$ptoBank = (int)($pto_bank ?? 0); +$ptoUsedYtd = (int)($pto_used_ytd ?? 0); +$ptoAvail = (int)($pto_available ?? 0); + +// Exclude the currently-saved PTO minutes from YTD so the “remaining” can update nicely while typing +$ptoCurrent = (int)($timecard['pto_minutes'] ?? 0); +$ptoUsedExclCurrent = max(0, $ptoUsedYtd - $ptoCurrent); +?> + +
+
+
+
Timecard
+
Pay Period: through
+ +
+ + +
+ + +
Rounding: • Increment: minutes
+ + +
+ PTO Remaining: +
+
+ PTO Bank: +
+
+ PTO Used YTD: +
+
+ +
+ + Locked + + Submitted + + Draft + +
+
+ +
+ + + +
+
+ Tip: Your current paper sheet is organized as two 1-week blocks (Mon–Sat) with Time IN/OUT and totals, plus a PTO hours line. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Day / DateTime INTime OUTTotal
Week 1
Week 2
+
+ +
+ + + + + + + + + + + +
+
+ +
+
+ + + + + +
+ + + + Bonus: $20 per program ( this period) +
+ + + + +
+ + + +
+ + + + + + +
+ + + + + + Max available this period: hours (). + +
+
+ +
+
Work Total:
+
PTO:
+
Grand Total:
+ + +
Weight Loss Programs:
+
Weight Loss Bonus:
+ + +
Nursing Encounters:
+ +
+
+
+
+ + +
+ +
+ + + +
+
+ + + + + + diff --git a/app/Views/layout.php b/app/Views/layout.php index 442d8ce..56a17d4 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -1,84 +1,86 @@ - - - - - - - <?= e($title ?? 'TimeClock') ?> - - - - - - - - -
-
-
TimeClock
- -
- - -
Error:
- - -
OK:
- - - -
- - - - - - + + + + + + + <?= e($title ?? 'TimeClock') ?> + + + + + +
+
+
TimeClock
+ +
+ + +
Error:
+ + +
OK:
+ + + +
+ + + + + + diff --git a/app/bootstrap.php b/app/bootstrap.php index 0542fc5..eb1a919 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -1,159 +1,167 @@ -Details" - . "
"
-      . htmlspecialchars((string)$e, ENT_QUOTES, 'UTF-8')
-      . "
"; - } - - echo ""; - echo "500"; - echo "

Server error

"; - echo "

" . htmlspecialchars($publicMessage, ENT_QUOTES, 'UTF-8') . "

"; - echo "

Request ID: " . htmlspecialchars($rid, ENT_QUOTES, 'UTF-8') . "

"; - echo "

Log file: " . htmlspecialchars($phpLog, ENT_QUOTES, 'UTF-8') - . ($logWritable ? "" : " (not writable — fix permissions on storage/logs)") . "

"; - echo $details; - echo ""; -}; - -set_exception_handler(function(Throwable $e) use (&$handling, $renderErrorPage) { - if ($handling) return; - $handling = true; - - // IMPORTANT: log as string (PHP 8.2-safe) - error_log("[" . APP_REQUEST_ID . "] " . (string)$e); - - $renderErrorPage('Unhandled exception', $e); -}); - -set_error_handler(function(int $severity, string $message, string $file, int $line) { - if (!(error_reporting() & $severity)) return false; - - error_log("[" . APP_REQUEST_ID . "] PHP {$severity}: {$message} in {$file}:{$line}"); - - // In debug, convert warnings/notices into exceptions to surface stack traces - if (!empty($GLOBALS['APP_DEBUG_RUNTIME'])) { - throw new ErrorException($message, 0, $severity, $file, $line); - } - - // In production, swallow (already logged) - return true; -}); - -register_shutdown_function(function() use ($renderErrorPage) { - $err = error_get_last(); - if (!$err) return; - - $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; - if (!in_array($err['type'], $fatalTypes, true)) return; - - error_log("[" . APP_REQUEST_ID . "] FATAL {$err['type']}: {$err['message']} in {$err['file']}:{$err['line']}"); - $renderErrorPage('Fatal error (see log for details)'); -}); - -/** - * Autoloader - */ -spl_autoload_register(function($class){ - $prefix = 'App\\'; - $baseDir = __DIR__ . '/'; - if (strncmp($prefix, $class, strlen($prefix)) !== 0) return; - $rel = substr($class, strlen($prefix)); - $file = $baseDir . str_replace('\\', '/', $rel) . '.php'; - if (file_exists($file)) require $file; -}); - -/** - * Load config AFTER handlers are active (so missing/bad config logs properly) - */ -Config::load(__DIR__ . '/config.php'); - -$cfg = Config::get(); -$GLOBALS['APP_DEBUG_RUNTIME'] = !empty($GLOBALS['APP_DEBUG_RUNTIME']) || (bool)($cfg['app']['debug'] ?? false); -if (!defined('APP_DEBUG')) define('APP_DEBUG', (bool)$GLOBALS['APP_DEBUG_RUNTIME']); - -date_default_timezone_set($cfg['app']['timezone'] ?? 'America/New_York'); - -/** - * Session hardening (shared-hosting safe) - */ -// --- Session setup (critical for CSRF on subfolder installs) --- -$cfg = Config::get(); -$baseUrl = (string)($cfg['app']['base_url'] ?? ''); -$cookiePath = '/'; - -if ($baseUrl !== '') { - $u = parse_url($baseUrl); - if (!empty($u['path'])) { - $cookiePath = rtrim($u['path'], '/') . '/'; - } -} - -// Store sessions inside the app (more reliable on shared hosting) -$sessionDir = dirname(__DIR__) . '/storage/sessions'; -if (!is_dir($sessionDir)) { @mkdir($sessionDir, 0755, true); } -if (is_dir($sessionDir) && is_writable($sessionDir)) { - ini_set('session.save_path', $sessionDir); -} - -// Avoid collisions with other PHP apps on the same domain -session_name('TIMECLKSESSID'); - -ini_set('session.cookie_path', $cookiePath); -ini_set('session.cookie_httponly', '1'); -ini_set('session.use_strict_mode', '1'); -ini_set('session.cookie_samesite', 'Lax'); -if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { - ini_set('session.cookie_secure', '1'); -} - -session_start(); - +Details" + . "
"
+      . htmlspecialchars((string)$e, ENT_QUOTES, 'UTF-8')
+      . "
"; + } + + echo ""; + echo "500"; + echo "

Server error

"; + echo "

" . htmlspecialchars($publicMessage, ENT_QUOTES, 'UTF-8') . "

"; + echo "

Request ID: " . htmlspecialchars($rid, ENT_QUOTES, 'UTF-8') . "

"; + if ($debug) { + echo "

Log file: " . htmlspecialchars($phpLog, ENT_QUOTES, 'UTF-8') + . ($logWritable ? "" : " (not writable — fix permissions on storage/logs)") . "

"; + } + echo $details; + echo ""; +}; + +set_exception_handler(function(Throwable $e) use (&$handling, $renderErrorPage) { + if ($handling) return; + $handling = true; + + // IMPORTANT: log as string (PHP 8.2-safe) + error_log("[" . APP_REQUEST_ID . "] " . (string)$e); + + $renderErrorPage('Unhandled exception', $e); +}); + +set_error_handler(function(int $severity, string $message, string $file, int $line) { + if (!(error_reporting() & $severity)) return false; + + error_log("[" . APP_REQUEST_ID . "] PHP {$severity}: {$message} in {$file}:{$line}"); + + // In debug, convert warnings/notices into exceptions to surface stack traces + if (!empty($GLOBALS['APP_DEBUG_RUNTIME'])) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + + // In production, swallow (already logged) + return true; +}); + +register_shutdown_function(function() use ($renderErrorPage) { + $err = error_get_last(); + if (!$err) return; + + $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; + if (!in_array($err['type'], $fatalTypes, true)) return; + + error_log("[" . APP_REQUEST_ID . "] FATAL {$err['type']}: {$err['message']} in {$err['file']}:{$err['line']}"); + $renderErrorPage('Fatal error (see log for details)'); +}); + +/** + * Autoloader + */ +spl_autoload_register(function($class){ + $prefix = 'App\\'; + $baseDir = __DIR__ . '/'; + if (strncmp($prefix, $class, strlen($prefix)) !== 0) return; + $rel = substr($class, strlen($prefix)); + $file = $baseDir . str_replace('\\', '/', $rel) . '.php'; + if (file_exists($file)) require $file; +}); + +/** + * Load config AFTER handlers are active (so missing/bad config logs properly) + */ +Config::load(__DIR__ . '/config.php'); + +$cfg = Config::get(); +$GLOBALS['APP_DEBUG_RUNTIME'] = !empty($GLOBALS['APP_DEBUG_RUNTIME']) || (bool)($cfg['app']['debug'] ?? false); +if (!defined('APP_DEBUG')) define('APP_DEBUG', (bool)$GLOBALS['APP_DEBUG_RUNTIME']); + +date_default_timezone_set($cfg['app']['timezone'] ?? 'America/New_York'); + +/** + * Session hardening (shared-hosting safe) + */ +// --- Session setup (critical for CSRF on subfolder installs) --- +$cfg = Config::get(); +$baseUrl = (string)($cfg['app']['base_url'] ?? ''); +$cookiePath = '/'; + +if ($baseUrl !== '') { + $u = parse_url($baseUrl); + if (!empty($u['path'])) { + $cookiePath = rtrim($u['path'], '/') . '/'; + } +} + +// Store sessions inside the app (more reliable on shared hosting) +$sessionDir = dirname(__DIR__) . '/storage/sessions'; +if (!is_dir($sessionDir)) { @mkdir($sessionDir, 0755, true); } +if (is_dir($sessionDir) && is_writable($sessionDir)) { + ini_set('session.save_path', $sessionDir); +} + +// Avoid collisions with other PHP apps on the same domain +session_name('TIMECLKSESSID'); + +ini_set('session.cookie_path', $cookiePath); +ini_set('session.cookie_httponly', '1'); +ini_set('session.use_only_cookies', '1'); +ini_set('session.use_strict_mode', '1'); +ini_set('session.cookie_samesite', 'Lax'); +if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + ini_set('session.cookie_secure', '1'); +} + +session_start(); + diff --git a/app/config.sample.php b/app/config.sample.php index 2cfd463..c90645b 100644 --- a/app/config.sample.php +++ b/app/config.sample.php @@ -1,19 +1,19 @@ - [ - 'base_url' => '', // e.g. https://example.com/timeclock-pro - 'timezone' => 'America/New_York', - 'debug' => false, // Or create storage/DEBUG_ON to enable debugging - // 'base_url' can be left empty; the app will infer the subfolder automatically. - - ], - 'db' => [ - 'host' => 'localhost', - 'name' => 'timeclock', - 'user' => 'timeclock_user', - 'pass' => 'CHANGE_ME', - 'charset' => 'utf8mb4', - ], -]; + [ + 'base_url' => '', // e.g. https://example.com/timeclock-pro + 'timezone' => 'UTC', + 'debug' => false, // Or create storage/DEBUG_ON to enable debugging + // 'base_url' can be left empty; the app will infer the subfolder automatically. + + ], + 'db' => [ + 'host' => 'localhost', + 'name' => 'timeclock', + 'user' => 'timeclock_user', + 'pass' => 'CHANGE_ME', + 'charset' => 'utf8mb4', + ], +]; diff --git a/app/helpers.php b/app/helpers.php index f406b45..eaf437b 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,245 +1,245 @@ - date('c'), - 'rid' => request_id(), - 'level' => strtoupper($level), - 'msg' => $message, - 'ctx' => $context, - ]; - - $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - @file_put_contents($file, $line, FILE_APPEND | LOCK_EX); -} - -function now(): string { return (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'); } - -function minutes_to_hhmm(int $minutes): string { - $sign = $minutes < 0 ? '-' : ''; - $minutes = abs($minutes); - $h = intdiv($minutes, 60); - $m = $minutes % 60; - return sprintf('%s%02d:%02d', $sign, $h, $m); -} - -function minutes_to_hours_decimal(int $minutes, int $precision = 2): string { - $sign = $minutes < 0 ? '-' : ''; - $minutes = abs($minutes); - $hours = $minutes / 60; - return $sign . number_format($hours, $precision, '.', ''); -} - - -/** - * Format a YYYY-MM-DD date string as MM/DD/YYYY for display. - * If parsing fails, returns the original string. - */ -function fmt_date(?string $date): string { - $date = trim((string)$date); - if ($date === '') return ''; - $date = substr($date, 0, 10); - - $cfg = \App\Core\Config::get(); - $tzName = (string)($cfg['app']['timezone'] ?? 'UTC'); - - try { - $tz = new \DateTimeZone($tzName); - } catch (\Throwable $e) { - $tz = new \DateTimeZone('UTC'); - } - - try { - $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date, $tz); - if ($dt instanceof \DateTimeImmutable) { - return $dt->format('m/d/Y'); - } - $dt2 = new \DateTimeImmutable($date, $tz); - return $dt2->format('m/d/Y'); - } catch (\Throwable $e) { - return $date; - } -} - -/** - * Format a datetime (YYYY-MM-DD HH:MM:SS) as MM/DD/YYYY h:mm AM/PM for display. - * If parsing fails, returns the original string. - */ -function fmt_datetime(?string $dt, bool $includeTime = true): string { - $dt = trim((string)$dt); - if ($dt === '') return ''; - - $cfg = \App\Core\Config::get(); - $tzName = (string)($cfg['app']['timezone'] ?? 'UTC'); - - try { - $tz = new \DateTimeZone($tzName); - } catch (\Throwable $e) { - $tz = new \DateTimeZone('UTC'); - } - - try { - $dti = new \DateTimeImmutable($dt, $tz); - return $dti->format($includeTime ? 'm/d/Y g:i A' : 'm/d/Y'); - } catch (\Throwable $e) { - return $dt; - } -} - -function hhmm_to_minutes(string $hhmm): int { - if (!preg_match('/^(\d{1,3}):(\d{2})$/', $hhmm, $m)) return 0; - return ((int)$m[1]) * 60 + (int)$m[2]; -} - -function flash_set(string $key, string $msg): void { - $_SESSION['_flash'][$key] = $msg; -} -function flash_get(string $key): ?string { - $msg = $_SESSION['_flash'][$key] ?? null; - if ($msg !== null) unset($_SESSION['_flash'][$key]); - return $msg; -} - -/** - * Compute and ensure the pay period that contains today's date. - */ -function current_pay_period(): array { - $state = \App\Models\Settings::appState(); - $anchor = new \DateTimeImmutable($state['pay_period_anchor_date']); - $len = (int)$state['pay_period_length_days']; - - $today = new \DateTimeImmutable('today'); - $diffDays = (int)$anchor->diff($today)->format('%r%a'); - $idx = (int)floor($diffDays / $len); - if ($diffDays < 0) $idx = (int)ceil($diffDays / $len) - 1; - - $start = $anchor->modify('+' . ($idx * $len) . ' days'); - $end = $start->modify('+' . ($len - 1) . ' days'); - - $sv = (int)$state['current_settings_version_id']; - return \App\Models\PayPeriod::ensure($start->format('Y-m-d'), $end->format('Y-m-d'), $sv); -} - -/** - * Resolve the selected working pay period. - * ?pp=ID updates the session selection. No ?pp=ID uses the current session selection. - * A fresh login/no session selection defaults to the current pay period. - */ -function selected_pay_period(?int $requestedId = null): array { - $requestedId = $requestedId ?? (int)($_GET['pp'] ?? 0); - - if ($requestedId > 0) { - $pp = \App\Models\PayPeriod::findById($requestedId); - if ($pp) { - $_SESSION['selected_pay_period_id'] = (int)$pp['id']; - return $pp; - } - } - - $savedId = (int)($_SESSION['selected_pay_period_id'] ?? 0); - if ($savedId > 0) { - $pp = \App\Models\PayPeriod::findById($savedId); - if ($pp) return $pp; - } - - $pp = current_pay_period(); - $_SESSION['selected_pay_period_id'] = (int)$pp['id']; - return $pp; -} - -function recent_pay_periods_with_selected(array $selected, int $limit = 8): array { - $periods = \App\Models\PayPeriod::listRecent($limit); - $has = false; - foreach ($periods as $p) { - if ((int)$p['id'] === (int)$selected['id']) { $has = true; break; } - } - if (!$has) array_unshift($periods, $selected); - return $periods; -} + date('c'), + 'rid' => request_id(), + 'level' => strtoupper($level), + 'msg' => $message, + 'ctx' => $context, + ]; + + $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + @file_put_contents($file, $line, FILE_APPEND | LOCK_EX); +} + +function now(): string { return (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'); } + +function minutes_to_hhmm(int $minutes): string { + $sign = $minutes < 0 ? '-' : ''; + $minutes = abs($minutes); + $h = intdiv($minutes, 60); + $m = $minutes % 60; + return sprintf('%s%02d:%02d', $sign, $h, $m); +} + +function minutes_to_hours_decimal(int $minutes, int $precision = 2): string { + $sign = $minutes < 0 ? '-' : ''; + $minutes = abs($minutes); + $hours = $minutes / 60; + return $sign . number_format($hours, $precision, '.', ''); +} + + +/** + * Format a YYYY-MM-DD date string as MM/DD/YYYY for display. + * If parsing fails, returns the original string. + */ +function fmt_date(?string $date): string { + $date = trim((string)$date); + if ($date === '') return ''; + $date = substr($date, 0, 10); + + $cfg = \App\Core\Config::get(); + $tzName = (string)($cfg['app']['timezone'] ?? 'UTC'); + + try { + $tz = new \DateTimeZone($tzName); + } catch (\Throwable $e) { + $tz = new \DateTimeZone('UTC'); + } + + try { + $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date, $tz); + if ($dt instanceof \DateTimeImmutable) { + return $dt->format('m/d/Y'); + } + $dt2 = new \DateTimeImmutable($date, $tz); + return $dt2->format('m/d/Y'); + } catch (\Throwable $e) { + return $date; + } +} + +/** + * Format a datetime (YYYY-MM-DD HH:MM:SS) as MM/DD/YYYY h:mm AM/PM for display. + * If parsing fails, returns the original string. + */ +function fmt_datetime(?string $dt, bool $includeTime = true): string { + $dt = trim((string)$dt); + if ($dt === '') return ''; + + $cfg = \App\Core\Config::get(); + $tzName = (string)($cfg['app']['timezone'] ?? 'UTC'); + + try { + $tz = new \DateTimeZone($tzName); + } catch (\Throwable $e) { + $tz = new \DateTimeZone('UTC'); + } + + try { + $dti = new \DateTimeImmutable($dt, $tz); + return $dti->format($includeTime ? 'm/d/Y g:i A' : 'm/d/Y'); + } catch (\Throwable $e) { + return $dt; + } +} + +function hhmm_to_minutes(string $hhmm): int { + if (!preg_match('/^(\d{1,3}):(\d{2})$/', $hhmm, $m)) return 0; + return ((int)$m[1]) * 60 + (int)$m[2]; +} + +function flash_set(string $key, string $msg): void { + $_SESSION['_flash'][$key] = $msg; +} +function flash_get(string $key): ?string { + $msg = $_SESSION['_flash'][$key] ?? null; + if ($msg !== null) unset($_SESSION['_flash'][$key]); + return $msg; +} + +/** + * Compute and ensure the pay period that contains today's date. + */ +function current_pay_period(): array { + $state = \App\Models\Settings::appState(); + $anchor = new \DateTimeImmutable($state['pay_period_anchor_date']); + $len = (int)$state['pay_period_length_days']; + + $today = new \DateTimeImmutable('today'); + $diffDays = (int)$anchor->diff($today)->format('%r%a'); + $idx = (int)floor($diffDays / $len); + if ($diffDays < 0) $idx = (int)ceil($diffDays / $len) - 1; + + $start = $anchor->modify('+' . ($idx * $len) . ' days'); + $end = $start->modify('+' . ($len - 1) . ' days'); + + $sv = (int)$state['current_settings_version_id']; + return \App\Models\PayPeriod::ensure($start->format('Y-m-d'), $end->format('Y-m-d'), $sv); +} + +/** + * Resolve the selected working pay period. + * ?pp=ID updates the session selection. No ?pp=ID uses the current session selection. + * A fresh login/no session selection defaults to the current pay period. + */ +function selected_pay_period(?int $requestedId = null): array { + $requestedId = $requestedId ?? (int)($_GET['pp'] ?? 0); + + if ($requestedId > 0) { + $pp = \App\Models\PayPeriod::findById($requestedId); + if ($pp) { + $_SESSION['selected_pay_period_id'] = (int)$pp['id']; + return $pp; + } + } + + $savedId = (int)($_SESSION['selected_pay_period_id'] ?? 0); + if ($savedId > 0) { + $pp = \App\Models\PayPeriod::findById($savedId); + if ($pp) return $pp; + } + + $pp = current_pay_period(); + $_SESSION['selected_pay_period_id'] = (int)$pp['id']; + return $pp; +} + +function recent_pay_periods_with_selected(array $selected, int $limit = 8): array { + $periods = \App\Models\PayPeriod::listRecent($limit); + $has = false; + foreach ($periods as $p) { + if ((int)$p['id'] === (int)$selected['id']) { $has = true; break; } + } + if (!$has) array_unshift($periods, $selected); + return $periods; +} diff --git a/bin/create-admin.php b/bin/create-admin.php new file mode 100644 index 0000000..7a319da --- /dev/null +++ b/bin/create-admin.php @@ -0,0 +1,59 @@ + 190) { + fwrite(STDERR, "Enter an administrator name between 1 and 190 characters.\n"); + exit(1); +} +if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 190) { + fwrite(STDERR, "Enter a valid administrator email address.\n"); + exit(1); +} +if (strlen($password) < 12) { + fwrite(STDERR, "The administrator password must contain at least 12 characters.\n"); + exit(1); +} +if (User::findByEmail($email)) { + fwrite(STDERR, "A user with that email address already exists.\n"); + exit(1); +} + +User::create([ + 'full_name' => $name, + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'role' => 'super', + 'active' => 1, + 'weight_loss_consultant' => 0, + 'nursing_encounters_enabled' => 0, + 'pto_bank_minutes' => 0, +]); + +fwrite(STDOUT, "Administrator created successfully.\n"); diff --git a/cron/ensure_pay_period.php b/cron/ensure_pay_period.php index e0e01b9..2ee61f5 100644 --- a/cron/ensure_pay_period.php +++ b/cron/ensure_pay_period.php @@ -1,29 +1,29 @@ -diff($today)->format('%r%a'); -$idx = (int)floor($diffDays / $len); -if ($diffDays < 0) $idx = (int)ceil($diffDays / $len) - 1; - -$currentStart = $anchor->modify('+' . ($idx*$len) . ' days'); -$nextStart = $currentStart->modify('+' . $len . ' days'); -$nextEnd = $nextStart->modify('+' . ($len-1) . ' days'); - -$sv = (int)$state['current_settings_version_id']; -PayPeriod::ensure($nextStart->format('Y-m-d'), $nextEnd->format('Y-m-d'), $sv); - -echo "OK: ensured next pay period " . $nextStart->format('Y-m-d') . " through " . $nextEnd->format('Y-m-d') . PHP_EOL; +diff($today)->format('%r%a'); +$idx = (int)floor($diffDays / $len); +if ($diffDays < 0) $idx = (int)ceil($diffDays / $len) - 1; + +$currentStart = $anchor->modify('+' . ($idx*$len) . ' days'); +$nextStart = $currentStart->modify('+' . $len . ' days'); +$nextEnd = $nextStart->modify('+' . ($len-1) . ' days'); + +$sv = (int)$state['current_settings_version_id']; +PayPeriod::ensure($nextStart->format('Y-m-d'), $nextEnd->format('Y-m-d'), $sv); + +echo "OK: ensured next pay period " . $nextStart->format('Y-m-d') . " through " . $nextEnd->format('Y-m-d') . PHP_EOL; diff --git a/database/migrations/2026-01-12_add_provider_pto.sql b/database/migrations/2026-01-12_add_provider_pto.sql index c820252..7f3c487 100644 --- a/database/migrations/2026-01-12_add_provider_pto.sql +++ b/database/migrations/2026-01-12_add_provider_pto.sql @@ -1,19 +1,19 @@ --- Provider PTO bank + per-pay-period PTO usage --- Providers who do not accrue PTO can leave pto_bank_minutes = 0. - -ALTER TABLE providers - ADD COLUMN pto_bank_minutes INT NOT NULL DEFAULT 0 AFTER active; - -CREATE TABLE IF NOT EXISTS provider_pto ( - provider_id INT NOT NULL, - pay_period_id INT NOT NULL, - pto_minutes INT NOT NULL DEFAULT 0, - submitted_at DATETIME NULL, - locked_at DATETIME NULL, - locked_by INT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - PRIMARY KEY (provider_id, pay_period_id), - KEY idx_provider_pto_period (pay_period_id), - KEY idx_provider_pto_provider (provider_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Provider PTO bank + per-pay-period PTO usage +-- Providers who do not accrue PTO can leave pto_bank_minutes = 0. + +ALTER TABLE providers + ADD COLUMN pto_bank_minutes INT NOT NULL DEFAULT 0 AFTER active; + +CREATE TABLE IF NOT EXISTS provider_pto ( + provider_id INT NOT NULL, + pay_period_id INT NOT NULL, + pto_minutes INT NOT NULL DEFAULT 0, + submitted_at DATETIME NULL, + locked_at DATETIME NULL, + locked_by INT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (provider_id, pay_period_id), + KEY idx_provider_pto_period (pay_period_id), + KEY idx_provider_pto_provider (provider_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/database/migrations/2026-01-12_add_weight_loss_consultant.sql b/database/migrations/2026-01-12_add_weight_loss_consultant.sql index e0c6e0f..9deea0b 100644 --- a/database/migrations/2026-01-12_add_weight_loss_consultant.sql +++ b/database/migrations/2026-01-12_add_weight_loss_consultant.sql @@ -1,6 +1,6 @@ --- Add Weight Loss Consultant flag and per-pay-period sales count -ALTER TABLE users - ADD COLUMN weight_loss_consultant TINYINT(1) NOT NULL DEFAULT 0 AFTER active; - -ALTER TABLE timecards - ADD COLUMN weight_loss_units INT NOT NULL DEFAULT 0 AFTER pto_minutes; +-- Add Weight Loss Consultant flag and per-pay-period sales count +ALTER TABLE users + ADD COLUMN weight_loss_consultant TINYINT(1) NOT NULL DEFAULT 0 AFTER active; + +ALTER TABLE timecards + ADD COLUMN weight_loss_units INT NOT NULL DEFAULT 0 AFTER pto_minutes; diff --git a/database/migrations/2026-03-27_add_nursing_encounters.sql b/database/migrations/2026-03-27_add_nursing_encounters.sql index 4a246b0..9f387f7 100644 --- a/database/migrations/2026-03-27_add_nursing_encounters.sql +++ b/database/migrations/2026-03-27_add_nursing_encounters.sql @@ -1,7 +1,7 @@ --- Add reusable Nursing Encounters employee flag + per-pay-period count -ALTER TABLE users - ADD COLUMN nursing_encounters_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER weight_loss_consultant; - +-- Add reusable Nursing Encounters employee flag + per-pay-period count +ALTER TABLE users + ADD COLUMN nursing_encounters_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER weight_loss_consultant; + ALTER TABLE timecards ADD COLUMN nursing_encounters INT NOT NULL DEFAULT 0 AFTER weight_loss_units; diff --git a/database/schema.sql b/database/schema.sql index 8d5aed7..cc454fa 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,100 +1,179 @@ --- TimeClock Pro schema (MySQL/MariaDB) --- Safe to run on a fresh database. - -SET sql_mode = 'STRICT_ALL_TABLES'; - -CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - full_name VARCHAR(190) NOT NULL, - email VARCHAR(190) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - role ENUM('employee','super') NOT NULL DEFAULT 'employee', - active TINYINT(1) NOT NULL DEFAULT 1, - weight_loss_consultant TINYINT(1) NOT NULL DEFAULT 0, - nursing_encounters_enabled TINYINT(1) NOT NULL DEFAULT 0, - pto_bank_minutes INT NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS settings_versions ( - id INT AUTO_INCREMENT PRIMARY KEY, - created_by INT NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - config_json JSON NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS app_state ( - id INT PRIMARY KEY, - current_settings_version_id INT NOT NULL, - company_name VARCHAR(190) NOT NULL DEFAULT 'TimeClock', - company_email VARCHAR(190) NOT NULL DEFAULT '', - pay_period_anchor_date DATE NOT NULL, - pay_period_length_days INT NOT NULL DEFAULT 14, - CONSTRAINT fk_app_state_settings FOREIGN KEY (current_settings_version_id) REFERENCES settings_versions(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS pay_periods ( - id INT AUTO_INCREMENT PRIMARY KEY, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - settings_version_id INT NOT NULL, - locked_at DATETIME NULL, - locked_by INT NULL, - created_at DATETIME NOT NULL, - UNIQUE KEY uniq_period (start_date, end_date), - CONSTRAINT fk_payperiod_settings FOREIGN KEY (settings_version_id) REFERENCES settings_versions(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS timecards ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - pay_period_id INT NOT NULL, - pto_minutes INT NOT NULL DEFAULT 0, - weight_loss_units INT NOT NULL DEFAULT 0, - nursing_encounters INT NOT NULL DEFAULT 0, - submitted_at DATETIME NULL, - locked_at DATETIME NULL, - locked_by INT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - UNIQUE KEY uniq_timecard (user_id, pay_period_id), - CONSTRAINT fk_timecards_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_timecards_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS time_entries ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - pay_period_id INT NOT NULL, - work_date DATE NOT NULL, - time_in TIME NULL, - time_out TIME NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - UNIQUE KEY uniq_entry (user_id, pay_period_id, work_date), - CONSTRAINT fk_entries_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_entries_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS audit_log ( - id INT AUTO_INCREMENT PRIMARY KEY, - actor_user_id INT NOT NULL, - action VARCHAR(120) NOT NULL, - entity VARCHAR(120) NOT NULL, - entity_id INT NULL, - payload_json JSON NULL, - created_at DATETIME NOT NULL, - INDEX idx_actor_created (actor_user_id, created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS reports ( - id INT AUTO_INCREMENT PRIMARY KEY, - pay_period_id INT NOT NULL, - filename VARCHAR(255) NOT NULL, - filepath VARCHAR(255) NOT NULL, - created_at DATETIME NOT NULL, - emailed_to VARCHAR(190) NOT NULL, - email_status VARCHAR(32) NOT NULL DEFAULT 'unknown', - CONSTRAINT fk_reports_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- TimeClock Pro schema (MySQL/MariaDB) +-- Intended for a fresh database. Existing installations should use migrations. + +SET sql_mode = 'STRICT_ALL_TABLES'; + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + full_name VARCHAR(190) NOT NULL, + email VARCHAR(190) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role ENUM('employee','super') NOT NULL DEFAULT 'employee', + active TINYINT(1) NOT NULL DEFAULT 1, + weight_loss_consultant TINYINT(1) NOT NULL DEFAULT 0, + nursing_encounters_enabled TINYINT(1) NOT NULL DEFAULT 0, + pto_bank_minutes INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS settings_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + created_by INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + config_json JSON NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS app_state ( + id INT PRIMARY KEY, + current_settings_version_id INT NOT NULL, + company_name VARCHAR(190) NOT NULL DEFAULT 'TimeClock Pro', + company_email VARCHAR(190) NOT NULL DEFAULT '', + pay_period_anchor_date DATE NOT NULL, + pay_period_length_days INT NOT NULL DEFAULT 14, + CONSTRAINT fk_app_state_settings FOREIGN KEY (current_settings_version_id) REFERENCES settings_versions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pay_periods ( + id INT AUTO_INCREMENT PRIMARY KEY, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + settings_version_id INT NOT NULL, + locked_at DATETIME NULL, + locked_by INT NULL, + created_at DATETIME NOT NULL, + UNIQUE KEY uniq_period (start_date, end_date), + CONSTRAINT fk_payperiod_settings FOREIGN KEY (settings_version_id) REFERENCES settings_versions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS timecards ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + pay_period_id INT NOT NULL, + pto_minutes INT NOT NULL DEFAULT 0, + weight_loss_units INT NOT NULL DEFAULT 0, + nursing_encounters INT NOT NULL DEFAULT 0, + submitted_at DATETIME NULL, + locked_at DATETIME NULL, + locked_by INT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_timecard (user_id, pay_period_id), + CONSTRAINT fk_timecards_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_timecards_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS time_entries ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + pay_period_id INT NOT NULL, + work_date DATE NOT NULL, + time_in TIME NULL, + time_out TIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_entry (user_id, pay_period_id, work_date), + CONSTRAINT fk_entries_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_entries_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS providers ( + id INT AUTO_INCREMENT PRIMARY KEY, + provider_type ENUM('chiro','massage') NOT NULL DEFAULT 'chiro', + full_name VARCHAR(190) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + pto_bank_minutes INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_providers_active_type (active, provider_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS appointment_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + provider_type ENUM('chiro','massage') NOT NULL DEFAULT 'chiro', + name VARCHAR(190) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_appointment_type (provider_type, active, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS provider_rates ( + id INT AUTO_INCREMENT PRIMARY KEY, + provider_id INT NOT NULL, + appointment_type_id INT NOT NULL, + rate DECIMAL(10,2) NOT NULL DEFAULT 0.00, + effective_from DATE NOT NULL, + effective_to DATE NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_provider_rate_lookup (provider_id, appointment_type_id, effective_from, effective_to), + CONSTRAINT fk_provider_rates_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE, + CONSTRAINT fk_provider_rates_type FOREIGN KEY (appointment_type_id) REFERENCES appointment_types(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS provider_production ( + id INT AUTO_INCREMENT PRIMARY KEY, + provider_id INT NOT NULL, + pay_period_id INT NOT NULL, + appointment_type_id INT NOT NULL, + `count` INT NOT NULL DEFAULT 0, + rate_used DECIMAL(10,2) NULL, + submitted_at DATETIME NULL, + locked_at DATETIME NULL, + locked_by INT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_provider_production (provider_id, pay_period_id, appointment_type_id), + INDEX idx_provider_production_period (pay_period_id), + CONSTRAINT fk_production_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE, + CONSTRAINT fk_production_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE, + CONSTRAINT fk_production_type FOREIGN KEY (appointment_type_id) REFERENCES appointment_types(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS provider_pto ( + provider_id INT NOT NULL, + pay_period_id INT NOT NULL, + pto_minutes INT NOT NULL DEFAULT 0, + submitted_at DATETIME NULL, + locked_at DATETIME NULL, + locked_by INT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (provider_id, pay_period_id), + INDEX idx_provider_pto_period (pay_period_id), + CONSTRAINT fk_provider_pto_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE, + CONSTRAINT fk_provider_pto_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor_user_id INT NOT NULL, + action VARCHAR(120) NOT NULL, + entity VARCHAR(120) NOT NULL, + entity_id INT NULL, + payload_json JSON NULL, + created_at DATETIME NOT NULL, + INDEX idx_actor_created (actor_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + pay_period_id INT NOT NULL, + filename VARCHAR(255) NOT NULL, + filepath VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + emailed_to VARCHAR(190) NOT NULL, + email_status VARCHAR(32) NOT NULL DEFAULT 'unknown', + CONSTRAINT fk_reports_period FOREIGN KEY (pay_period_id) REFERENCES pay_periods(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO settings_versions (id, created_by, created_at, config_json) +VALUES (1, 0, NOW(), '{"time_increment_minutes":15,"allowed_minutes":[0,15,30,45],"rounding_mode":"nearest","days_to_show":[1,2,3,4,5,6],"time_start_hour":5,"time_end_hour":20}'); + +INSERT IGNORE INTO app_state + (id, current_settings_version_id, company_name, company_email, pay_period_anchor_date, pay_period_length_days) +VALUES + (1, 1, 'TimeClock Pro', '', DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), 14); + diff --git a/public/.htaccess b/public/.htaccess index a357e5f..c4daa01 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,17 +1,17 @@ - - RewriteEngine On - - # If the folder name changes, RewriteBase may need to be updated or removed. - # RewriteBase /timeclock-pro/public/ - - RewriteCond %{REQUEST_FILENAME} !-f - RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule ^ index.php [QSA,L] - - -# Security headers (best-effort on shared hosting) - - Header set X-Frame-Options "SAMEORIGIN" - Header set X-Content-Type-Options "nosniff" - Header set Referrer-Policy "strict-origin-when-cross-origin" - + + RewriteEngine On + + # If the folder name changes, RewriteBase may need to be updated or removed. + # RewriteBase /timeclock-pro/public/ + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security headers (best-effort on shared hosting) + + Header set X-Frame-Options "SAMEORIGIN" + Header set X-Content-Type-Options "nosniff" + Header set Referrer-Policy "strict-origin-when-cross-origin" + diff --git a/public/DFC Circle Logo - clock.svg b/public/DFC Circle Logo - clock.svg deleted file mode 100644 index 816f92a..0000000 --- a/public/DFC Circle Logo - clock.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - Thomas Clark - - - English - - - - - - - - - - \ No newline at end of file diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png deleted file mode 100644 index d8ac7c58ac31671e93b8b50ecd19a0339fdf0572..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17390 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 diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png deleted file mode 100644 index a2ffbcb008bf20729016fc828c456f0202fba1c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50835 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

_M2ugKj4a1)hVfV& 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 C6h3|c diff --git a/public/assets/css/app.css b/public/assets/css/app.css index b2ca9d9..54b2e32 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,327 +1,330 @@ -:root{ - --bg:#0b1020; - --card:#111a33; - --muted:#9fb0d0; - --text:#e9eefc; - --accent:#7aa2ff; - --danger:#ff6b6b; - --ok:#38d9a9; - --border:rgba(255,255,255,.12); - --shadow:0 10px 30px rgba(0,0,0,.35); - --radius:16px; - --font:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; -} -*{box-sizing:border-box} -body{ - margin:0; - background:linear-gradient(180deg, #070a14 0%, var(--bg) 100%); - color:var(--text); - font-family:var(--font); -} -a{color:var(--accent); text-decoration:none} -a:hover{text-decoration:underline} -.container{max-width:1100px; margin:0 auto; padding:18px} -.topbar{ - display:flex; justify-content:space-between; align-items:center; - padding:12px 16px; border:1px solid var(--border); border-radius:var(--radius); - background:rgba(17,26,51,.75); box-shadow:var(--shadow); backdrop-filter: blur(6px); -} -.brand{font-weight:700; letter-spacing:.3px} -.nav a{margin-left:14px; font-weight:600} -.card{ - margin-top:16px; - border:1px solid var(--border); - border-radius:var(--radius); - background:rgba(17,26,51,.75); - box-shadow:var(--shadow); - overflow:visible; -} -.card .hd{ - padding:14px 16px; - border-bottom:1px solid var(--border); - display:flex; justify-content:space-between; align-items:center; gap:10px; -} -.card .bd{padding:16px} -.grid{ - display:grid; - grid-template-columns: 1fr 1fr; - gap:14px; -} -@media (max-width: 860px){ .grid{grid-template-columns:1fr} } -.badge{display:inline-block; padding:4px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); color:var(--muted)} -.badge.ok{border-color:rgba(56,217,169,.5); color:var(--ok)} -.badge.warn{border-color:rgba(255,209,102,.5); color:#ffd166} -.badge.danger{border-color:rgba(255,107,107,.5); color:var(--danger)} -.btn{ - display:inline-flex; align-items:center; justify-content:center; - padding:10px 14px; border-radius:12px; - border:1px solid var(--border); - background:rgba(122,162,255,.12); - color:var(--text); - cursor:pointer; font-weight:700; -} -.btn:hover{filter:brightness(1.05)} -.btn.danger{background:rgba(255,107,107,.12)} -.btn.ok{background:rgba(56,217,169,.12)} -.btn.secondary{background:rgba(255,255,255,.06)} -.btn:disabled{opacity:.55; cursor:not-allowed} -.input, select, textarea{ - width:100%; - padding:10px 12px; - border-radius:12px; - border:1px solid var(--border); - background:rgba(255,255,255,.05); - color:var(--text); - outline:none; -} -label{display:block; font-size:13px; color:var(--muted); margin-bottom:6px} -.row{display:grid; grid-template-columns:1fr 1fr; gap:12px} -@media (max-width: 600px){ .row{grid-template-columns:1fr} } -.table{ - width:100%; - border-collapse:collapse; - font-size:14px; -} -.table th, .table td{ - padding:10px 8px; - border-bottom:1px solid var(--border); - vertical-align:top; -} -.table th{color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.08em; text-align:left} -.table td small{color:var(--muted)} -.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace} -.notice{ - padding:12px 14px; - border-radius:14px; - border:1px solid var(--border); - background:rgba(255,255,255,.04); - color:var(--muted); -} -.notice strong{color:var(--text)} -hr.sep{border:none; border-top:1px solid var(--border); margin:14px 0} -.footer-actions{ - display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end; - border-top:1px solid var(--border); - padding:14px 16px; - background:rgba(0,0,0,.12); -} -.kpi{ - display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; -} -@media (max-width: 860px){ .kpi{grid-template-columns:1fr} } -.kpi .box{ - padding:12px 14px; border-radius:14px; border:1px solid var(--border); - background:rgba(255,255,255,.04); -} -.kpi .box .v{font-size:22px; font-weight:800} -.kpi .box .k{font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.08em} - -.week-sep td{ - background: rgba(255,255,255,.06); - border-top: 2px solid var(--border); - font-weight: 800; - letter-spacing: .06em; - text-transform: uppercase; - padding: 10px 12px; -} - -:root{ color-scheme: dark; } -select{ color-scheme: dark; } -select option{ - background: var(--card); - color: var(--text); -} - -/* Make wide tables usable on mobile */ -.table-wrap{ - width:100%; - overflow-x:auto; - -webkit-overflow-scrolling:touch; -} - -/* Keep columns readable; wrapper will scroll on small screens */ -.table{ min-width: 720px; } - -/* Primary navigation dropdown */ -.nav{display:flex; align-items:center; gap:10px; flex-wrap:wrap} -.nav a{margin-left:0} -.nav-dropdown{position:relative; display:inline-flex} -.nav-dropbtn{font:inherit; font-weight:700; color:var(--accent); border:0; background:transparent; cursor:pointer; padding:0} -.nav-dropdown-menu{ - display:none; position:absolute; right:0; top:100%; min-width:190px; z-index:20; - padding:8px; border:1px solid var(--border); border-radius:14px; - background:rgba(17,26,51,.98); box-shadow:var(--shadow); -} -.nav-dropdown:hover .nav-dropdown-menu, -.nav-dropdown:focus-within .nav-dropdown-menu{display:grid; gap:4px} -.nav-dropdown-menu a{display:block; padding:8px 10px; border-radius:10px} -.nav-dropdown-menu a:hover{background:rgba(255,255,255,.06); text-decoration:none} - -.time-entry-table, -.production-entry-table, -.staff-table{min-width:0} -.period-picker{max-width:460px} -.report-actions{display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:14px} -.report-actions form{margin:0} -.flex-notice{flex:1; min-width:260px} -.timecards-grid{align-items:start} -.actions-cell{text-align:right} - -@media (max-width: 700px){ - body{background:var(--bg)} - .container{padding:10px} - .topbar{align-items:flex-start; gap:10px; padding:12px} - .brand{font-size:18px} - .nav{width:100%; justify-content:flex-start; gap:12px} - .nav-dropdown-menu{left:0; right:auto} - .card{margin-top:12px; border-radius:14px} - .card .hd{display:block; padding:12px} - .card .bd{padding:12px} - .footer-actions{justify-content:stretch; padding:12px} - .footer-actions .btn,.footer-actions form,.report-actions .btn{width:100%} - .btn{width:auto; min-height:42px} - .input, select, textarea{font-size:16px; min-height:42px} - .table-wrap.no-mobile-scroll{overflow:visible} - - .time-entry-table, - .time-entry-table thead, - .time-entry-table tbody, - .time-entry-table tr, - .time-entry-table td, - .production-entry-table, - .production-entry-table thead, - .production-entry-table tbody, - .production-entry-table tr, - .production-entry-table td, - .mobile-card-table, - .mobile-card-table thead, - .mobile-card-table tbody, - .mobile-card-table tr, - .mobile-card-table td{display:block; width:100%} - - .time-entry-table thead, - .production-entry-table thead, - .mobile-card-table thead{display:none} - - .time-entry-table tr, - .production-entry-table tr, - .mobile-card-table tr{ - margin:0 0 12px; - padding:10px; - border:1px solid var(--border); - border-radius:14px; - background:rgba(255,255,255,.035); - } - .time-entry-table td, - .production-entry-table td, - .mobile-card-table td{ - border-bottom:0; - padding:7px 0; - } - .time-entry-table td:not(:first-child), - .production-entry-table td:not(:first-child), - .mobile-card-table td:not(:first-child){border-top:1px solid rgba(255,255,255,.08)} - - .time-entry-table td::before, - .production-entry-table td::before, - .mobile-card-table td::before{ - display:block; - margin-bottom:4px; - color:var(--muted); - font-size:12px; - font-weight:800; - text-transform:uppercase; - letter-spacing:.06em; - } - .time-entry-table td:nth-child(1)::before{content:"Day / Date"} - .time-entry-table td:nth-child(2)::before{content:"Time In"} - .time-entry-table td:nth-child(3)::before{content:"Time Out"} - .time-entry-table td:nth-child(4)::before{content:"Total"} - - .production-entry-table td:nth-child(1)::before{content:"Type"} - .production-entry-table td:nth-child(2)::before{content:"Count"} - .production-entry-table td:nth-child(3)::before{content:"Rate"} - .production-entry-table td:nth-child(4)::before{content:"Line Total"} - - .mobile-card-table td::before{content:attr(data-label)} - .mobile-card-table .actions-cell{text-align:left} - - .week-sep{padding:0!important; border:0!important; background:transparent!important} - .week-sep td{border:0; border-radius:12px; margin-bottom:8px; padding:9px 10px!important} - .week-sep td::before{display:none} -} - -/* 2026-04-27 cleanup patch: real Admin dropdown + vertical Time Cards layout */ -.topbar{ - flex-wrap:wrap; - position:relative; - z-index:100; -} -.nav-dropdown{ - position:relative; - display:inline-block; -} -.nav-dropbtn{ - display:inline-flex; - align-items:center; - gap:4px; - font:inherit; - font-weight:700; - color:var(--accent); - background:transparent; - border:0; - cursor:pointer; - padding:0; - list-style:none; - user-select:none; -} -.nav-dropbtn::-webkit-details-marker{display:none} -.nav-dropdown-menu{ - display:none !important; - position:absolute; - right:0; - top:calc(100% + 8px); - min-width:210px; - z-index:999; - padding:8px; - border:1px solid var(--border); - border-radius:14px; - background:rgba(17,26,51,.98); - box-shadow:var(--shadow); -} -.nav-dropdown[open] .nav-dropdown-menu{ - display:grid !important; - gap:4px; -} -.nav-dropdown-menu a{ - display:block; - margin:0; - padding:9px 10px; - border-radius:10px; - white-space:nowrap; -} -.nav-dropdown-menu a:hover{ - background:rgba(255,255,255,.06); - text-decoration:none; -} -.timecards-grid{ - display:block !important; -} -.timecards-grid > .card{ - width:100%; -} -.timecards-grid > .card + .card{ - margin-top:16px; -} - -@media (max-width:700px){ - .nav{gap:10px} - .nav-dropdown-menu{ - left:0; - right:auto; - max-width:calc(100vw - 40px); - } - .table-wrap{ - overflow-x:visible; - } -} +:root{ + --bg:#0b1020; + --card:#111a33; + --muted:#9fb0d0; + --text:#e9eefc; + --accent:#7aa2ff; + --danger:#ff6b6b; + --ok:#38d9a9; + --border:rgba(255,255,255,.12); + --shadow:0 10px 30px rgba(0,0,0,.35); + --radius:16px; + --font:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; +} +*{box-sizing:border-box} +body{ + margin:0; + background:linear-gradient(180deg, #070a14 0%, var(--bg) 100%); + color:var(--text); + font-family:var(--font); +} +a{color:var(--accent); text-decoration:none} +a:hover{text-decoration:underline} +.container{max-width:1100px; margin:0 auto; padding:18px} +.topbar{ + display:flex; justify-content:space-between; align-items:center; + padding:12px 16px; border:1px solid var(--border); border-radius:var(--radius); + background:rgba(17,26,51,.75); box-shadow:var(--shadow); backdrop-filter: blur(6px); +} +.brand{font-weight:700; letter-spacing:.3px} +.nav a{margin-left:14px; font-weight:600} +.card{ + margin-top:16px; + border:1px solid var(--border); + border-radius:var(--radius); + background:rgba(17,26,51,.75); + box-shadow:var(--shadow); + overflow:visible; +} +.card .hd{ + padding:14px 16px; + border-bottom:1px solid var(--border); + display:flex; justify-content:space-between; align-items:center; gap:10px; +} +.card .bd{padding:16px} +.grid{ + display:grid; + grid-template-columns: 1fr 1fr; + gap:14px; +} +@media (max-width: 860px){ .grid{grid-template-columns:1fr} } +.badge{display:inline-block; padding:4px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); color:var(--muted)} +.badge.ok{border-color:rgba(56,217,169,.5); color:var(--ok)} +.badge.warn{border-color:rgba(255,209,102,.5); color:#ffd166} +.badge.danger{border-color:rgba(255,107,107,.5); color:var(--danger)} +.btn{ + display:inline-flex; align-items:center; justify-content:center; + padding:10px 14px; border-radius:12px; + border:1px solid var(--border); + background:rgba(122,162,255,.12); + color:var(--text); + cursor:pointer; font-weight:700; +} +.btn:hover{filter:brightness(1.05)} +.btn.danger{background:rgba(255,107,107,.12)} +.btn.ok{background:rgba(56,217,169,.12)} +.btn.secondary{background:rgba(255,255,255,.06)} +.btn:disabled{opacity:.55; cursor:not-allowed} +.input, select, textarea{ + width:100%; + padding:10px 12px; + border-radius:12px; + border:1px solid var(--border); + background:rgba(255,255,255,.05); + color:var(--text); + outline:none; +} +label{display:block; font-size:13px; color:var(--muted); margin-bottom:6px} +.row{display:grid; grid-template-columns:1fr 1fr; gap:12px} +@media (max-width: 600px){ .row{grid-template-columns:1fr} } +.table{ + width:100%; + border-collapse:collapse; + font-size:14px; +} +.table th, .table td{ + padding:10px 8px; + border-bottom:1px solid var(--border); + vertical-align:top; +} +.table th{color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.08em; text-align:left} +.table td small{color:var(--muted)} +.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace} +.notice{ + padding:12px 14px; + border-radius:14px; + border:1px solid var(--border); + background:rgba(255,255,255,.04); + color:var(--muted); +} +.notice strong{color:var(--text)} +hr.sep{border:none; border-top:1px solid var(--border); margin:14px 0} +.footer-actions{ + display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end; + border-top:1px solid var(--border); + padding:14px 16px; + background:rgba(0,0,0,.12); +} +.kpi{ + display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; +} +@media (max-width: 860px){ .kpi{grid-template-columns:1fr} } +.kpi .box{ + padding:12px 14px; border-radius:14px; border:1px solid var(--border); + background:rgba(255,255,255,.04); +} +.kpi .box .v{font-size:22px; font-weight:800} +.kpi .box .k{font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.08em} + +.week-sep td{ + background: rgba(255,255,255,.06); + border-top: 2px solid var(--border); + font-weight: 800; + letter-spacing: .06em; + text-transform: uppercase; + padding: 10px 12px; +} + +:root{ color-scheme: dark; } +select{ color-scheme: dark; } +select option{ + background: var(--card); + color: var(--text); +} + +/* Make wide tables usable on mobile */ +.table-wrap{ + width:100%; + overflow-x:auto; + -webkit-overflow-scrolling:touch; +} + +/* Keep columns readable; wrapper will scroll on small screens */ +.table{ min-width: 720px; } + +/* Primary navigation dropdown */ +.nav{display:flex; align-items:center; gap:10px; flex-wrap:wrap} +.nav a{margin-left:0} +.nav-form{display:inline; margin:0} +.nav-link-button{font:inherit; font-weight:600; color:var(--accent); border:0; background:transparent; padding:0; cursor:pointer} +.nav-link-button:hover{text-decoration:underline} +.nav-dropdown{position:relative; display:inline-flex} +.nav-dropbtn{font:inherit; font-weight:700; color:var(--accent); border:0; background:transparent; cursor:pointer; padding:0} +.nav-dropdown-menu{ + display:none; position:absolute; right:0; top:100%; min-width:190px; z-index:20; + padding:8px; border:1px solid var(--border); border-radius:14px; + background:rgba(17,26,51,.98); box-shadow:var(--shadow); +} +.nav-dropdown:hover .nav-dropdown-menu, +.nav-dropdown:focus-within .nav-dropdown-menu{display:grid; gap:4px} +.nav-dropdown-menu a{display:block; padding:8px 10px; border-radius:10px} +.nav-dropdown-menu a:hover{background:rgba(255,255,255,.06); text-decoration:none} + +.time-entry-table, +.production-entry-table, +.staff-table{min-width:0} +.period-picker{max-width:460px} +.report-actions{display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:14px} +.report-actions form{margin:0} +.flex-notice{flex:1; min-width:260px} +.timecards-grid{align-items:start} +.actions-cell{text-align:right} + +@media (max-width: 700px){ + body{background:var(--bg)} + .container{padding:10px} + .topbar{align-items:flex-start; gap:10px; padding:12px} + .brand{font-size:18px} + .nav{width:100%; justify-content:flex-start; gap:12px} + .nav-dropdown-menu{left:0; right:auto} + .card{margin-top:12px; border-radius:14px} + .card .hd{display:block; padding:12px} + .card .bd{padding:12px} + .footer-actions{justify-content:stretch; padding:12px} + .footer-actions .btn,.footer-actions form,.report-actions .btn{width:100%} + .btn{width:auto; min-height:42px} + .input, select, textarea{font-size:16px; min-height:42px} + .table-wrap.no-mobile-scroll{overflow:visible} + + .time-entry-table, + .time-entry-table thead, + .time-entry-table tbody, + .time-entry-table tr, + .time-entry-table td, + .production-entry-table, + .production-entry-table thead, + .production-entry-table tbody, + .production-entry-table tr, + .production-entry-table td, + .mobile-card-table, + .mobile-card-table thead, + .mobile-card-table tbody, + .mobile-card-table tr, + .mobile-card-table td{display:block; width:100%} + + .time-entry-table thead, + .production-entry-table thead, + .mobile-card-table thead{display:none} + + .time-entry-table tr, + .production-entry-table tr, + .mobile-card-table tr{ + margin:0 0 12px; + padding:10px; + border:1px solid var(--border); + border-radius:14px; + background:rgba(255,255,255,.035); + } + .time-entry-table td, + .production-entry-table td, + .mobile-card-table td{ + border-bottom:0; + padding:7px 0; + } + .time-entry-table td:not(:first-child), + .production-entry-table td:not(:first-child), + .mobile-card-table td:not(:first-child){border-top:1px solid rgba(255,255,255,.08)} + + .time-entry-table td::before, + .production-entry-table td::before, + .mobile-card-table td::before{ + display:block; + margin-bottom:4px; + color:var(--muted); + font-size:12px; + font-weight:800; + text-transform:uppercase; + letter-spacing:.06em; + } + .time-entry-table td:nth-child(1)::before{content:"Day / Date"} + .time-entry-table td:nth-child(2)::before{content:"Time In"} + .time-entry-table td:nth-child(3)::before{content:"Time Out"} + .time-entry-table td:nth-child(4)::before{content:"Total"} + + .production-entry-table td:nth-child(1)::before{content:"Type"} + .production-entry-table td:nth-child(2)::before{content:"Count"} + .production-entry-table td:nth-child(3)::before{content:"Rate"} + .production-entry-table td:nth-child(4)::before{content:"Line Total"} + + .mobile-card-table td::before{content:attr(data-label)} + .mobile-card-table .actions-cell{text-align:left} + + .week-sep{padding:0!important; border:0!important; background:transparent!important} + .week-sep td{border:0; border-radius:12px; margin-bottom:8px; padding:9px 10px!important} + .week-sep td::before{display:none} +} + +/* 2026-04-27 cleanup patch: real Admin dropdown + vertical Time Cards layout */ +.topbar{ + flex-wrap:wrap; + position:relative; + z-index:100; +} +.nav-dropdown{ + position:relative; + display:inline-block; +} +.nav-dropbtn{ + display:inline-flex; + align-items:center; + gap:4px; + font:inherit; + font-weight:700; + color:var(--accent); + background:transparent; + border:0; + cursor:pointer; + padding:0; + list-style:none; + user-select:none; +} +.nav-dropbtn::-webkit-details-marker{display:none} +.nav-dropdown-menu{ + display:none !important; + position:absolute; + right:0; + top:calc(100% + 8px); + min-width:210px; + z-index:999; + padding:8px; + border:1px solid var(--border); + border-radius:14px; + background:rgba(17,26,51,.98); + box-shadow:var(--shadow); +} +.nav-dropdown[open] .nav-dropdown-menu{ + display:grid !important; + gap:4px; +} +.nav-dropdown-menu a{ + display:block; + margin:0; + padding:9px 10px; + border-radius:10px; + white-space:nowrap; +} +.nav-dropdown-menu a:hover{ + background:rgba(255,255,255,.06); + text-decoration:none; +} +.timecards-grid{ + display:block !important; +} +.timecards-grid > .card{ + width:100%; +} +.timecards-grid > .card + .card{ + margin-top:16px; +} + +@media (max-width:700px){ + .nav{gap:10px} + .nav-dropdown-menu{ + left:0; + right:auto; + max-width:calc(100vw - 40px); + } + .table-wrap{ + overflow-x:visible; + } +} diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png deleted file mode 100644 index 0cf217275f9c6b91fed6fe4d9c98c6db8c5a48e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 634 zcmV-=0)_pFP)xX=!c1_DT;sR%+39MtCE+R{4*tvLxbe5W^WzR&0R z=6T;oa7r;nxsmPl4_3SLEh3MMWn)(hGhdW5{uO;bVHm4UzbGG)P_@<5G()}Bs;+03 ztS#$}2D<=L5VYE*JNorNpM4-@{OR2h4GMn&NvPVq9ej1c{F%9u&OI4|;^|}n8hDSo zYo>B?ZL|7z=WZFoP;XOmBvLbTR?%4+4*RBuBBAF1(uAUPdFxBfZK>Sj^8%Fgj|rFG zYKtjKr?l6j4*J*irE=^^xa6FTQ*pQzhx-~78aMYBzlGI7Np_jdr4QCZ*;Imd4PI+- zvwSxRZ5oW(kbep<0obq>JRY+}Jcu?dzgXUnIy-3~1CM|%U>kVPZ>UxF*dQK88uQm49rS&-}VHm5$L(le6cg@s!0efNJGwVQnoHmR# z2_TUEGMtlZL!%Mj?EjKz;2n?nX1{wch8j{kqu}^38h8)FO*1alx~y(y7f&|+S*8#j zQh)Yppzq|+^(m0r8PlB0J2^6^yb*0FP(m<8_q8ZKtfGP*LzNHS+i?Ndn(>Ict*E|?GMP( zZ^Pe!h-4l>f&hX7+DbKiTklZTp55G3_~b7}{p!Kf&x(Bh5eeC<0s0^`@rBUQ+)rwi zRVT+B_VGf037(LUx>m(50(lhJ$DLZ|nT^es>!~>_JQ>+psT!XGknrWR>zbeU{vbP3 z_nWD?(hR#L#Hul_u#kqLk0Z0c4Av%IFzQ#H8of)xyH^D0gV5=9ErSi6vk9Z`!br*D zehJ0Jm@i0zLs2qdq0cuKDCqiu%ssUt@au_L+v0&6X%1jp+*kkG$wcnQUOS=d zT?^v^=0CNut!sC!7#?$23HHd(SoTViP*i8wv+Aueb#%+>@ApCI5N>ZN$=Q+V3$O znztmFqqGaGt4#uU5Y7R+d*Kf@98%z%gtQHR(%>7{`ty~`nxp9OSgNDi3FSHf1JA+w z_h10XN%+xX@&GsrNm0Uwcn;KRs<5DovF(dBR(&{%GY(6*@K_q4U0{7y^;_x z8;qhSo-{>hV(L^h;8z5+eO>?pw#I|?R%kNvwlLD>@q(zqu3_ir1)#XtnZv2nnC5Ze zgk!0bdO+(?z9_+Sv&|m^uiBxkNxtFq+wh9m56Py#j}D$~Zz?FT?`8vJ;TLDxRuxu` zjGvH@5&-VB+4ae=XBl^DoqacPGfD`G9omlMfV0k>O7+11?gO#aC;s!t@r})vwTvSY z7Ii*c!`z|Pab*0~mQu^m>1QNFE&%|DeEt#Rskzeh&zCP%G!ETp07d1=`2FEp*WuZs zzgT?q(s+4LAA~+y*E|&HoSSj?U3lBJxKG}QOR2__>F&B);xGD^wY)pl-VM=(=W2QI z2}tdXCWfYFLwiblT}!dH6AROOYzQ5znYRf0SQ-VE$hzo~s|bYqKU9G)$PCnKHZE59nhMfw^` z*Sklzw*IiTEgfGmd`8O;*&m85{SFwo{E{IdAmDbz#dk!9(mK)BR7}jbX3qrj`M$l7 ztK?Hna~d=~o`WCXx@wDeQ*K&Q@^)YRJPK?#4fnQ4IJm*|dkoc6v;nM?Lvc^1(xJ)7TW-^QtpqR2Dqq)s Y0bl+u>n-d>Qvd(}07*qoM6N<$f^LHSX8-^I diff --git a/public/favicon-512x512.png b/public/favicon-512x512.png deleted file mode 100644 index a2ffbcb008bf20729016fc828c456f0202fba1c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50835 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

_M2ugKj4a1)hVfV& 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 + TimeClock Pro + + + + + diff --git a/public/index.php b/public/index.php index 2634383..ea16cfe 100644 --- a/public/index.php +++ b/public/index.php @@ -1,92 +1,92 @@ -get('/login', fn() => AuthController::showLogin()); -$router->post('/login', fn() => AuthController::login()); -$router->get('/logout', fn() => AuthController::logout()); - -// Home redirect -$router->get('/', fn() => \redirect('/timecards')); -$router->get('/home', fn() => EmployeeController::home()); -$router->get('/timecards', fn() => AdminController::timecards()); -// Employee -$router->get('/dashboard', fn() => EmployeeController::dashboard()); -$router->get('/timecard', fn() => EmployeeController::timecard()); -$router->post('/timecard/save', fn() => EmployeeController::saveTimecard()); -$router->post('/timecard/submit', fn() => EmployeeController::submitTimecard()); - -// Admin -$router->get('/admin', fn() => AdminController::dashboard()); -$router->get('/admin/employees', fn() => AdminController::employees()); -$router->get('/admin/employees/new', fn($p=[]) => AdminController::employeeEdit(['id'=>0])); -$router->get('/admin/employees/{id}', fn($p) => AdminController::employeeEdit($p)); -$router->post('/admin/employees/save', fn() => AdminController::employeeSave()); -$router->post('/admin/employees/reset-password', fn() => AdminController::employeeResetPassword()); -$router->post('/admin/employees/delete', fn() => AdminController::employeeDelete()); - -$router->get('/admin/timecards', fn() => AdminController::timecards()); -$router->get('/admin/timecards/{id}', fn($p) => AdminController::timecardEdit($p)); -$router->post('/admin/timecards/save', fn() => AdminController::timecardSave()); -$router->post('/admin/timecards/lock', fn() => AdminController::timecardLock()); -$router->post('/admin/timecards/unlock', fn() => AdminController::timecardUnlock()); - -$router->post('/admin/payperiod/lock', fn() => AdminController::lockGenerateReport()); -$router->post('/admin/payperiod/regenerate', fn() => AdminController::regeneratePayPeriodReport()); -// Providers (admin-only) -$router->get('/admin/providers', fn() => ProviderController::providers()); -$router->get('/admin/providers/new', fn($p=[]) => ProviderController::providerEdit(['id'=>0])); -$router->get('/admin/providers/{id}', fn($p) => ProviderController::providerEdit($p)); -$router->post('/admin/providers/save', fn() => ProviderController::providerSave()); - -$router->get('/admin/appointment-types', fn() => ProviderController::appointmentTypes()); -$router->post('/admin/appointment-types/save', fn() => ProviderController::appointmentTypeSave()); -$router->post('/admin/appointment-types/toggle', fn() => ProviderController::appointmentTypeToggle()); -$router->post('/admin/appointment-types/delete', fn() => ProviderController::appointmentTypeDelete()); - -$router->get('/admin/providers/{id}/rates', fn($p) => ProviderController::providerRates($p)); -$router->post('/admin/providers/{id}/rates/add', fn($p) => ProviderController::providerRateAdd($p)); -$router->post('/admin/providers/{id}/rates/end', fn($p) => ProviderController::providerRateEnd($p)); -$router->post('/admin/providers/{id}/rates/update', fn($p) => ProviderController::providerRateUpdate($p)); -$router->post('/admin/providers/{id}/rates/delete', fn($p) => ProviderController::providerRateDelete($p)); - -$router->get('/admin/provider-production', fn() => ProviderController::production()); -$router->get('/admin/provider-production/{id}', fn($p) => ProviderController::productionEdit($p)); -$router->post('/admin/provider-production/save', fn() => ProviderController::productionSave()); -$router->post('/admin/provider-production/lock', fn() => ProviderController::productionLock()); -$router->post('/admin/provider-production/unlock', fn() => ProviderController::productionUnlock()); - -// Provider production working routes -$router->get('/provider-production/{id}', fn($p) => ProviderController::productionEdit($p)); -$router->post('/provider-production/save', fn() => ProviderController::productionSave()); -$router->post('/provider-production/lock', fn() => ProviderController::productionLock()); -$router->post('/provider-production/unlock', fn() => ProviderController::productionUnlock()); - - -// Settings -$router->get('/admin/settings', fn() => SettingsController::settings()); -$router->post('/admin/settings/save', fn() => SettingsController::save()); - -// Debug (client-side error capture) -$router->post('/debug/client-error', fn() => DebugController::clientError()); - -// Dispatch -$uri = $_SERVER['REQUEST_URI'] ?? '/'; -$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/'); -if ($base !== '' && $base !== '/' && strpos($uri, $base) === 0) { - $uri = substr($uri, strlen($base)); - if ($uri === '') $uri = '/'; -} -$router->dispatch($_SERVER['REQUEST_METHOD'], $uri); +get('/login', fn() => AuthController::showLogin()); +$router->post('/login', fn() => AuthController::login()); +$router->post('/logout', fn() => AuthController::logout()); + +// Home redirect +$router->get('/', fn() => AuthController::home()); +$router->get('/home', fn() => AuthController::home()); +$router->get('/timecards', fn() => AdminController::timecards()); +// Employee +$router->get('/dashboard', fn() => EmployeeController::dashboard()); +$router->get('/timecard', fn() => EmployeeController::timecard()); +$router->post('/timecard/save', fn() => EmployeeController::saveTimecard()); +$router->post('/timecard/submit', fn() => EmployeeController::submitTimecard()); + +// Admin +$router->get('/admin', fn() => AdminController::dashboard()); +$router->get('/admin/employees', fn() => AdminController::employees()); +$router->get('/admin/employees/new', fn($p=[]) => AdminController::employeeEdit(['id'=>0])); +$router->get('/admin/employees/{id}', fn($p) => AdminController::employeeEdit($p)); +$router->post('/admin/employees/save', fn() => AdminController::employeeSave()); +$router->post('/admin/employees/reset-password', fn() => AdminController::employeeResetPassword()); +$router->post('/admin/employees/delete', fn() => AdminController::employeeDelete()); + +$router->get('/admin/timecards', fn() => AdminController::timecards()); +$router->get('/admin/timecards/{id}', fn($p) => AdminController::timecardEdit($p)); +$router->post('/admin/timecards/save', fn() => AdminController::timecardSave()); +$router->post('/admin/timecards/lock', fn() => AdminController::timecardLock()); +$router->post('/admin/timecards/unlock', fn() => AdminController::timecardUnlock()); + +$router->post('/admin/payperiod/lock', fn() => AdminController::lockGenerateReport()); +$router->post('/admin/payperiod/regenerate', fn() => AdminController::regeneratePayPeriodReport()); +// Providers (admin-only) +$router->get('/admin/providers', fn() => ProviderController::providers()); +$router->get('/admin/providers/new', fn($p=[]) => ProviderController::providerEdit(['id'=>0])); +$router->get('/admin/providers/{id}', fn($p) => ProviderController::providerEdit($p)); +$router->post('/admin/providers/save', fn() => ProviderController::providerSave()); + +$router->get('/admin/appointment-types', fn() => ProviderController::appointmentTypes()); +$router->post('/admin/appointment-types/save', fn() => ProviderController::appointmentTypeSave()); +$router->post('/admin/appointment-types/toggle', fn() => ProviderController::appointmentTypeToggle()); +$router->post('/admin/appointment-types/delete', fn() => ProviderController::appointmentTypeDelete()); + +$router->get('/admin/providers/{id}/rates', fn($p) => ProviderController::providerRates($p)); +$router->post('/admin/providers/{id}/rates/add', fn($p) => ProviderController::providerRateAdd($p)); +$router->post('/admin/providers/{id}/rates/end', fn($p) => ProviderController::providerRateEnd($p)); +$router->post('/admin/providers/{id}/rates/update', fn($p) => ProviderController::providerRateUpdate($p)); +$router->post('/admin/providers/{id}/rates/delete', fn($p) => ProviderController::providerRateDelete($p)); + +$router->get('/admin/provider-production', fn() => ProviderController::production()); +$router->get('/admin/provider-production/{id}', fn($p) => ProviderController::productionEdit($p)); +$router->post('/admin/provider-production/save', fn() => ProviderController::productionSave()); +$router->post('/admin/provider-production/lock', fn() => ProviderController::productionLock()); +$router->post('/admin/provider-production/unlock', fn() => ProviderController::productionUnlock()); + +// Provider production working routes +$router->get('/provider-production/{id}', fn($p) => ProviderController::productionEdit($p)); +$router->post('/provider-production/save', fn() => ProviderController::productionSave()); +$router->post('/provider-production/lock', fn() => ProviderController::productionLock()); +$router->post('/provider-production/unlock', fn() => ProviderController::productionUnlock()); + + +// Settings +$router->get('/admin/settings', fn() => SettingsController::settings()); +$router->post('/admin/settings/save', fn() => SettingsController::save()); + +// Debug (client-side error capture) +$router->post('/debug/client-error', fn() => DebugController::clientError()); + +// Dispatch +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/'); +if ($base !== '' && $base !== '/' && strpos($uri, $base) === 0) { + $uri = substr($uri, strlen($base)); + if ($uri === '') $uri = '/'; +} +$router->dispatch($_SERVER['REQUEST_METHOD'], $uri); diff --git a/public/site.webmanifest b/public/site.webmanifest index e4bd9a9..950bf65 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -2,10 +2,9 @@ "name": "TimeClock Pro", "short_name": "TimeClock", "icons": [ - { "src": "android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, - { "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } ], - "theme_color": "#000000", - "background_color": "#000000", + "theme_color": "#0b1020", + "background_color": "#0b1020", "display": "standalone" } diff --git a/storage/.htaccess b/storage/.htaccess new file mode 100644 index 0000000..2ba198a --- /dev/null +++ b/storage/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/storage/logs/.htaccess b/storage/logs/.htaccess index 03688ee..2ba198a 100644 --- a/storage/logs/.htaccess +++ b/storage/logs/.htaccess @@ -1 +1,6 @@ -Deny from all + + Require all denied + + + Deny from all + diff --git a/storage/rate-limits/.gitkeep b/storage/rate-limits/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/rate-limits/.gitkeep @@ -0,0 +1 @@ + diff --git a/storage/reports/.htaccess b/storage/reports/.htaccess index 03688ee..2ba198a 100644 --- a/storage/reports/.htaccess +++ b/storage/reports/.htaccess @@ -1 +1,6 @@ -Deny from all + + Require all denied + + + Deny from all + diff --git a/tests/TimeServiceTest.php b/tests/TimeServiceTest.php new file mode 100644 index 0000000..ee631e0 --- /dev/null +++ b/tests/TimeServiceTest.php @@ -0,0 +1,52 @@ + [0, 15, 30, 45], + 'rounding_mode' => 'nearest', + 'days_to_show' => [1, 2, 3, 4, 5, 6], +]; +$period = ['start_date' => '2026-07-20', 'end_date' => '2026-08-02']; + +assert_same(510, TimeService::timeToMinutes('08:30'), 'Valid time conversion failed.'); +assert_same(null, TimeService::timeToMinutes('24:00'), 'Out-of-range hours must be rejected.'); +assert_same(null, TimeService::timeToMinutes('08:99'), 'Out-of-range minutes must be rejected.'); +assert_same('08:30:00', TimeService::roundTime('08:29', $settings), 'Nearest-quarter rounding failed.'); + +$rows = TimeService::normalizeRows([ + '2026-07-20' => ['in' => '08:02', 'out' => '16:31'], +], $period, $settings); +assert_same('08:00:00', $rows['2026-07-20']['in'], 'Time-in normalization failed.'); +assert_same('16:30:00', $rows['2026-07-20']['out'], 'Time-out normalization failed.'); + +assert_throws(static function() use ($period, $settings): void { + TimeService::normalizeRows(['2026-08-03' => ['in' => '08:00', 'out' => '17:00']], $period, $settings); +}, 'Dates outside the pay period must be rejected.'); + +assert_throws(static function() use ($period, $settings): void { + TimeService::normalizeRows(['2026-07-20' => ['in' => 'not-a-time', 'out' => '17:00']], $period, $settings); +}, 'Malformed times must be rejected.'); + +fwrite(STDOUT, "TimeService regression tests passed.\n"); + From 40293cf7c18c648caa7dc74a3593cf7d49cf2134 Mon Sep 17 00:00:00 2001 From: drumhead39 Date: Tue, 21 Jul 2026 13:57:35 -0400 Subject: [PATCH 2/2] Standardize repository line endings --- .gitattributes | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8641d1c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.ico binary +*.jpg binary +*.jpeg binary +*.png binary +*.pdf binary +*.zip binary