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 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 d8ac7c5..0000000 Binary files a/public/android-chrome-192x192.png and /dev/null differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png deleted file mode 100644 index a2ffbcb..0000000 Binary files a/public/android-chrome-512x512.png and /dev/null differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index 8e5cbe3..0000000 Binary files a/public/apple-touch-icon.png and /dev/null differ 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 0cf2172..0000000 Binary files a/public/favicon-16x16.png and /dev/null differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png deleted file mode 100644 index 3165edd..0000000 Binary files a/public/favicon-32x32.png and /dev/null differ diff --git a/public/favicon-512x512.png b/public/favicon-512x512.png deleted file mode 100644 index a2ffbcb..0000000 Binary files a/public/favicon-512x512.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 004b93a..0000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..306287a --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,7 @@ + + 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"); +