fix teacher, parent and admin pages
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,201 @@
|
|||||||
|
{
|
||||||
|
"title": "School Policies",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"heading": "",
|
||||||
|
"subsections": {
|
||||||
|
"title": "",
|
||||||
|
"body": "Dear Parents and Students,\n\nIt is our pleasure to greet and welcome you to our school year 2025-2026. Classes are scheduled to start at ISGL (Islamic Society of Greater Lowell) Masjid starting 09-21-2025. We start each year with the vision of achieving excellence in every aspect at Al Rahma Sunday School at ISGL.\n\nAs with any school, there are policies, procedures and guidelines that must be followed to ensure a smooth school operation.\n\nWe therefore expect your support and cooperation to achieve the goals and objectives of Al Rahma Sunday School and to provide a solid foundation for the future of our children."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "POLICIES AND PROCEDURES",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "Objective",
|
||||||
|
"body": "Al Rahma main goal is to make the Masjid a central figure in the lives of our Muslim children from a very young age. Al Rahma is also committed to offering its students a solid curriculum of Quran, Arabic and Islamic Studies. Al Rahma will strive to provide the highest education possible to instill Islamic practices and values into each student."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "Registration",
|
||||||
|
"body": "The school offers a solid curriculum of Islamic Studies and Quran\/Arabic through 9 progressive grades, after which the students will join a 3-year rotation youth program <u>with a minimum age rule of at least 15<\/u>. <strong><u>Starting this year, to be eligible to get into 1st grade, your child needs to be at least 6 years old by 12-31-2025. Younger students will have the possibility to be enrolled in our newly created Kindergarten class if they are at least 5 years old by 12-31-2025.<\/u><\/strong>\n\nRegistration this year will open <strong><u>from 09-01-2025 to 10-01-2025<\/u><\/strong>. We highly encourage parents to enroll their children early so they will not miss out on early classes during the year. <strong><u>By 10-01-2025 at midnight<\/u><\/strong>, registration will have closed and parents will not be allowed to register their kids except if the said parents just recently moved to the area from a faraway town\/city\/state.\n\nThe school will make every effort to broadcast the opening of the registration campaign and communicate the registration link through all means available at the time (school email list, ISGL Whatsapp group, website, social media, …). <strong><u>Starting this year, all parents have to create accounts in the official school website <a href=\"https:\/\/alrahmaisgl.org\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color: #007BFF; text-decoration: underline;\">https:\/\/alrahmaisgl.org<\/a> to be able to register their kids and enroll them in classes. All registrations without exception will happen online, we highly encourage parents to pay tuition online as well<\/u><\/strong>. During the first 2-3 weeks of school, there will be dedicated school staff at the front desk who will help with in-person payments, just walk in and ask for an admin to help you.\n\nPlease make sure your contact information is correct and up to date especially the phone numbers and emails section because that would be our only way to get in contact with you regarding school matters. Also, please make sure to fill out the known allergies section accurately so that we ensure your kids are never presented with food they could be allergic to.\n\n<strong><u>We only accept students with ages between 5 and 18<\/u><\/strong>. For younger students, they must be <strong><u>at least 6 years old by 12-31-2025<\/u><\/strong> to be admitted, there will be no exceptions. <strong><u>Students that are 5 years old by 12-31-2025 can enroll in our newly created Kindergarten class<\/u><\/strong>. Students outside of the range described above are considered too young or too old for the activities offered by the school and will not be eligible to enroll with us. Please do not register your kids if they do not meet the criteria described above.\n\nAfter the registration period ends, parents will be invited to join WhatsApp grade groups where their kids are enrolled. These groups allow teachers, parents and administration to stay close and communicate efficiently about all school matters and events."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tuition",
|
||||||
|
"body": "All parents will be asked to <u>pay tuition for the year<\/u> or come to an agreement with the administration about an interest-free installment plan where <u>payments are made at the beginning of each month starting October 1st<\/u>. Accepted methods of payments are cash, check and debit\/credit card. Tuition covers school expenses related to operations, purchase of books\/supplies\/materials, organizing events for students, etc... The school is committed to offer high quality services while maintaining low cost of operations so that tuition is affordable by the majority of parents. <u>Annual<\/u> tuition for this school year is as follows:\n\n<u>Kindergarten & Grades 1 – 9<\/u>: <strong>$370<\/strong> per child + <strong>$220<\/strong> per additional child\n<u>Youth<\/u>: $200\n\nExamples:\n <table border=\"1\" cellspacing=\"0\" cellpadding=\"8\" style=\"border-collapse: collapse; text-align: center; width: 100%;\">\n <thead>\n <tr>\n <th><\/th>\n <th colspan=\"3\">Example<\/th>\n <\/tr>\n <tr>\n <th><\/th>\n <th>1<\/th>\n <th>2<\/th>\n <th>3<\/th>\n <\/tr>\n <\/thead>\n <tbody>\n <tr>\n <td># kids in K & Grades 1–9<\/td>\n <td>1<\/td>\n <td>2<\/td>\n <td>3<\/td>\n <\/tr>\n <tr>\n <td># kids in Youth<\/td>\n <td>0<\/td>\n <td>0<\/td>\n <td>1<\/td>\n <\/tr>\n <tr>\n <td>Total Tuition<\/td>\n <td><strong>$370<\/strong><\/td>\n <td><strong>$370 + $220 = $590<\/strong><\/td>\n <td><strong>$370 + 2×$220 + $200 = $1010<\/strong><\/td>\n <\/tr>\n <\/tbody>\n <\/table>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "SCHOOL HOURS",
|
||||||
|
"body": "On regular days, Al Rahma School starts at 10:00 AM and finishes at 1:00 PM every Sunday (except Sundays off noted in the calendar). There are two main sessions; the first session from 10:00 AM to 11:30 AM is dedicated to Islamic Studies while the second session from 12:00 PM to 1:00 PM is dedicated to Quran\/Arabic. Students get 30 min recess to socialize, play and eat snacks. It is highly recommended that parents provide their children with snacks. In case some students forget theirs, the school may occasionally provide snacks for them when available.\n\nThe ISGL lower-level space is entirely reserved for school operations on Sundays between 8:00 AM to 1:00 PM. <strong><u>For safety and logistical reasons, no one is allowed to be in school premises between 10:00 AM to 1:00 PM except for students and staff<\/u><\/strong>. Parents are allowed to come in to drop off their kids and leave right after. Parents who wish to speak with a school admin or teacher can do so before school starts (before 10:00 AM) or after school is finished (after 1:00 PM).\n\nIn Ramadan, Al Rahma School starts at 11:00 AM and finishes at 1:00 PM. First session will be an hour long instead of 90 minutes while the second session will last 45 minutes with a 15 minutes break in between. In observance of the last 10 nights of Ramadan leading to Eid Al-Fitr, there will be no school on the 15th and 22nd of March (see calendar)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "ATTENDANCE",
|
||||||
|
"body": "Students are expected to attend all classes each Sunday. Each absence interferes with your child’s ability to benefit from his\/her class. In case of absence, parents are asked to inform the school administration <strong><u>ahead of Sunday session<\/u><\/strong> through call, text (regular & WhatsApp) or email to explain the reason for the absence. Failure to comply has the following progressive consequences:\n\n a- If unnotified, teachers will ask students for the reason for their absence when they\n meet up the following Sunday. Any assignments missed should be made up by students \n within the time frame given by the teacher.\n\n b- <strong>Two consecutive absences<\/strong> or <strong>two absences in any three-week streak<\/strong> <u>without<\/u> \n <u>prior notice<\/u> will trigger a phone call and email from the administration to follow up \n with parents. \n\n c- <strong>Three consecutive absences<\/strong> or <strong>three absences in any four-week streak<\/strong> <u>without<\/u> \n <u>prior notice<\/u> will trigger a final warning call, text and email. \n\n d- <strong>Four consecutive absences<\/strong> or <strong>four absences in any five-week streak<\/strong> <u>without<\/u> \n <u>prior notice<\/u> will result in student dismissal from the school.\n\nThis is how you <strong>justify an absence\/late arrival<\/strong>:\n\n\n<u>When?<\/u> Contact us before classes start on Sunday, preferably at least 24 hours before\n\n<u>How?<\/u> Notify us by one of these 4 methods:\n<ul><li>Send an email to <a href=\"mailto:alrahma.isgl@gmail.com\" style=\"color: #007BFF; text-decoration: underline;\">alrahma.isgl@gmail.com<\/a><\/li><li>Call +1 978-364-0219. Leave a voicemail if no one picks up.<\/li><li>Text +1 978-364-0219<\/li><li>Leave a WhatsApp message in your student grade group<\/li><\/ul>\n<u>What?<\/u> We need to know:\n<ul><li>Student full name (first name + last name)<\/li><li>Student grade<\/li><li>Date and duration of absence\/late<\/li><li>Reason for absence\/late<\/li><\/ul>\nLike the previous year, <strong><u>absence (reported or unreported) will account for 20% of overall student grade except for long term leave with prior notification to administration<\/u><\/strong>. One sick day per semester is typically granted to all students and will not be counted toward the final grade. This policy has been put in place to keep all parties honest. Being absent means missing out on important learning opportunities, that’s why <strong><u>ALL<\/u><\/strong> absences will be marked and used toward the 20% target of overall grade.\n\nNot showing up for events organized by the school also counts as absence like any other regular session. Missing out on exams, school projects\/assignments <u>purposefully and without a valid reason<\/u> will result in a grade of 0 for that particular item. No make up exam will be scheduled in that case. \n\n<u>School hours are 10:00 AM to 1:00 PM<\/u>. Half-day attendance is not allowed: a child will not be allowed to only attend one of the sessions and be excused from the other without a valid excuse."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Illness",
|
||||||
|
"body": "Out of consideration for other children, parents are requested to keep their sick child at home until he\/she feels better. Children will need one fever free day before coming to school. Due to the flu season every fall\/winter, we urge all parents to keep their children at home, if they have flu-like symptoms."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tardy Policy",
|
||||||
|
"body": "Excessive tardiness interferes with the student’s ability to benefit from class. Also, it disrupts the flow of the lesson and causes distraction for students and teachers. Students are expected to arrive on time and be prepared to start class at 10:00 AM each Sunday. \nIn continuation of our 0-tolerance policy toward tardiness, the following measures will be adopted:\n<ul><li>If a student arrives after 10:00 AM, he\/she will not be allowed in class unless he\/she gets a tardy slip from the administration which he\/she will hand to the teacher before entering the classroom.<\/li><li><strong><u>School doors will be locked after 10:10 AM<\/u><\/strong>, parents dropping their kids after that time will be asked to take them back home <u>unless we receive a written message (email, text, WhatsApp) before 10:10 AM letting us know about the late arrival<\/u>. If the student is asked to leave, this will count as one absence on the student record.<\/li><li>A student will typically be forgiven for four late showings (arriving between 10:00 AM and 10:10 AM) per school year. Any subsequent late showings (five and up) will result in the student being sent home and marked as absent.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Drop-Off",
|
||||||
|
"body": "Parents must bring their children on time so that they are not penalized for late showings, they should typically plan to arrive 5 to 10 minutes before classes start. For extra security, there will be assigned school staff members every Sunday morning from 9:45 AM to 10:00 AM to enforce parking safety protocols. Parents are also kindly reminded to be mindful of other drivers behind them who are waiting to drop off their children. Upon arrival, get in the line queue and stay in your car until you reach the front of the Masjid. When it is your turn, and only when instructed by one of our volunteers, you can then drop off your kids, then please leave immediately when it is safe. No need to park or get out of your car. Some cars have safety features requiring someone from outside to open the door, our volunteers will do that for you. If you insist on coming with your children inside, then the back of the Masjid will be reserved for that reason. If you choose this option, then you need to park in the back and escort your children inside. \n\nOur volunteers will show you where you can safely park without hindering the flow of other cars. No one is allowed to park in any parking spot facing the Masjid, all of those are reserved for staff members only. Everyone <strong><u>MUST<\/u><\/strong> follow instructions of our volunteers in the parking lot. It is very dangerous when parents get impatient and try to go from the sides or drop off their kids when it is not their turn yet.\nFailure to follow the aforementioned guidelines purposefully, or arguing\/getting aggressive with one of the volunteers will be dealt with swiftly. Zero tolerance for staff members getting disrespected or yelled at!\n<br>\n<div style=\"text-align: center;\">\n<br>\n<img src=\"\/assets\/images\/parking.png\" alt=\"Parking Map\" style=\"width:90%; height:auto; border:1px solid #ccc; padding:4px;\"><\/div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "PICK-UP & EARLY RELEASE",
|
||||||
|
"body": "School ends at 1:00 PM. For safety reasons, students are allowed to leave the school <strong><u>only<\/u><\/strong> if a parent comes inside and picks them up for all grades, no exceptions. Youth who have their own car or elect to walk home will need to tell one of the staff before leaving. The school encourages parents and students to stay until 1:15 PM to pray Duhr in congregation in the Masjid which usually takes about 5 to 10 minutes.\n\nIn general, early pick-up is a practice the school discourages, but if a child needs to leave early, the parent is required to email the school in advance and state the student’s full name, the reason for early release and the pick-up date\/time. The parent picking up the child needs also to sign the “Early Release Form” found at the front desk."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "EXAMS, GRADING & PASS\/FAIL",
|
||||||
|
"body": "In coordination with all teachers, the school organizes two official exams throughout the year that count toward pass\/fail criteria for the following year: \n\n a) <strong>Midterm\/Winter Exam<\/strong> after about 13 weeks of active sessions. This marks the end of \n the first semester<br>\n b) <strong>Final\/Spring Exam<\/strong> after about 13 weeks of active sessions that follow the midterm. \n This marks the end of the second semester\n\nFor this current school year, please refer to the calendar on the first page for exact exam dates. A report card will be shared with you after each semester.\n\nMidterm and final exams are each scored out of a 100. They have an “Islamic Studies” and “Quran” portion, teachers decide the weight of each portion. On the Islamic Studies portion, questions will be diversified in style, innovative and thought-provoking that challenge students critical thinking skills. On the Quran portion, students will be tested on memorization and meaning\/understanding of the verses.\n\nStudents must have a valid reason for not showing up to these two major exams like sickness, travel or emergency. School will work with said students on a make-up exam the following school day. Make-up exams will not be provided for students who are absent for no valid reason, a reason will be deemed valid or invalid at the discretion of the administration.\n\nThere will be projects and assignments given to students throughout the year. It is very important for parents to follow up with their kids to make sure that projects and assignments are completed promptly. There can be times where teachers decide to have a test\/quiz in class generally limited in scope to keep students engaged with class material throughout the year. Projects, Tests, Assignments and Participation (referred in this document as PTAP) accounts for 20% of final grade.\n\nAttendance for this year will account for 20% of overall grade, <strong><u>ALL<\/u><\/strong> absences will be counted except for one sick day per semester.\n\nFinal Grade Distribution:\n\n a) Attendance: 20%\n b) Projects, Tests, Assignments and Participation (PTAP): 20%\n c) Midterm: 30%\n d) Final: 30%\n<br>\n<div style=\"text-align: center;\">\n<br>\n <img src=\"\/assets\/images\/score.png\" alt=\"score graph\" style=\"width:75%; height:auto; border:1px solid #ccc; padding:4px;\">\n<\/div>\n\nFinal grade is the main criterion to determine students passing to next grades for the following school year. The objective criteria that the school uses is as follows:\n\n<strong>Final Grade >= 60<\/strong>: Automatic Pass\n<strong>50 <= Final Grade < 60<\/strong>: Up for Debate\n<strong>Final Grade < 50<\/strong>: Fail\n\nAt the request of parents, last resort make-up exams can be made available for failing students at the beginning of the following school year to assess if they have made an effort during summer to study and rectify the poor showing during the previous year. If the result is satisfactory (at the discretion of the teacher and administration), then the said students will be allowed to progress to the next grade, otherwise they will have to repeat the class. Passing or failing a grade is a matter discussed internally by the principal and the vice-principal after the final exam week. Final decisions are made at the discretion of the school. All decisions are final."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Events",
|
||||||
|
"body": "The school will typically try to organize two fun events for students throughout the year to make the Islamic learning experience much more appealing. In previous years for example, the school organized a World Cup Final event indoors where all students got together and watched the game, food and snacks were provided. Also, an Islamic quiz competition was organized to assess students’ levels and top contenders received cash prizes. School also organized outdoor Laser Tag events and Trampoline Park outing which were well received by parents and were entertaining for students and staff. Event related information will always be communicated to parents ahead of time for better planning."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "Parent-School Communication",
|
||||||
|
"body": "Please feel free to communicate any suggestions\/concerns you may have about the school, your child, teacher, or staff. We welcome your feedback to let us know how we are doing. See “Contact Information” section below for ways to contact us.\n\nFor phone calls, we will pick up the phone on Sundays from 9:30 AM to 1:00 PM. Also, you can contact the school anytime and leave a voicemail\/text message. A staff member from the communications department will respond to you within 48 hours."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "TEACHERS\/STAFF RESPONSIBILITIES",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"body": "All teachers and staff members are entrusted with the sacred duty of nurturing, educating, and safeguarding the well-being of every student. These responsibilities stem from our commitment to Islamic values, academic excellence and the trust placed in us by families. The following outlines the expectations and responsibilities of all school personnel toward the students in our care:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1. Uphold Islamic Character and Conduct",
|
||||||
|
"body": "<ul><li>Serve as role models by displaying Islamic manners, modesty, humility, patience, and compassion in speech and action.<\/li><li>Avoid harsh speech, sarcasm, ridicule, or any behavior that may cause embarrassment or emotional harm to students.<\/li><li>Foster an environment of respect, empathy, and kindness among all students.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2. Ensure Physical and Emotional Safety",
|
||||||
|
"body": "<ul><li>Maintain supervision of students at all times during class hours, breaks, and transitions.<\/li><li>Report any concerns of bullying, neglect, or abuse (verbal, emotional, or physical) to administration immediately.<\/li><li>Never use any form of physical discipline. Discipline must be positive, constructive, and in line with school policy.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "3. Promote a Positive Learning Environment",
|
||||||
|
"body": "<ul><li>Arrive on time and prepared for each class, with a clear lesson plan and necessary materials.<\/li><li>Engage students with age-appropriate, interactive, and relevant Islamic teachings.<\/li><li>Encourage participation and respect diverse levels of understanding and backgrounds.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "4. Maintain Professional Boundaries",
|
||||||
|
"body": "<ul><li>Refrain from forming inappropriate or exclusive personal relationships with students.<\/li><li>Avoid one-on-one communication with students outside of class without parental or administrative awareness (e.g., texting, social media).<\/li><li>Use school communication platforms and maintain transparency in all interactions.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "5. Communicate with Families Respectfully",
|
||||||
|
"body": "<ul><li>Collaborate with parents\/guardians for the benefit of the child’s learning and character development.<\/li><li>Inform parents promptly and respectfully about any significant concerns, absences, or behavioral issues.<\/li><li>Maintain confidentiality and professionalism when discussing student matters.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "6. Report and Record Keeping",
|
||||||
|
"body": "<ul><li>Take accurate attendance and report it as per school policy.<\/li><li>Document any serious incidents, concerns, or interventions and share them with the administration.<\/li><li>Assist in maintaining class records, student progress, and feedback when applicable.<\/li><\/ul>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "STUDENT CODE OF CONDUCT",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1. Respect and Dignity",
|
||||||
|
"body": "<ul><li>Respect for Others: All students are expected to treat their peers, teachers, and staff with respect, kindness, and consideration. This includes refraining from any form of bullying, harassment, or discrimination.<\/li><li>Respect for Property: Students must respect school property, personal belongings of others, and the environment.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2. Adherence to Islamic Values",
|
||||||
|
"body": "<ul><li>Modesty in Dress: Students must adhere to the school’s dress code, which reflects Islamic principles of modesty. Short pants are not allowed. All girls should wear head scarves\/Hijab.<\/li><li>Honesty and Integrity: Cheating, lying, or stealing is strictly prohibited.<\/li><li>Behavior and Language: Students are expected to use appropriate language and exhibit behavior that reflects Islamic teachings, avoiding foul language, gossip, and backbiting.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "3. Attendance and Punctuality",
|
||||||
|
"body": "<ul><li>Regular Attendance: Students must attend school regularly and arrive on time.<\/li><li>Punctuality in Submissions: Homework and assignments must be completed and submitted on time.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "4. Engagement and Effort",
|
||||||
|
"body": "<ul><li>Participation: Students are encouraged to actively participate in classes and school activities.<\/li><li>Effort in Academics: All students should strive to achieve their best in all academic and extracurricular endeavors.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "5. Safety and Well-being",
|
||||||
|
"body": "<ul><li>Safe Environment: Students must refrain from actions that endanger themselves or others.<\/li><li>Prohibition of Harmful Substances: The possession or use of harmful substances, such as tobacco, drugs, or alcohol, is strictly forbidden.<\/li><\/ul>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "PROGRESSIVE DISCIPLINE ACTIONS",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1. Minor Infractions",
|
||||||
|
"body": "Examples: Tardiness, incomplete homework, minor disruptions in class.\nActions:\n<ul><li>Verbal Warning: Teacher addresses the issue directly with the student.<\/li><li>Written Warning: A note is sent home, and parents are informed.<\/li><li>Loss of Privileges: Temporary loss of privileges, such as recess or extracurricular activities.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2. Moderate Infractions",
|
||||||
|
"body": "Examples: Repeated minor infractions, disrespectful behavior, dress code violations, unauthorized use of telephones or other electronic devices during class.\nActions:\n<ul><li>Confiscation: The teacher may confiscate the phone or device for the remainder of the day.<\/li><li>Parent-Teacher Conference: A meeting is arranged to discuss the student’s behavior and possible solutions.<\/li><li>Behavioral Contract: The student may be placed on a behavioral contract outlining expected behavior and consequences for further infractions.<\/li><li>Detention: The student may be required to stay after school or during recess to complete missed work or reflect on their behavior.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "3. Serious Infractions",
|
||||||
|
"body": "Examples: Bullying, cheating, theft, physical aggression, or any behavior that compromises the safety of others.\nActions:\n<ul><li>In-School Suspension: The student is removed from regular classes but remains in school.<\/li><li>Out-of-School Suspension: Temporary suspension from school, requiring a meeting with parents before the student is allowed to return.<\/li><li>Counseling: Mandatory counseling sessions to address underlying issues.<\/li><\/ul>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "4. Extreme Infractions",
|
||||||
|
"body": "Examples: Possession of weapons, illegal substances, severe physical violence.\nActions:\n<ul><li>Long-Term Suspension: Extended suspension, possibly leading to expulsion depending on the severity of the infraction.<\/li><li>Expulsion: Permanent removal from the school. This is considered a last resort and is only implemented after a thorough review by the school administration.<\/li><\/ul>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "STUDENTS & PARENTS RESPONSIBILITIES",
|
||||||
|
"subsections": {
|
||||||
|
"body": "Students:\n<ul><li>Attend class regularly.<\/li><li>Have appropriate materials for class.<\/li><li>Arrive at school prepared to learn.<\/li><li>Recognize and respect the rights of classmates.<\/li><li>Respect teachers at all times.<\/li><li>Respect the Masjid and its facilities.<\/li><li>Comply with and conform to all class\/school rules.<\/li><\/ul>Parents:\n<ul><li>Be aware of and understand school policies.<\/li><li>Help promote a positive attitude.<\/li><li>Help students in regard to attendance and studying.<\/li><li>Communicate with teachers.<\/li><li>Communicate any concerns to the administration.<\/li><li>Report any changes in email and phone to school staff.<\/li><\/ul>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "PARENTAL CONSENT",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"title": "1. Consent for School Activities",
|
||||||
|
"body": "\nI hereby grant permission for my child(ren) to participate in activities organized by Al Rahma Sunday School during the current school year, in addition to the regular scheduled classes. I understand that these activities may include, but are not limited to, field trips, sports events and extracurricular programs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2. Liability Release",
|
||||||
|
"body": "\nI acknowledge that participation in school activities involves certain inherent risks. I hereby release and hold harmless Al Rahma Sunday School, its staff, volunteers, agents and representatives from any liability for injury, loss or damage that may arise from my child(ren)’s participation in these activities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "3. Medical Authorization",
|
||||||
|
"body": "\nIn the event of a medical emergency or illness during a school activity, I authorize Al Rahma Sunday School to seek medical care, treatment or transportation to a medical facility for my child(ren), as deemed necessary by school personnel. I understand that every effort will be made to contact me or the designated emergency contact first. If I or the emergency contact cannot be reached, I authorize the school to act in the best interest of my child(ren)’s health and safety."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "CONTACT INFORMATION",
|
||||||
|
"subsections": {
|
||||||
|
"body": "<p>Email: <a href=\"mailto:alrahma.isgl@gmail.com\" style=\"color: #007BFF; text-decoration: underline;\">alrahma.isgl@gmail.com<\/a><br>Call\/Text: +1 978-364-0219<\/p>\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/** Stub CI helper so the partial can be included standalone. */
|
||||||
|
function base_url(string $path = ''): string
|
||||||
|
{
|
||||||
|
$path = ltrim($path, '/');
|
||||||
|
return $path === '' ? '/' : '/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$data = include $root . '/../app_codeigniter/app/Views/policy/school_policy_partial.php';
|
||||||
|
|
||||||
|
$outDir = $root . '/public/policy';
|
||||||
|
if (!is_dir($outDir)) {
|
||||||
|
mkdir($outDir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||||
|
if ($json === false) {
|
||||||
|
fwrite(STDERR, "json_encode failed\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($outDir . '/school-policy-content.json', $json);
|
||||||
|
echo "Wrote public/policy/school-policy-content.json\n";
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Incidents / flags API (parity with CI `flags/*` + JSON routes in Laravel).
|
||||||
|
* Expected base: `/api/v1/administrator/flags/...` with JWT.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
export type IncidentFlagRow = {
|
||||||
|
id?: number
|
||||||
|
flag_state?: string
|
||||||
|
student_name?: string
|
||||||
|
grade?: string
|
||||||
|
flag?: string
|
||||||
|
open_description?: string
|
||||||
|
close_description?: string
|
||||||
|
cancel_description?: string
|
||||||
|
flag_datetime?: string | null
|
||||||
|
action_taken?: string | null
|
||||||
|
updated_by_open_name?: string | null
|
||||||
|
updated_by_open?: string | number | null
|
||||||
|
updated_by_closed_name?: string | null
|
||||||
|
updated_by_closed?: string | number | null
|
||||||
|
updated_by_canceled_name?: string | null
|
||||||
|
updated_by_canceled?: string | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncidentLogRow = {
|
||||||
|
flag?: string
|
||||||
|
flag_state?: string
|
||||||
|
flag_datetime?: string | null
|
||||||
|
open_description?: string
|
||||||
|
close_description?: string
|
||||||
|
cancel_description?: string
|
||||||
|
action_taken?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncidentAnalysisStudent = {
|
||||||
|
student_name?: string
|
||||||
|
grade?: string
|
||||||
|
total?: number
|
||||||
|
open?: number
|
||||||
|
closed?: number
|
||||||
|
canceled?: number
|
||||||
|
last_incident?: string | null
|
||||||
|
logs?: IncidentLogRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GradeOption = { id: number; name: string }
|
||||||
|
|
||||||
|
function qs(searchParams: URLSearchParams): string {
|
||||||
|
const sy = searchParams.get('school_year')
|
||||||
|
const sem = searchParams.get('semester')
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (sy) q.set('school_year', sy)
|
||||||
|
if (sem) q.set('semester', sem)
|
||||||
|
const s = q.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapFlags(body: unknown): unknown[] {
|
||||||
|
if (Array.isArray(body)) return body
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
const o = body as { flags?: unknown; data?: { flags?: unknown } }
|
||||||
|
const raw = o.flags ?? o.data?.flags
|
||||||
|
if (Array.isArray(raw)) return raw
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapStudents(body: unknown): unknown[] {
|
||||||
|
if (Array.isArray(body)) return body
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
const o = body as { students?: unknown; data?: { students?: unknown } }
|
||||||
|
const raw = o.students ?? o.data?.students
|
||||||
|
if (Array.isArray(raw)) return raw
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFlagsProcessed(searchParams: URLSearchParams): Promise<IncidentFlagRow[]> {
|
||||||
|
const body = await apiFetch<{ flags?: IncidentFlagRow[]; data?: { flags?: IncidentFlagRow[] } }>(
|
||||||
|
`/api/v1/administrator/flags/processed${qs(searchParams)}`,
|
||||||
|
)
|
||||||
|
return unwrapFlags(body) as IncidentFlagRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFlagsPending(searchParams: URLSearchParams): Promise<IncidentFlagRow[]> {
|
||||||
|
const body = await apiFetch<{ flags?: IncidentFlagRow[]; data?: { flags?: IncidentFlagRow[] } }>(
|
||||||
|
`/api/v1/administrator/flags/pending${qs(searchParams)}`,
|
||||||
|
)
|
||||||
|
return unwrapFlags(body) as IncidentFlagRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchIncidentAnalysis(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<IncidentAnalysisStudent[]> {
|
||||||
|
const body = await apiFetch<{
|
||||||
|
students?: IncidentAnalysisStudent[]
|
||||||
|
data?: { students?: IncidentAnalysisStudent[] }
|
||||||
|
}>(`/api/v1/administrator/flags/incident-analysis${qs(searchParams)}`)
|
||||||
|
return unwrapStudents(body) as IncidentAnalysisStudent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFlagsFormMeta(): Promise<{ grades: GradeOption[] }> {
|
||||||
|
return apiFetch<{ grades: GradeOption[] }>('/api/v1/administrator/flags/form-meta')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudentsByGrade(gradeId: number): Promise<Array<{ id: number; name: string }>> {
|
||||||
|
return apiFetch<Array<{ id: number; name: string }>>(
|
||||||
|
`/api/v1/administrator/flags/grades/${gradeId}/students`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIncidentFlag(payload: {
|
||||||
|
grade: number | string
|
||||||
|
student: number | string
|
||||||
|
flag: string
|
||||||
|
description: string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ message?: string } | unknown> {
|
||||||
|
return apiFetch(`/api/v1/administrator/flags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeIncidentFlag(
|
||||||
|
id: number,
|
||||||
|
body: { state_description: string; action_taken: string },
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/api/v1/administrator/flags/${id}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelIncidentFlag(
|
||||||
|
id: number,
|
||||||
|
body: { state_description: string; action_taken: string },
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/api/v1/administrator/flags/${id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Administrator grading API (parity with CI `Views/grading/*.php`).
|
||||||
|
* Base: `/api/v1/administrator/grading/...` with JWT.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/grading'
|
||||||
|
|
||||||
|
export type GradingScoreRow = {
|
||||||
|
id?: number
|
||||||
|
score?: number | string | null
|
||||||
|
comment?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GradingScoreFormPayload = {
|
||||||
|
scoresLocked?: boolean
|
||||||
|
student?: { id?: number; firstname?: string; lastname?: string }
|
||||||
|
classSectionId?: number
|
||||||
|
scores?: GradingScoreRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(sp: URLSearchParams): string {
|
||||||
|
const s = sp.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGradingMain(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/main${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGradingScoreForm(
|
||||||
|
scoreType: string,
|
||||||
|
studentId: string,
|
||||||
|
classSectionId: string,
|
||||||
|
): Promise<GradingScoreFormPayload> {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
student_id: studentId,
|
||||||
|
class_section_id: classSectionId,
|
||||||
|
})
|
||||||
|
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postGradingScoreUpdate(
|
||||||
|
scoreType: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBelowSixty(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/below-sixty${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams): Promise<{
|
||||||
|
studentId?: number
|
||||||
|
studentName?: string
|
||||||
|
semester?: string
|
||||||
|
schoolYear?: string
|
||||||
|
subject?: string
|
||||||
|
html?: string
|
||||||
|
}> {
|
||||||
|
return apiFetch(`${BASE}/below-sixty/email-editor${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postBelowSixtyEmail(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/below-sixty/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postBelowSixtyStatus(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/below-sixty/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHomeworkTracking(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/homework-tracking${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParticipation(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/participation${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postParticipation(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/participation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlacementIndex(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/placement-index${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postPlacementAll(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/placement-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlacementSection(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/placement${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postPlacementSection(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/placement`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlacementBatch(batchId: string | number): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/placement-batch/${batchId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postPlacementBatch(
|
||||||
|
batchId: string | number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/placement-batch/${batchId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScheduleMeetingForm(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/schedule-meeting${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/schedule-meeting`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -37,7 +37,17 @@ export async function apiFetch<T>(
|
|||||||
headers.set('Authorization', `Bearer ${token}`)
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(apiUrl(path), { ...init, headers })
|
const url = apiUrl(path)
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { ...init, headers })
|
||||||
|
} catch (e) {
|
||||||
|
const hint = import.meta.env.DEV
|
||||||
|
? ' Start the API and ensure Vite can reach it (see vite.config.ts; default proxy is http://192.168.3.100:8000).'
|
||||||
|
: ''
|
||||||
|
const reason = e instanceof Error ? e.message : 'Failed to fetch'
|
||||||
|
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
|
||||||
|
}
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Inventory (parity with CI `Views/inventory/*.php`).
|
||||||
|
* Base: `/api/v1/administrator/inventory` with JWT unless noted.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/inventory'
|
||||||
|
|
||||||
|
function q(searchParams: URLSearchParams): string {
|
||||||
|
const s = searchParams.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
|
||||||
|
|
||||||
|
/** Index list: expect `{ items, categories, userNames }` (names optional). */
|
||||||
|
export async function fetchInventoryIndex(
|
||||||
|
kind: InventoryKind,
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/${kind}${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/category`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInventoryItem(itemId: string | number): Promise<{
|
||||||
|
item?: Record<string, unknown>
|
||||||
|
categories?: Record<string, unknown>[]
|
||||||
|
conditionOptions?: Record<string, string>
|
||||||
|
[key: string]: unknown
|
||||||
|
}> {
|
||||||
|
return apiFetch(`${BASE}/items/${itemId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create form payload (categories, defaults). */
|
||||||
|
export async function fetchInventoryCreateForm(
|
||||||
|
kind: InventoryKind,
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/${kind}/create${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putInventoryItem(
|
||||||
|
itemId: string | number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/items/${itemId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInventoryItem(itemId: string | number): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/items/${itemId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postInventoryAdjust(
|
||||||
|
itemId: string | number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/items/${itemId}/adjust`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postClassroomAudit(
|
||||||
|
itemId: string | number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInventorySummary(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/summary${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/movements${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMovementForm(
|
||||||
|
movementId: string | number | undefined,
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (movementId === undefined || movementId === '') {
|
||||||
|
return apiFetch(`${BASE}/movements/create${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
return apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postMovement(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/movements`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putMovement(
|
||||||
|
movementId: string | number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/movements/${movementId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovement(movementId: string | number): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/movements/${movementId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postMovementsBulkDelete(ids: number[]): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/movements/bulk-delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teacher book distribution — JWT; base matches CI `inventory/books/distribute`. */
|
||||||
|
const TEACHER_DIST = '/api/v1/teacher/inventory/books/distribute'
|
||||||
|
|
||||||
|
export async function fetchTeacherBookDistribute(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`${TEACHER_DIST}${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postTeacherBookDistribute(body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return apiFetch(TEACHER_DIST, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
|
||||||
|
*/
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { apiFetch, getStoredToken } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/invoices'
|
||||||
|
|
||||||
|
function q(searchParams: URLSearchParams): string {
|
||||||
|
const s = searchParams.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceManagementRow = {
|
||||||
|
parent_id?: number
|
||||||
|
parent_name?: string
|
||||||
|
enrolledKids?: { name?: string; grade?: string | number }[]
|
||||||
|
invoice_id?: number | null
|
||||||
|
invoice_amount?: number
|
||||||
|
refund_amount?: number
|
||||||
|
invoice_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceManagementResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
schoolYears?: string[]
|
||||||
|
schoolYear?: string
|
||||||
|
invoices?: InvoiceManagementRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInvoiceManagement(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): Promise<InvoiceManagementResponse> {
|
||||||
|
return apiFetch(`${BASE}/management${q(searchParams)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postInvoiceGenerate(parentId: number): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/generate-for-parent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ parent_id: parentId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML/JSON preview for browser print view (mirrors FPDF layout). */
|
||||||
|
export async function fetchInvoicePreview(invoiceId: string | number): Promise<unknown> {
|
||||||
|
return apiFetch(`${BASE}/${invoiceId}/preview`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
|
||||||
|
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
|
||||||
|
const token = getStoredToken()
|
||||||
|
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), {
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
Accept: 'application/pdf',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || `PDF HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPdfBlobInNewTab(blob: Blob): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 120_000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Password reset / initial set-password flows (legacy CI `user/*` auth views).
|
||||||
|
* Backend expected under `/api/v1/auth/...`.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
export async function requestForgotPassword(email: string): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.trim() }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitPasswordReset(payload: {
|
||||||
|
token: string
|
||||||
|
password: string
|
||||||
|
pass_confirm: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitSetPassword(payload: {
|
||||||
|
user_id: number | string
|
||||||
|
token: string
|
||||||
|
password: string
|
||||||
|
password_confirm: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/auth/set-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitAuthorizedUserPassword(
|
||||||
|
userId: number | string,
|
||||||
|
payload: { token: string; password: string; password_confirm: string },
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/auth/set-authorized-user-password/${encodeURIComponent(String(userId))}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* Administrator payment / notifications / financial report API.
|
||||||
|
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
||||||
|
*/
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||||
|
|
||||||
|
export type AdminNotificationRow = {
|
||||||
|
id?: number
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
priority?: string | number
|
||||||
|
scheduled_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
deleted_at?: string
|
||||||
|
target_group?: string
|
||||||
|
created_at?: string
|
||||||
|
isExpired?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotificationsDeleted(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ notifications: AdminNotificationRow[] }> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/notifications/deleted${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotificationsActive(params?: {
|
||||||
|
target_group?: string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ notifications: AdminNotificationRow[] }> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.target_group) qs.set('target_group', params.target_group)
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/notifications/active${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreDeletedNotification(
|
||||||
|
id: number | string,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/notifications/${encodeURIComponent(String(id))}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
|
||||||
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||||
|
}
|
||||||
|
return body as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManualPayPageResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
parent?: Record<string, unknown> | null
|
||||||
|
students?: Array<Record<string, unknown>>
|
||||||
|
payments?: Array<Record<string, unknown>>
|
||||||
|
invoices?: Array<Record<string, unknown>>
|
||||||
|
searchTermUsedInSearch?: string
|
||||||
|
installmentEndYmd?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchManualPayPage(params: {
|
||||||
|
search_term: string
|
||||||
|
page?: number | string
|
||||||
|
}): Promise<ManualPayPageResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('search_term', params.search_term)
|
||||||
|
if (params.page != null) qs.set('page', String(params.page))
|
||||||
|
return apiFetch(`/api/v1/administrator/payments/manual-pay?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualPaySuggestRow = { id?: number | string; label?: string; email?: string; phone?: string }
|
||||||
|
|
||||||
|
export async function fetchManualPaySuggest(q: string): Promise<ManualPaySuggestRow[]> {
|
||||||
|
const qs = new URLSearchParams({ q })
|
||||||
|
const body = await apiFetch<{ suggestions?: unknown[]; data?: unknown[] }>(
|
||||||
|
`/api/v1/administrator/payments/manual-pay/suggest?${qs}`,
|
||||||
|
)
|
||||||
|
const raw = body.suggestions ?? body.data ?? []
|
||||||
|
return Array.isArray(raw) ? (raw as ManualPaySuggestRow[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitManualPayUpdate(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return postMultipart('/api/v1/administrator/payments/manual-pay/update', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitManualPayEdit(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return postMultipart('/api/v1/administrator/payments/manual-pay/edit', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated URL for embedding or downloading check/card files (legacy CI: serveCheckFile). */
|
||||||
|
export function manualPayCheckFileUrl(tokenOrKey: string, mode: 'inline' | 'download'): string {
|
||||||
|
const enc = encodeURIComponent(tokenOrKey)
|
||||||
|
return `/api/v1/administrator/payments/check-file/${enc}/${mode}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string> {
|
||||||
|
const headers = new Headers({ Accept: '*/*' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(apiPath), { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentNotificationLogRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function fetchPaymentNotificationLogs(params?: {
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
type?: string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{
|
||||||
|
logs: PaymentNotificationLogRow[]
|
||||||
|
parentsById?: Record<string, string>
|
||||||
|
filters?: { from?: string; to?: string; type?: string }
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.from) qs.set('from', params.from)
|
||||||
|
if (params?.to) qs.set('to', params.to)
|
||||||
|
if (params?.type) qs.set('type', params.type)
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/payments/notification-logs${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnpaidParentRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function fetchUnpaidParents(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ rows: UnpaidParentRow[]; school_year?: string; schoolYears?: string[] }> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/payments/unpaid-parents${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPaymentReminders(payload: {
|
||||||
|
school_year: string
|
||||||
|
parent_id?: number | string
|
||||||
|
return_to?: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/administrator/payments/notifications/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentPaymentsModalRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function fetchParentPaymentsForModal(
|
||||||
|
parentId: number | string,
|
||||||
|
): Promise<{ payments: ParentPaymentsModalRow[]; parentName?: string }> {
|
||||||
|
return apiFetch(
|
||||||
|
`/api/v1/administrator/payments/parent/${encodeURIComponent(String(parentId))}/payments-list`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyDiscountByCodeForStudent(payload: {
|
||||||
|
student_id: number | string
|
||||||
|
voucher_code: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/discounts/apply-by-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtraChargeRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function fetchExtraChargesPage(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
page?: number | string
|
||||||
|
parent_id?: number | string
|
||||||
|
}): Promise<{
|
||||||
|
rows: ExtraChargeRow[]
|
||||||
|
schoolYear?: string
|
||||||
|
schoolYears?: string[]
|
||||||
|
semester?: string
|
||||||
|
parents?: Array<{ id: number; firstname?: string; lastname?: string }>
|
||||||
|
pager?: { current_page?: number; last_page?: number }
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
if (params?.page != null) qs.set('page', String(params.page))
|
||||||
|
if (params?.parent_id != null) qs.set('parent_id', String(params.parent_id))
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/charges${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChargeInvoicesForParent(parentId: number | string): Promise<
|
||||||
|
Array<{ id?: number; invoice_number?: string }>
|
||||||
|
> {
|
||||||
|
const body = await apiFetch<{ invoices?: unknown[] }>(
|
||||||
|
`/api/v1/administrator/charges/invoices-for-parent?parent_id=${encodeURIComponent(String(parentId))}`,
|
||||||
|
)
|
||||||
|
const raw = body.invoices
|
||||||
|
return Array.isArray(raw) ? (raw as Array<{ id?: number; invoice_number?: string }>) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitExtraCharge(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return postMultipart('/api/v1/administrator/charges', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinancialDetailedJson = {
|
||||||
|
ok?: boolean
|
||||||
|
invoices?: Array<Record<string, unknown>>
|
||||||
|
payments?: Array<Record<string, unknown>>
|
||||||
|
refunds?: Array<Record<string, unknown>>
|
||||||
|
discounts?: Array<Record<string, unknown>>
|
||||||
|
paymentBreakdown?: Record<string, { cash?: number; credit?: number; check?: number }>
|
||||||
|
paymentTotals?: {
|
||||||
|
total_cash?: number
|
||||||
|
total_credit?: number
|
||||||
|
total_check?: number
|
||||||
|
total_all?: number
|
||||||
|
}
|
||||||
|
expenses?: Array<{ category?: string; total_amount?: number }>
|
||||||
|
reimbursements?: Array<{ status?: string; total_amount?: number }>
|
||||||
|
eventFeesTotal?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFinancialReportDetailed(params: {
|
||||||
|
school_year?: string
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
}): Promise<FinancialDetailedJson> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params.date_from) qs.set('date_from', params.date_from)
|
||||||
|
if (params.date_to) qs.set('date_to', params.date_to)
|
||||||
|
qs.set('format', 'json')
|
||||||
|
return apiFetch(`/api/v1/administrator/reports/financial-detailed?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFinancialReportCsv(params: {
|
||||||
|
school_year?: string
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params.date_from) qs.set('date_from', params.date_from)
|
||||||
|
if (params.date_to) qs.set('date_to', params.date_to)
|
||||||
|
const path = `/api/v1/administrator/reports/financial-detailed.csv?${qs}`
|
||||||
|
const headers = new Headers()
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'financial-report.csv'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinancialSummaryJson = {
|
||||||
|
ok?: boolean
|
||||||
|
schoolYear?: string
|
||||||
|
totalCharges?: number
|
||||||
|
totalEventFees?: number
|
||||||
|
totalExtraCharges?: number
|
||||||
|
totalDiscounts?: number
|
||||||
|
totalRefunds?: number
|
||||||
|
totalExpenses?: number
|
||||||
|
totalReimbursements?: number
|
||||||
|
donationToSchool?: number
|
||||||
|
netAmount?: number
|
||||||
|
amountCollected?: number
|
||||||
|
totalUnpaid?: number
|
||||||
|
totalPaid?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFinancialReportSummary(params: {
|
||||||
|
school_year?: string
|
||||||
|
}): Promise<FinancialSummaryJson> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
|
qs.set('format', 'json')
|
||||||
|
return apiFetch(`/api/v1/administrator/reports/financial-summary?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFinancialSummaryPdf(params: { school_year?: string }): Promise<void> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
|
const path = `/api/v1/administrator/reports/financial-summary.pdf?${qs}`
|
||||||
|
const headers = new Headers()
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'financial-summary.pdf'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaypalRedirectContext = {
|
||||||
|
parentName?: string
|
||||||
|
totalAmount?: string | number
|
||||||
|
schoolId?: string | number
|
||||||
|
currency?: string
|
||||||
|
invoiceId?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPaypalPaymentContext(params: {
|
||||||
|
invoice_id?: string
|
||||||
|
}): Promise<PaypalRedirectContext> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.invoice_id) qs.set('invoice_id', params.invoice_id)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/parent/payment/paypal-context${suffix}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Teacher / admin print copy requests (legacy CI: print-requests/*).
|
||||||
|
*/
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||||
|
|
||||||
|
export type PrintRequestRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export type TeacherPrintRequestsContext = {
|
||||||
|
sundays?: string[]
|
||||||
|
times?: string[]
|
||||||
|
class_id?: number | null
|
||||||
|
class_section_id?: number | null
|
||||||
|
class_section_name?: string | null
|
||||||
|
print_requests?: PrintRequestRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTeacherPrintRequestsContext(): Promise<TeacherPrintRequestsContext> {
|
||||||
|
return apiFetch('/api/v1/teacher/print-requests/context')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function multipartRequest<T>(path: string, formData: FormData, method = 'POST'): Promise<T> {
|
||||||
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(path), { method, headers, body: formData })
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||||
|
}
|
||||||
|
return body as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPrintRequest(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return multipartRequest('/api/v1/print-requests', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCopyRequest(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return multipartRequest('/api/v1/print-requests/hand-copy', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePrintRequest(
|
||||||
|
requestId: number | string,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
formData.set('_method', 'PATCH')
|
||||||
|
return multipartRequest(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePrintRequest(requestId: number | string): Promise<{ ok?: boolean }> {
|
||||||
|
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> {
|
||||||
|
return apiFetch('/api/v1/administrator/print-requests')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdatePrintRequestStatus(
|
||||||
|
requestId: number | string,
|
||||||
|
payload: { status: string; admin_id?: number | string },
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/administrator/print-requests/${encodeURIComponent(String(requestId))}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated download URL for uploaded job files (opened in new tab). */
|
||||||
|
export function printRequestFileUrl(requestId: number | string): string {
|
||||||
|
return `/api/v1/print-requests/${encodeURIComponent(String(requestId))}/file`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPrintRequestFile(requestId: number | string): Promise<void> {
|
||||||
|
const token = getStoredToken()
|
||||||
|
const headers = new Headers({ Accept: '*/*' })
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
window.open(url, '_blank', 'noopener')
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* Stickers, badges, report cards (legacy CI printables_reports/*).
|
||||||
|
*/
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||||
|
|
||||||
|
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
|
||||||
|
|
||||||
|
export type StudentOption = {
|
||||||
|
id: number | string
|
||||||
|
firstname?: string
|
||||||
|
lastname?: string
|
||||||
|
registration_grade?: string
|
||||||
|
gender?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStickerFormOptions(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{
|
||||||
|
classes?: ClassOption[]
|
||||||
|
students?: StudentOption[]
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/printables/stickers/form-options${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudentsByClass(classId: number | string): Promise<StudentOption[]> {
|
||||||
|
const body = await apiFetch<{ students?: StudentOption[] } | StudentOption[]>(
|
||||||
|
`/api/v1/administrator/students/by-class/${encodeURIComponent(String(classId))}`,
|
||||||
|
)
|
||||||
|
if (Array.isArray(body)) return body
|
||||||
|
return body.students ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
|
||||||
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody: unknown = await res.json().catch(() => null)
|
||||||
|
const msg =
|
||||||
|
typeof errBody === 'object' && errBody !== null && 'message' in errBody
|
||||||
|
? String((errBody as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postBadgeGenerate(formData: FormData): Promise<Blob> {
|
||||||
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl('/api/v1/administrator/printables/badges/generate'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody: unknown = await res.json().catch(() => null)
|
||||||
|
const msg =
|
||||||
|
typeof errBody === 'object' && errBody !== null && 'message' in errBody
|
||||||
|
? String((errBody as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StickerPreviewCounts = {
|
||||||
|
students?: Array<{ student_id: number | string; primary_count?: number }>
|
||||||
|
totals?: { stickers?: number; students?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStickerPreviewCounts(classId?: number | string | null): Promise<StickerPreviewCounts> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (classId) qs.set('class_id', String(classId))
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<
|
||||||
|
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
|
||||||
|
>(`/api/v1/administrator/printables/stickers/preview${suffix}`)
|
||||||
|
if (body.data) return body.data
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BadgeUserRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function fetchBadgeFormOptions(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{
|
||||||
|
selectedYear?: string
|
||||||
|
users?: BadgeUserRow[]
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/printables/badges/form-options${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBadgePrintStatus(params: {
|
||||||
|
user_ids: (number | string)[]
|
||||||
|
school_year?: string
|
||||||
|
}): Promise<{
|
||||||
|
ok?: boolean
|
||||||
|
data?: Record<string, { count?: number }>
|
||||||
|
csrf_token?: string
|
||||||
|
csrf_hash?: string
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
for (const id of params.user_ids) qs.append('user_ids[]', String(id))
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
|
return apiFetch(`/api/v1/administrator/printables/badges/status?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportCardMeta = {
|
||||||
|
ok?: boolean
|
||||||
|
schoolYears?: string[]
|
||||||
|
semesters?: string[]
|
||||||
|
selectedYear?: string
|
||||||
|
selectedSemester?: string
|
||||||
|
students?: Array<{ id: number | string; firstname?: string; lastname?: string; class_section_name?: string }>
|
||||||
|
classSections?: Array<{ class_section_id?: number; id?: number; class_section_name?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReportCardMeta(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<ReportCardMeta> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/printables/report-card/meta${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReportCardAck(params: {
|
||||||
|
student_id: number | string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ ok?: boolean; viewed_at?: string; signed_at?: string; signed_name?: string }> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('student_id', String(params.student_id))
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params.semester) qs.set('semester', params.semester ?? '')
|
||||||
|
return apiFetch(`/api/v1/administrator/printables/report-card/ack?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReportCardCompleteness(params: {
|
||||||
|
class_section_id: number | string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{
|
||||||
|
ok?: boolean
|
||||||
|
students?: Array<Record<string, unknown>>
|
||||||
|
summary?: Record<string, unknown>
|
||||||
|
used_fallback?: boolean
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('class_section_id', String(params.class_section_id))
|
||||||
|
if (params.school_year) qs.set('school_year', params.school_year ?? '')
|
||||||
|
if (params.semester) qs.set('semester', params.semester ?? '')
|
||||||
|
return apiFetch(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
|
||||||
|
export function reportCardStudentUrl(
|
||||||
|
studentId: number | string,
|
||||||
|
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||||
|
): string {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('school_year', opts.school_year)
|
||||||
|
if (opts.semester) qs.set('semester', opts.semester)
|
||||||
|
if (opts.download) qs.set('download', '1')
|
||||||
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
|
qs.set('cb', String(Date.now()))
|
||||||
|
return apiUrl(`/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportCardClassUrl(
|
||||||
|
classSectionId: number | string,
|
||||||
|
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||||
|
): string {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('school_year', opts.school_year)
|
||||||
|
if (opts.semester) qs.set('semester', opts.semester)
|
||||||
|
if (opts.download) qs.set('download', '1')
|
||||||
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
|
qs.set('cb', String(Date.now()))
|
||||||
|
return apiUrl(`/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
|
||||||
|
export async function fetchReportCardStudentHtml(
|
||||||
|
studentId: number | string,
|
||||||
|
opts: { school_year: string; semester?: string; report_date?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('school_year', opts.school_year)
|
||||||
|
if (opts.semester) qs.set('semester', opts.semester)
|
||||||
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
|
qs.set('cb', String(Date.now()))
|
||||||
|
const headers = new Headers({ Accept: 'text/html' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
|
||||||
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReportCardClassHtml(
|
||||||
|
classSectionId: number | string,
|
||||||
|
opts: { school_year: string; semester?: string; report_date?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
qs.set('school_year', opts.school_year)
|
||||||
|
if (opts.semester) qs.set('semester', opts.semester)
|
||||||
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
|
qs.set('cb', String(Date.now()))
|
||||||
|
const headers = new Headers({ Accept: 'text/html' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
|
||||||
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens PDF download in new tab (authenticated GET). */
|
||||||
|
export async function openReportCardDownload(url: string): Promise<void> {
|
||||||
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(url, { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const r = URL.createObjectURL(blob)
|
||||||
|
window.open(r, '_blank', 'noopener')
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Admin refunds list (legacy CI `refunds/list.php`).
|
||||||
|
* Laravel: `GET /api/v1/administrator/refunds/list` with JWT.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/refunds'
|
||||||
|
|
||||||
|
export type RefundListRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export type RefundsListResponse = {
|
||||||
|
refunds: RefundListRow[]
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
school_years?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeListPayload(raw: unknown): RefundsListResponse {
|
||||||
|
const body = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
||||||
|
const inner =
|
||||||
|
'data' in body && body.data && typeof body.data === 'object'
|
||||||
|
? (body.data as Record<string, unknown>)
|
||||||
|
: body
|
||||||
|
const list = inner.refunds ?? inner.rows ?? []
|
||||||
|
return {
|
||||||
|
refunds: Array.isArray(list) ? (list as RefundListRow[]) : [],
|
||||||
|
school_year: inner.school_year != null ? String(inner.school_year) : undefined,
|
||||||
|
semester: inner.semester != null ? String(inner.semester) : undefined,
|
||||||
|
school_years: Array.isArray(inner.school_years)
|
||||||
|
? (inner.school_years as string[])
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefundsList(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
page?: number | string
|
||||||
|
}): Promise<RefundsListResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
if (params?.page != null) qs.set('page', String(params.page))
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<unknown>(`${BASE}/list${suffix}`)
|
||||||
|
return normalizeListPayload(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Admin reimbursements (legacy CI reimbursements/*).
|
||||||
|
* Backend is expected under `/api/v1/administrator/reimbursements`.
|
||||||
|
*/
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/reimbursements'
|
||||||
|
|
||||||
|
export type ReimbursementUserOption = { id?: number | string; firstname?: string; lastname?: string }
|
||||||
|
|
||||||
|
export type BatchSummaryRow = Record<string, unknown>
|
||||||
|
export type BatchDetailPayload = {
|
||||||
|
items?: Array<Record<string, unknown>>
|
||||||
|
checks?: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReimbursementsIndexResponse = {
|
||||||
|
schoolYears?: string[]
|
||||||
|
users?: ReimbursementUserOption[]
|
||||||
|
batchSummaries?: BatchSummaryRow[]
|
||||||
|
batchDetails?: Record<string, BatchDetailPayload>
|
||||||
|
donationBatch?: BatchSummaryRow | null
|
||||||
|
donationDetails?: BatchDetailPayload
|
||||||
|
batchAttachments?: Record<string, { receipts?: unknown[]; checks?: unknown[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementsIndex(params?: {
|
||||||
|
semester?: string
|
||||||
|
status?: string
|
||||||
|
school_year?: string
|
||||||
|
user_id?: string
|
||||||
|
}): Promise<ReimbursementsIndexResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
if (params?.status) qs.set('status', params.status)
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.user_id) qs.set('user_id', params.user_id)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`${BASE}${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens CSV download in a new tab (authenticated GET → blob URL). */
|
||||||
|
export async function downloadReimbursementsExport(params?: {
|
||||||
|
semester?: string
|
||||||
|
status?: string
|
||||||
|
school_year?: string
|
||||||
|
user_id?: string
|
||||||
|
type?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
if (params?.status) qs.set('status', params.status)
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.user_id) qs.set('user_id', params.user_id)
|
||||||
|
if (params?.type) qs.set('type', params.type)
|
||||||
|
const url = apiUrl(`${BASE}/export?${qs}`)
|
||||||
|
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(url, { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
|
||||||
|
const qs = new URLSearchParams({ batch_id: String(batchId) })
|
||||||
|
const url = apiUrl(`${BASE}/batch/export?${qs}`)
|
||||||
|
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(url, { headers })
|
||||||
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
|
const blob = await res.blob()
|
||||||
|
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postBatchEmail(formData: FormData): Promise<{ success?: boolean; message?: string }> {
|
||||||
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData })
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body === 'object' && body !== null && 'error' in body
|
||||||
|
? String((body as { error?: unknown }).error ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||||
|
}
|
||||||
|
return body as { success?: boolean; message?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnderProcessingWorkspace = {
|
||||||
|
pendingItems?: Array<Record<string, unknown>>
|
||||||
|
itemsPayload?: Array<Record<string, unknown>>
|
||||||
|
existingBatches?: Array<Record<string, unknown>>
|
||||||
|
adminUsers?: Array<{ id?: number; firstname?: string; lastname?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUnderProcessingWorkspace(): Promise<UnderProcessingWorkspace> {
|
||||||
|
return apiFetch(`${BASE}/under-processing`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReimbursementBatch(): Promise<{
|
||||||
|
success?: boolean
|
||||||
|
batch_id?: number
|
||||||
|
sequence?: number
|
||||||
|
yearly_number?: number
|
||||||
|
csrf_hash?: string
|
||||||
|
}> {
|
||||||
|
const fd = new FormData()
|
||||||
|
return postFormExpectJson(`${BASE}/batch/create`, fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postFormExpectJson(path: string, form: FormData): Promise<{ success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number } & Record<string, unknown>> {
|
||||||
|
const headers = new Headers({ Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
|
||||||
|
const body: unknown = await res.json().catch(() => ({}))
|
||||||
|
const data = body as Record<string, unknown>
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiHttpError(String(data.error ?? `HTTP ${res.status}`), res.status, body)
|
||||||
|
}
|
||||||
|
if (data && typeof data === 'object' && 'success' in data && data.success === false) {
|
||||||
|
throw new ApiHttpError(String(data.error ?? 'Request failed'), res.status, body)
|
||||||
|
}
|
||||||
|
return data as { success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postBatchUpdate(form: FormData) {
|
||||||
|
return postFormExpectJson(`${BASE}/batch/update`, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postBatchLock(form: FormData) {
|
||||||
|
return postFormExpectJson(`${BASE}/batch/lock`, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postAdminCheckUpload(form: FormData) {
|
||||||
|
return postFormExpectJson(`${BASE}/batch/admin-file/upload`, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postMarkDonation(form: FormData) {
|
||||||
|
return postFormExpectJson(`${BASE}/mark-donation`, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReimbursementFormContext = {
|
||||||
|
users?: ReimbursementUserOption[]
|
||||||
|
expense_id?: number | string
|
||||||
|
prefill_amount?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementCreateContext(search: {
|
||||||
|
expense_id?: string
|
||||||
|
}): Promise<ReimbursementFormContext> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (search.expense_id) qs.set('expense_id', search.expense_id)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`${BASE}/create-context${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postMultipart(path: string, formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||||
|
}
|
||||||
|
return (body ?? {}) as { ok?: boolean; message?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReimbursement(formData: FormData) {
|
||||||
|
return postMultipart(`${BASE}`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReimbursementForEdit(id: number | string): Promise<{
|
||||||
|
reimbursement?: Record<string, unknown>
|
||||||
|
users?: ReimbursementUserOption[]
|
||||||
|
receipt_url?: string | null
|
||||||
|
}> {
|
||||||
|
return apiFetch(`${BASE}/${encodeURIComponent(String(id))}/edit-data`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReimbursement(id: number | string, formData: FormData) {
|
||||||
|
return postMultipart(`${BASE}/${encodeURIComponent(String(id))}`, formData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { apiFetch } from './http'
|
||||||
|
import type { ApiEnvelope } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laravel `GET /api/v1/reports/combined` with JWT Bearer auth.
|
||||||
|
* If your `routes/api.php` registers this elsewhere, change the path here.
|
||||||
|
*/
|
||||||
|
export type CombinedReportPayload = {
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
html?: string
|
||||||
|
message?: string
|
||||||
|
columns?: string[]
|
||||||
|
rows?: Array<Record<string, unknown>>
|
||||||
|
sections?: Array<{
|
||||||
|
title?: string
|
||||||
|
heading?: string
|
||||||
|
rows?: Array<Record<string, unknown>>
|
||||||
|
columns?: string[]
|
||||||
|
}>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapCombinedReport(raw: unknown): CombinedReportPayload | null {
|
||||||
|
if (raw == null || typeof raw !== 'object') return null
|
||||||
|
const o = raw as ApiEnvelope<CombinedReportPayload> & CombinedReportPayload
|
||||||
|
if (o.data != null && typeof o.data === 'object') {
|
||||||
|
return o.data as CombinedReportPayload
|
||||||
|
}
|
||||||
|
return raw as CombinedReportPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCombinedReport(params?: {
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
student_id?: string | number | null
|
||||||
|
class_section_id?: string | number | null
|
||||||
|
}): Promise<unknown> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.school_year) q.set('school_year', String(params.school_year))
|
||||||
|
if (params?.semester) q.set('semester', String(params.semester))
|
||||||
|
if (params?.student_id != null && String(params.student_id).trim() !== '') {
|
||||||
|
q.set('student_id', String(params.student_id))
|
||||||
|
}
|
||||||
|
if (params?.class_section_id != null && String(params.class_section_id).trim() !== '') {
|
||||||
|
q.set('class_section_id', String(params.class_section_id))
|
||||||
|
}
|
||||||
|
const qs = q.size > 0 ? `?${q.toString()}` : ''
|
||||||
|
return apiFetch<unknown>(`/api/v1/reports/combined${qs}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laravel `GET /api/v1/rfid/coming-soon` with JWT Bearer auth.
|
||||||
|
* Adjust the path if your `routes/api.php` uses a different segment (e.g. under `administrator`).
|
||||||
|
*/
|
||||||
|
export type RfidComingSoonPayload = {
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
message?: string
|
||||||
|
/** Extra keys from the API are ignored by the UI unless added explicitly. */
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRfidComingSoon(): Promise<RfidComingSoonPayload> {
|
||||||
|
return apiFetch<RfidComingSoonPayload>('/api/v1/rfid/coming-soon')
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* Roles & permissions admin (legacy CI rolepermission/*).
|
||||||
|
* Expected under `/api/v1/role-permission`.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/role-permission'
|
||||||
|
|
||||||
|
function unwrapData<T>(body: T): T {
|
||||||
|
if (!body || typeof body !== 'object') return body
|
||||||
|
const maybeEnvelope = body as { data?: unknown }
|
||||||
|
return (maybeEnvelope.data as T | undefined) ?? body
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleRow = {
|
||||||
|
id?: number | string
|
||||||
|
name?: string
|
||||||
|
slug?: string
|
||||||
|
description?: string
|
||||||
|
dashboard_route?: string
|
||||||
|
priority?: number
|
||||||
|
is_active?: number | string | boolean
|
||||||
|
user_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionRow = {
|
||||||
|
id?: number | string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRolesList(): Promise<{ roles?: RoleRow[] }> {
|
||||||
|
const body = await apiFetch<{ roles?: RoleRow[] } | { data?: { roles?: RoleRow[] } }>(`${BASE}/roles`)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermissionsList(): Promise<{ permissions?: PermissionRow[] }> {
|
||||||
|
const body = await apiFetch<
|
||||||
|
{ permissions?: PermissionRow[] } | { data?: { permissions?: PermissionRow[] } }
|
||||||
|
>(`${BASE}/permissions`)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssignUserRow = {
|
||||||
|
id?: number | string
|
||||||
|
account_id?: string
|
||||||
|
firstname?: string
|
||||||
|
lastname?: string
|
||||||
|
email?: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsersForAssign(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
}): Promise<{ users?: AssignUserRow[]; meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number } }> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
qs.set('per_page', String(params?.per_page ?? 200))
|
||||||
|
if (params?.page) qs.set('page', String(params.page))
|
||||||
|
qs.set('sort_by', 'lastname')
|
||||||
|
qs.set('sort_dir', 'asc')
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<
|
||||||
|
| {
|
||||||
|
users?: AssignUserRow[]
|
||||||
|
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data?: {
|
||||||
|
users?: AssignUserRow[]
|
||||||
|
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
`${BASE}/users${suffix}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllUsersForAssign(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ users: AssignUserRow[] }> {
|
||||||
|
const usersById = new Map<string, AssignUserRow>()
|
||||||
|
let page = 1
|
||||||
|
let lastPage = 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await fetchUsersForAssign({
|
||||||
|
school_year: params?.school_year,
|
||||||
|
semester: params?.semester,
|
||||||
|
page,
|
||||||
|
per_page: 200,
|
||||||
|
})
|
||||||
|
for (const user of response.users ?? []) {
|
||||||
|
const key = String(user.id ?? '').trim()
|
||||||
|
if (key === '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!usersById.has(key)) {
|
||||||
|
usersById.set(key, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastPage = Number(response.meta?.last_page ?? page)
|
||||||
|
page += 1
|
||||||
|
} while (page <= lastPage)
|
||||||
|
|
||||||
|
return { users: [...usersById.values()] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserRoles(payload: {
|
||||||
|
user_id: number | string
|
||||||
|
role_ids: (number | string)[]
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const userId = encodeURIComponent(String(payload.user_id))
|
||||||
|
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||||
|
`${BASE}/users/${userId}/roles`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role_ids: payload.role_ids }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRole(payload: Record<string, unknown>): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||||
|
`${BASE}/roles`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRole(
|
||||||
|
roleId: number | string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||||
|
`${BASE}/roles/${encodeURIComponent(String(roleId))}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRole(roleId: number | string): Promise<{ ok?: boolean }> {
|
||||||
|
const body = await apiFetch<{ ok?: boolean } | { data?: { ok?: boolean } }>(
|
||||||
|
`${BASE}/roles/${encodeURIComponent(String(roleId))}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRole(roleId: number | string): Promise<{ role?: RoleRow }> {
|
||||||
|
const body = await apiFetch<{ role?: RoleRow } | { data?: { role?: RoleRow } }>(
|
||||||
|
`${BASE}/roles/${encodeURIComponent(String(roleId))}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPermission(payload: {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||||
|
`${BASE}/permissions`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermission(
|
||||||
|
permissionId: number | string,
|
||||||
|
): Promise<{ permission?: PermissionRow }> {
|
||||||
|
const body = await apiFetch<{ permission?: PermissionRow } | { data?: { permission?: PermissionRow } }>(
|
||||||
|
`${BASE}/permissions/${encodeURIComponent(String(permissionId))}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePermission(
|
||||||
|
permissionId: number | string,
|
||||||
|
payload: { name: string; description?: string },
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||||
|
`${BASE}/permissions/${encodeURIComponent(String(permissionId))}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionMatrixRow = {
|
||||||
|
id?: number | string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExistingPermissionFlags = {
|
||||||
|
can_create?: boolean
|
||||||
|
can_read?: boolean
|
||||||
|
can_update?: boolean
|
||||||
|
can_delete?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRolePermissionMatrix(roleId: number | string): Promise<{
|
||||||
|
roleId?: number | string
|
||||||
|
permissions?: PermissionMatrixRow[]
|
||||||
|
existing?: Record<string, ExistingPermissionFlags>
|
||||||
|
}> {
|
||||||
|
const body = await apiFetch<
|
||||||
|
| {
|
||||||
|
roleId?: number | string
|
||||||
|
permissions?: PermissionMatrixRow[]
|
||||||
|
existing?: Record<string, ExistingPermissionFlags>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data?: {
|
||||||
|
roleId?: number | string
|
||||||
|
permissions?: PermissionMatrixRow[]
|
||||||
|
existing?: Record<string, ExistingPermissionFlags>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>(`${BASE}/roles/${encodeURIComponent(String(roleId))}/permissions`)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRolePermissionMatrix(
|
||||||
|
roleId: number | string,
|
||||||
|
payload: Record<string, { create?: boolean; read?: boolean; update?: boolean; delete?: boolean }>,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||||
|
`${BASE}/roles/${encodeURIComponent(String(roleId))}/permissions`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ permissions: payload }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Score analysis / prediction (legacy CI score_analysis/score_prediction).
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
export type ScorePredictionStudent = Record<string, unknown>
|
||||||
|
|
||||||
|
export type ScorePredictionResponse = {
|
||||||
|
school_year?: string
|
||||||
|
selectedClassSectionId?: string | number | null
|
||||||
|
classSections?: Array<{ class_section_id?: number | string; class_section_name?: string }>
|
||||||
|
students?: ScorePredictionStudent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScorePrediction(params?: {
|
||||||
|
school_year?: string
|
||||||
|
class_section_id?: string
|
||||||
|
}): Promise<ScorePredictionResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.class_section_id) qs.set('class_section_id', params.class_section_id)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/score-analysis/score-prediction${suffix}`)
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||||
import type {
|
import type {
|
||||||
AdministratorAbsenceFormResponse,
|
AdministratorAbsenceFormResponse,
|
||||||
AdministratorAbsenceSubmitResponse,
|
AdministratorAbsenceSubmitResponse,
|
||||||
AdministratorAdminAttendanceResponse,
|
AdministratorAdminAttendanceResponse,
|
||||||
|
AttendanceCommentTemplatesResponse,
|
||||||
|
AttendanceViolationsResponse,
|
||||||
|
EarlyDismissalsResponse,
|
||||||
|
ParentAttendanceAdminReportRow,
|
||||||
|
StaffMonthlyOverviewPayload,
|
||||||
|
TeacherStaffAttendanceResponse,
|
||||||
AdministratorDailyAttendanceResponse,
|
AdministratorDailyAttendanceResponse,
|
||||||
AdministratorDashboardMetricsResponse,
|
AdministratorDashboardMetricsResponse,
|
||||||
AdministratorDashboardSearchResponse,
|
AdministratorDashboardSearchResponse,
|
||||||
@@ -67,7 +73,25 @@ import type {
|
|||||||
TeacherSubmissionsResponse,
|
TeacherSubmissionsResponse,
|
||||||
EmergencyContactGroupsResponse,
|
EmergencyContactGroupsResponse,
|
||||||
EmergencyContactShowResponse,
|
EmergencyContactShowResponse,
|
||||||
|
UserListRow,
|
||||||
UsersResponse,
|
UsersResponse,
|
||||||
|
ClassPrepIndexResponse,
|
||||||
|
ClassPrepPrintResponse,
|
||||||
|
CommunicationsComposeResponse,
|
||||||
|
CommunicationsPreviewResponse,
|
||||||
|
ConfigurationListResponse,
|
||||||
|
DiscountApplyContextResponse,
|
||||||
|
DiscountsListResponse,
|
||||||
|
FamilyListItem,
|
||||||
|
GuardianListItem,
|
||||||
|
RegisteredNewStudentsResponse,
|
||||||
|
EnrollmentWithdrawalPageResponse,
|
||||||
|
ExpensesListResponse,
|
||||||
|
ExpenseCreateFormResponse,
|
||||||
|
ExpenseEditFormResponse,
|
||||||
|
FamilyLegacyImportMetaResponse,
|
||||||
|
FamilyLegacyImportResult,
|
||||||
|
FamilySearchResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function loginRequest(
|
export async function loginRequest(
|
||||||
@@ -200,6 +224,251 @@ export async function saveAdministratorAdminAttendance(payload: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTeacherStaffAttendance(params?: {
|
||||||
|
date?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
classSectionId?: number | null
|
||||||
|
}): Promise<TeacherStaffAttendanceResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.date) query.set('date', params.date)
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||||
|
if (params?.classSectionId != null && params.classSectionId > 0) {
|
||||||
|
query.set('class_section_id', String(params.classSectionId))
|
||||||
|
}
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<TeacherStaffAttendanceResponse>(`/api/v1/attendance/staff/teachers${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTeacherStaffAttendance(payload: {
|
||||||
|
date: string
|
||||||
|
semester: string
|
||||||
|
school_year: string
|
||||||
|
class_section_id: number
|
||||||
|
teachers: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
status?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}): Promise<{ message?: string }> {
|
||||||
|
return apiFetch<{ message?: string }>('/api/v1/attendance/staff/teachers/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStaffMonthlyAttendanceOverview(params?: {
|
||||||
|
semester?: string | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
}): Promise<StaffMonthlyOverviewPayload> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<StaffMonthlyOverviewPayload>(`/api/v1/attendance/staff/month-overview${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches CI `teacher_attendance_month` save cell (form body). */
|
||||||
|
export async function saveStaffMonthlyAttendanceCell(payload: Record<string, string>): Promise<{
|
||||||
|
ok?: boolean
|
||||||
|
csrf_token?: string
|
||||||
|
csrf_hash?: string
|
||||||
|
}> {
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(payload)) body.set(k, v)
|
||||||
|
return apiFetch('/api/v1/attendance/staff/month-save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceViolationsPending(params?: {
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
}): Promise<AttendanceViolationsResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<AttendanceViolationsResponse>(`/api/v1/attendance/violations/pending${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceViolationsNotified(params?: {
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
}): Promise<AttendanceViolationsResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<AttendanceViolationsResponse>(`/api/v1/attendance/violations/notified${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentAttendanceReportsAdmin(params?: {
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
start?: string | null
|
||||||
|
end?: string | null
|
||||||
|
}): Promise<{ rows?: ParentAttendanceAdminReportRow[] }> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
if (params?.start) query.set('start', params.start)
|
||||||
|
if (params?.end) query.set('end', params.end)
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<{ rows?: ParentAttendanceAdminReportRow[] }>(
|
||||||
|
`/api/v1/attendance/parent-reports${qs}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEarlyDismissals(params?: {
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
}): Promise<EarlyDismissalsResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<EarlyDismissalsResponse>(`/api/v1/attendance/early-dismissals${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEarlyDismissalStudentOptions(): Promise<{
|
||||||
|
students?: Array<{
|
||||||
|
id?: number
|
||||||
|
firstname?: string
|
||||||
|
lastname?: string
|
||||||
|
class_section_name?: string | null
|
||||||
|
}>
|
||||||
|
}> {
|
||||||
|
return apiFetch('/api/v1/attendance/early-dismissals/student-options')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEarlyDismissal(payload: {
|
||||||
|
student_id: number
|
||||||
|
date: string
|
||||||
|
dismiss_time: string
|
||||||
|
reason?: string | null
|
||||||
|
}): Promise<{ message?: string }> {
|
||||||
|
return apiFetch('/api/v1/attendance/early-dismissals', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multipart signature upload per report date — matches CI `attendance/early-dismissals/signature`. */
|
||||||
|
export async function uploadEarlyDismissalSignature(formData: FormData): Promise<{ message?: string }> {
|
||||||
|
const token = getStoredToken()
|
||||||
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new Error(msg || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return body as { message?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceCommentTemplates(): Promise<AttendanceCommentTemplatesResponse> {
|
||||||
|
return apiFetch<AttendanceCommentTemplatesResponse>('/api/v1/attendance-templates')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAttendanceCommentTemplate(payload: Record<string, string>): Promise<{
|
||||||
|
status?: string
|
||||||
|
}> {
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(payload)) body.set(k, v)
|
||||||
|
return apiFetch('/api/v1/attendance-templates/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttendanceCommentTemplate(id: number): Promise<{ status?: string }> {
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
body.set('id', String(id))
|
||||||
|
return apiFetch('/api/v1/attendance-templates/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAttendanceViolationNote(payload: Record<string, string>): Promise<{ message?: string }> {
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(payload)) body.set(k, v)
|
||||||
|
return apiFetch('/api/v1/attendance/violations/save-note', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAttendanceViolationEmail(payload: Record<string, string>): Promise<{ message?: string }> {
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(payload)) body.set(k, v)
|
||||||
|
return apiFetch('/api/v1/attendance/violations/send-email-manual', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceComposeEmailContext(params: {
|
||||||
|
studentId: string | number
|
||||||
|
code: string
|
||||||
|
variant?: string | null
|
||||||
|
incidentDate?: string | null
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
q.set('student_id', String(params.studentId))
|
||||||
|
q.set('code', params.code)
|
||||||
|
if (params.variant) q.set('variant', params.variant)
|
||||||
|
if (params.incidentDate) q.set('incident_date', params.incidentDate)
|
||||||
|
return apiFetch(`/api/v1/attendance/violations/compose?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceStudentViolationsView(
|
||||||
|
studentId: string | number,
|
||||||
|
query?: { code?: string | null; incident_date?: string | null; include_notified?: string | null },
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (query?.code) q.set('code', query.code)
|
||||||
|
if (query?.incident_date) q.set('incident_date', query.incident_date)
|
||||||
|
if (query?.include_notified) q.set('include_notified', query.include_notified)
|
||||||
|
const qs = q.size > 0 ? `?${q.toString()}` : ''
|
||||||
|
return apiFetch(`/api/v1/attendance/violations/student/${studentId}${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceTrackingStudents(): Promise<{
|
||||||
|
students?: Array<Record<string, unknown>>
|
||||||
|
title?: string
|
||||||
|
}> {
|
||||||
|
return apiFetch('/api/v1/attendance/tracking')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordAttendanceTrackingEntry(payload: Record<string, unknown>): Promise<{ message?: string }> {
|
||||||
|
return apiFetch('/api/v1/attendance/tracking/record', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchBroadcastEmailOptions(): Promise<BroadcastEmailOptionsResponse> {
|
export async function fetchBroadcastEmailOptions(): Promise<BroadcastEmailOptionsResponse> {
|
||||||
return apiFetch<BroadcastEmailOptionsResponse>('/api/v1/broadcast-email/options')
|
return apiFetch<BroadcastEmailOptionsResponse>('/api/v1/broadcast-email/options')
|
||||||
}
|
}
|
||||||
@@ -429,8 +698,13 @@ export async function deleteTeacherClassAssignment(payload: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTeacherSubmissions(): Promise<TeacherSubmissionsResponse> {
|
export async function fetchTeacherSubmissions(
|
||||||
return apiFetch<TeacherSubmissionsResponse>('/api/v1/administrator/teacher-submissions')
|
searchParams?: URLSearchParams,
|
||||||
|
): Promise<TeacherSubmissionsResponse> {
|
||||||
|
const q = searchParams?.toString()
|
||||||
|
return apiFetch<TeacherSubmissionsResponse>(
|
||||||
|
`/api/v1/administrator/teacher-submissions${q ? `?${q}` : ''}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPublishedCompetitions(): Promise<PublicCompetitionIndexResponse> {
|
export async function fetchPublishedCompetitions(): Promise<PublicCompetitionIndexResponse> {
|
||||||
@@ -719,12 +993,194 @@ export async function fetchUsers(params?: {
|
|||||||
return apiFetch<UsersResponse>(`/api/v1/users${qs}`)
|
return apiFetch<UsersResponse>(`/api/v1/users${qs}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapEnvelopeData(body: unknown): unknown {
|
||||||
|
if (!body || typeof body !== 'object') return body
|
||||||
|
const o = body as Record<string, unknown>
|
||||||
|
if ('data' in o && o.data != null && typeof o.data === 'object') return o.data
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserDetailResponse(body: unknown): UserListRow {
|
||||||
|
const raw = unwrapEnvelopeData(body)
|
||||||
|
if (!raw || typeof raw !== 'object') throw new Error('Invalid user response')
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
|
||||||
|
if (o.user != null && typeof o.user === 'object') {
|
||||||
|
return {
|
||||||
|
user: o.user as UserListRow['user'],
|
||||||
|
roles: Array.isArray(o.roles) ? (o.roles as UserListRow['roles']) : undefined,
|
||||||
|
role_ids: Array.isArray(o.role_ids) ? (o.role_ids as number[]) : undefined,
|
||||||
|
parent_type: typeof o.parent_type === 'string' ? o.parent_type : undefined,
|
||||||
|
second_from_parents:
|
||||||
|
typeof o.second_from_parents === 'string' ? o.second_from_parents : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o.id != null || o.email != null || o.firstname != null) {
|
||||||
|
return { user: o as UserListRow['user'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid user response')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET `/api/v1/users/:id` (falls back to user list row if show route is missing). */
|
||||||
|
export async function fetchUser(userId: number): Promise<UserListRow> {
|
||||||
|
try {
|
||||||
|
const body = await apiFetch<unknown>(`/api/v1/users/${userId}`)
|
||||||
|
return normalizeUserDetailResponse(body)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiHttpError && (e.status === 404 || e.status === 405)) {
|
||||||
|
const data = await fetchUsers({ sort: 'lastname', order: 'asc' })
|
||||||
|
const row = data.users?.find((r) => Number(r.user?.id) === userId)
|
||||||
|
if (row) return row
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: number): Promise<{ ok: boolean; message?: string }> {
|
export async function deleteUser(userId: number): Promise<{ ok: boolean; message?: string }> {
|
||||||
return apiFetch<{ ok: boolean; message?: string }>(`/api/v1/users/${userId}`, {
|
return apiFetch<{ ok: boolean; message?: string }>(`/api/v1/users/${userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveUser(
|
||||||
|
userId: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
return await apiFetch(`/api/v1/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiHttpError && error.status === 422) {
|
||||||
|
const body = error.body
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
const errors = (body as { errors?: unknown }).errors
|
||||||
|
if (errors && typeof errors === 'object') {
|
||||||
|
for (const value of Object.values(errors as Record<string, unknown>)) {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
throw new Error(String(value[0]))
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
throw new Error(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether `email` can be used (not taken by another user).
|
||||||
|
* Expected Laravel route: `GET /api/v1/users/email-availability?email=...&except_user_id=...`
|
||||||
|
* 200 body: `{ "available": true }` or `{ "available": false, "message": "..." }` (or under `data`).
|
||||||
|
*/
|
||||||
|
export async function checkUserEmailAvailable(
|
||||||
|
exceptUserId: number,
|
||||||
|
email: string,
|
||||||
|
): Promise<{ available: boolean; message?: string }> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
q.set('email', email.trim())
|
||||||
|
q.set('except_user_id', String(exceptUserId))
|
||||||
|
const body = await apiFetch<unknown>(`/api/v1/users/email-availability?${q.toString()}`)
|
||||||
|
const o = (body && typeof body === 'object' ? body : {}) as Record<string, unknown>
|
||||||
|
const d =
|
||||||
|
o.data && typeof o.data === 'object' && o.data !== null
|
||||||
|
? (o.data as Record<string, unknown>)
|
||||||
|
: o
|
||||||
|
const av = d.available
|
||||||
|
if (av === false) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
message: typeof d.message === 'string' ? d.message : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (av === true) {
|
||||||
|
return { available: true, message: typeof d.message === 'string' ? d.message : undefined }
|
||||||
|
}
|
||||||
|
return { available: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account settings: ask the API to email a confirmation link to `email`.
|
||||||
|
* The stored login email must not change until the user completes that flow (`confirmUserEmailChange`).
|
||||||
|
*
|
||||||
|
* Expected Laravel route: `POST /api/v1/users/{user}/email-change-request` with JSON `{ "email": "..." }`.
|
||||||
|
*/
|
||||||
|
export async function requestUserEmailChange(
|
||||||
|
userId: number,
|
||||||
|
email: string,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/users/${userId}/email-change-request`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.trim() }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes a pending email change (token from the confirmation email).
|
||||||
|
* Expected Laravel route: `POST /api/v1/users/email-change/confirm` with JSON `{ "token": "..." }`.
|
||||||
|
*/
|
||||||
|
export async function confirmUserEmailChange(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/users/email-change/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: token.trim() }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserRecord(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ ok?: boolean; id?: number; message?: string }> {
|
||||||
|
return apiFetch('/api/v1/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginActivityRow = {
|
||||||
|
user_id?: number | string
|
||||||
|
email?: string | null
|
||||||
|
login_time?: string | null
|
||||||
|
logout_time?: string | null
|
||||||
|
ip_address?: string | null
|
||||||
|
user_agent?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginActivityResponse = {
|
||||||
|
activities?: LoginActivityRow[]
|
||||||
|
pagination?: {
|
||||||
|
currentPage?: number
|
||||||
|
pageCount?: number
|
||||||
|
perPage?: number
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLoginActivity(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<LoginActivityResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.page) query.set('page', String(params.page))
|
||||||
|
if (params?.per_page) query.set('per_page', String(params.per_page))
|
||||||
|
if (params?.school_year) query.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) query.set('semester', params.semester)
|
||||||
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
|
return apiFetch<LoginActivityResponse>(`/api/v1/administrator/login-activity${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchNotificationAlerts(): Promise<NotificationAlertsResponse> {
|
export async function fetchNotificationAlerts(): Promise<NotificationAlertsResponse> {
|
||||||
return apiFetch<NotificationAlertsResponse>('/api/v1/administrator/notifications/alerts')
|
return apiFetch<NotificationAlertsResponse>('/api/v1/administrator/notifications/alerts')
|
||||||
}
|
}
|
||||||
@@ -769,6 +1225,62 @@ export async function fetchFamilyAdminIndex(params?: {
|
|||||||
return apiFetch<FamilyAdminIndexResponse>(`/api/v1/family-admin${qs}`)
|
return apiFetch<FamilyAdminIndexResponse>(`/api/v1/family-admin${qs}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional JSON search (`family/search` parity). Falls back to client-side lists when unavailable. */
|
||||||
|
export async function fetchFamilySearchSuggestions(q: string): Promise<FamilySearchResponse> {
|
||||||
|
const query = q.trim()
|
||||||
|
if (!query) return { items: [] }
|
||||||
|
try {
|
||||||
|
return await apiFetch<FamilySearchResponse>(
|
||||||
|
`/api/v1/family-admin/search?q=${encodeURIComponent(query)}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return { items: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFamilyComposeEmail(payload: {
|
||||||
|
to: string
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
return_url?: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/family-admin/compose-email/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET metadata for legacy family import (`routes/api.php` + JWT Bearer). */
|
||||||
|
export async function fetchFamilyLegacyImportMeta(): Promise<FamilyLegacyImportMetaResponse> {
|
||||||
|
const raw = await apiFetch<
|
||||||
|
FamilyLegacyImportMetaResponse | ApiEnvelope<FamilyLegacyImportMetaResponse>
|
||||||
|
>('/api/v1/families/import-legacy')
|
||||||
|
if (raw && typeof raw === 'object' && 'data' in raw && (raw as ApiEnvelope<FamilyLegacyImportMetaResponse>).data) {
|
||||||
|
return (raw as ApiEnvelope<FamilyLegacyImportMetaResponse>).data as FamilyLegacyImportMetaResponse
|
||||||
|
}
|
||||||
|
return raw as FamilyLegacyImportMetaResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST multipart legacy family import. Typical Laravel field: `file` (UploadedFile).
|
||||||
|
* Append optional `dry_run` = `1` when supported.
|
||||||
|
*/
|
||||||
|
export async function submitFamilyLegacyImport(formData: FormData): Promise<FamilyLegacyImportResult> {
|
||||||
|
const raw = await fetchMultipartJson<
|
||||||
|
FamilyLegacyImportResult | ApiEnvelope<FamilyLegacyImportResult>
|
||||||
|
>('/api/v1/families/import-legacy', formData)
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw === 'object' &&
|
||||||
|
'data' in raw &&
|
||||||
|
(raw as ApiEnvelope<FamilyLegacyImportResult>).data != null
|
||||||
|
) {
|
||||||
|
return (raw as ApiEnvelope<FamilyLegacyImportResult>).data as FamilyLegacyImportResult
|
||||||
|
}
|
||||||
|
return raw as FamilyLegacyImportResult
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPaypalTransactions(params?: {
|
export async function fetchPaypalTransactions(params?: {
|
||||||
q?: string
|
q?: string
|
||||||
perPage?: number
|
perPage?: number
|
||||||
@@ -983,6 +1495,35 @@ export async function fetchStudentScoreCard(studentId: number): Promise<StudentS
|
|||||||
return apiFetch<StudentScoreCardResponse>(`/api/v1/students/${studentId}/score-card`)
|
return apiFetch<StudentScoreCardResponse>(`/api/v1/students/${studentId}/score-card`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Students the current user may open on the score-card list (CI `StudentController::scoreCardList`). */
|
||||||
|
export type ScoreCardSelectableStudent = {
|
||||||
|
id: number
|
||||||
|
school_id?: string | null
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
/** When present, client may filter to the teacher’s active section (see `StudentScoreCardListPage`). */
|
||||||
|
class_section_ids?: number[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teachers should pass `class_section_id` + `school_year` so the list matches CI `scoreCardList`
|
||||||
|
* (students in that section for the year, not the whole school).
|
||||||
|
*/
|
||||||
|
export async function fetchScoreCardSelectableStudents(params?: {
|
||||||
|
classSectionId?: number | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
}): Promise<{ students: ScoreCardSelectableStudent[] }> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.classSectionId != null && params.classSectionId > 0) {
|
||||||
|
q.set('class_section_id', String(params.classSectionId))
|
||||||
|
}
|
||||||
|
if (params?.schoolYear != null && String(params.schoolYear).trim() !== '') {
|
||||||
|
q.set('school_year', String(params.schoolYear).trim())
|
||||||
|
}
|
||||||
|
const qs = q.size > 0 ? `?${q.toString()}` : ''
|
||||||
|
return apiFetch<{ students: ScoreCardSelectableStudent[] }>(`/api/v1/students/score-card/selectable${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAdministratorEmergencyContacts(params?: {
|
export async function fetchAdministratorEmergencyContacts(params?: {
|
||||||
parentId?: number | null
|
parentId?: number | null
|
||||||
parentIds?: number[]
|
parentIds?: number[]
|
||||||
@@ -1115,3 +1656,358 @@ export async function fetchReportCardPdf(studentId: number): Promise<Blob> {
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.blob()
|
return res.blob()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapData<T extends object>(body: T & { data?: unknown }): T {
|
||||||
|
if (body && typeof body === 'object' && 'data' in body && body.data && typeof body.data === 'object') {
|
||||||
|
return body.data as T
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClassPrepIndex(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<ClassPrepIndexResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<ClassPrepIndexResponse & { data?: ClassPrepIndexResponse }>(
|
||||||
|
`/api/v1/class-prep${suffix}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveClassPrepAdjustment(payload: {
|
||||||
|
class_section_id: number | string
|
||||||
|
school_year: string
|
||||||
|
semester?: string
|
||||||
|
adjustments: Record<string, number>
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/class-prep/adjustments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClassPrepPrintPayload(params: {
|
||||||
|
sectionId: string
|
||||||
|
schoolYear: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<ClassPrepPrintResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<ClassPrepPrintResponse & { data?: ClassPrepPrintResponse }>(
|
||||||
|
`/api/v1/class-prep/sections/${encodeURIComponent(params.sectionId)}/print/${encodeURIComponent(params.schoolYear)}${suffix}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCommunicationsCompose(): Promise<CommunicationsComposeResponse> {
|
||||||
|
const body = await apiFetch<CommunicationsComposeResponse & { data?: CommunicationsComposeResponse }>(
|
||||||
|
'/api/v1/communications/compose',
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudentFamilies(studentId: number): Promise<{ data: FamilyListItem[] }> {
|
||||||
|
return apiFetch(`/api/v1/students/${studentId}/families`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFamilyGuardians(familyId: number): Promise<{ data: GuardianListItem[] }> {
|
||||||
|
return apiFetch(`/api/v1/families/${familyId}/guardians`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewCommunicationsEmail(payload: {
|
||||||
|
student_id: number
|
||||||
|
family_id: number
|
||||||
|
template_key: string
|
||||||
|
vars?: Record<string, string>
|
||||||
|
}): Promise<CommunicationsPreviewResponse> {
|
||||||
|
return apiFetch(`/api/v1/communications/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendCommunicationsEmail(payload: {
|
||||||
|
student_id: number
|
||||||
|
family_id: number
|
||||||
|
template_key: string
|
||||||
|
subject: string
|
||||||
|
body: string
|
||||||
|
recipients: string[]
|
||||||
|
cc?: string[]
|
||||||
|
bcc?: string[]
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/communications/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConfigurations(): Promise<ConfigurationListResponse> {
|
||||||
|
const body = await apiFetch<ConfigurationListResponse & { data?: ConfigurationListResponse }>(
|
||||||
|
'/api/v1/configuration',
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfigurationEntry(payload: {
|
||||||
|
config_key: string
|
||||||
|
config_value: string
|
||||||
|
}): Promise<{ ok?: boolean }> {
|
||||||
|
return apiFetch(`/api/v1/configuration`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfigurationEntry(
|
||||||
|
id: number | string,
|
||||||
|
payload: { config_key: string; config_value: string },
|
||||||
|
): Promise<{ ok?: boolean }> {
|
||||||
|
return apiFetch(`/api/v1/configuration/${encodeURIComponent(String(id))}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfigurationEntry(id: number | string): Promise<{ ok?: boolean }> {
|
||||||
|
return apiFetch(`/api/v1/configuration/${encodeURIComponent(String(id))}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDiscountsList(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<DiscountsListResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<DiscountsListResponse & { data?: DiscountsListResponse }>(
|
||||||
|
`/api/v1/discounts${suffix}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDiscountApplyContext(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<DiscountApplyContextResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<DiscountApplyContextResponse & { data?: DiscountApplyContextResponse }>(
|
||||||
|
`/api/v1/discounts/apply-context${suffix}`,
|
||||||
|
)
|
||||||
|
return unwrapData(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDiscountVoucher(payload: Record<string, unknown>): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/discounts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyDiscountVoucher(payload: {
|
||||||
|
voucher_id: number | string
|
||||||
|
parent_ids: (number | string)[]
|
||||||
|
allow_additional?: boolean
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/discounts/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reverseDiscountApplication(payload: {
|
||||||
|
voucher_id?: number | string
|
||||||
|
parent_ids: (number | string)[]
|
||||||
|
reason?: string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/discounts/reverse`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnrollmentWithdrawalResponse(
|
||||||
|
d: EnrollmentWithdrawalPageResponse,
|
||||||
|
): {
|
||||||
|
students: EnrollmentWithdrawalPageResponse['students']
|
||||||
|
classes: EnrollmentWithdrawalPageResponse['classes']
|
||||||
|
schoolYears: string[]
|
||||||
|
selectedYear: string
|
||||||
|
currentYear: string
|
||||||
|
semester: string
|
||||||
|
missingYear: boolean
|
||||||
|
isCurrentYear: boolean
|
||||||
|
} {
|
||||||
|
const selectedYear = String(d.selectedYear ?? d.selected_year ?? '')
|
||||||
|
const currentYear = String(d.currentYear ?? d.current_year ?? '')
|
||||||
|
const semester = String(d.semester ?? '')
|
||||||
|
const missingYear = Boolean(d.missingYear ?? d.missing_year)
|
||||||
|
let isCurrentYear = d.isCurrentYear ?? d.is_current_year
|
||||||
|
if (isCurrentYear === undefined)
|
||||||
|
isCurrentYear = selectedYear !== '' && currentYear !== '' && selectedYear === currentYear
|
||||||
|
const schoolYearsRaw = d.schoolYears ?? d.school_years
|
||||||
|
const schoolYears = Array.isArray(schoolYearsRaw) ? schoolYearsRaw.map((y) => String(y)) : []
|
||||||
|
return {
|
||||||
|
students: d.students ?? [],
|
||||||
|
classes: d.classes ?? [],
|
||||||
|
schoolYears,
|
||||||
|
selectedYear,
|
||||||
|
currentYear,
|
||||||
|
semester,
|
||||||
|
missingYear,
|
||||||
|
isCurrentYear: Boolean(isCurrentYear),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin — registered students (legacy new-students). */
|
||||||
|
export async function fetchRegisteredNewStudents(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{
|
||||||
|
new_students: RegisteredNewStudentsResponse['new_students']
|
||||||
|
total_new?: number
|
||||||
|
school_years?: string[]
|
||||||
|
}> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<
|
||||||
|
RegisteredNewStudentsResponse & { data?: RegisteredNewStudentsResponse }
|
||||||
|
>(`/api/v1/administrator/enroll-withdrawal/registered-new-students${suffix}`)
|
||||||
|
const d = unwrapData(body)
|
||||||
|
return {
|
||||||
|
new_students: d.new_students ?? [],
|
||||||
|
total_new: d.total_new,
|
||||||
|
school_years: d.school_years,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin — enrollment & withdrawal grid (legacy enrollment_withdrawal). */
|
||||||
|
export async function fetchEnrollmentWithdrawalPage(params?: {
|
||||||
|
schoolYear?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<ReturnType<typeof normalizeEnrollmentWithdrawalResponse>> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.schoolYear) qs.set('schoolYear', params.schoolYear)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<
|
||||||
|
EnrollmentWithdrawalPageResponse & { data?: EnrollmentWithdrawalPageResponse }
|
||||||
|
>(`/api/v1/administrator/enroll-withdrawal${suffix}`)
|
||||||
|
const d = unwrapData(body)
|
||||||
|
return normalizeEnrollmentWithdrawalResponse(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postEnrollmentWithdrawalAssignClass(payload: {
|
||||||
|
student_id: number
|
||||||
|
parent_id: number
|
||||||
|
class_section_id: number | string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/administrator/enroll-withdrawal/assign-class`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postEnrollmentWithdrawalStatusBatch(payload: {
|
||||||
|
updates: { student_id: number; enrollment_status: string }[]
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`/api/v1/administrator/enroll-withdrawal/status-batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postGenerateInvoiceForParent(parentId: number): Promise<unknown> {
|
||||||
|
return apiFetch(`/api/v1/administrator/invoices/generate-for-parent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ parent_id: parentId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExpensesList(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<ExpensesListResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
const body = await apiFetch<ExpensesListResponse & { data?: ExpensesListResponse }>(
|
||||||
|
`/api/v1/administrator/expenses${suffix}`,
|
||||||
|
)
|
||||||
|
const d = unwrapData(body as ExpensesListResponse & { data?: ExpensesListResponse })
|
||||||
|
return {
|
||||||
|
expenses: d.expenses ?? [],
|
||||||
|
school_year: d.school_year,
|
||||||
|
semester: d.semester,
|
||||||
|
school_years: d.school_years,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExpenseCreateForm(): Promise<ExpenseCreateFormResponse> {
|
||||||
|
const body = await apiFetch<ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse }>(
|
||||||
|
'/api/v1/administrator/expenses/create-options',
|
||||||
|
)
|
||||||
|
return unwrapData(body as ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExpense(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return fetchMultipartJson('/api/v1/administrator/expenses', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExpenseEdit(expenseId: number): Promise<ExpenseEditFormResponse> {
|
||||||
|
const body = await apiFetch<ExpenseEditFormResponse & { data?: ExpenseEditFormResponse }>(
|
||||||
|
`/api/v1/administrator/expenses/${expenseId}/edit`,
|
||||||
|
)
|
||||||
|
return unwrapData(body as ExpenseEditFormResponse & { data?: ExpenseEditFormResponse })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExpense(
|
||||||
|
expenseId: number,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return fetchMultipartJson(`/api/v1/administrator/expenses/${expenseId}`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExpenseStatus(payload: {
|
||||||
|
id: number
|
||||||
|
status: 'approved' | 'denied'
|
||||||
|
}): Promise<{ success?: boolean; error?: string }> {
|
||||||
|
return apiFetch(`/api/v1/administrator/expenses/update-status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Late slips preview (legacy CI slips/preview_list).
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
export type SlipPreviewRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function fetchSlipPreviewList(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<{ rows?: SlipPreviewRow[]; school_year?: string; semester?: string }> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`/api/v1/administrator/slips/preview${suffix}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Staff directory (legacy CI `staff/*`).
|
||||||
|
* Expected Laravel routes under `/api/v1/administrator/staff`.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/staff'
|
||||||
|
|
||||||
|
export type StaffListRow = {
|
||||||
|
id: number
|
||||||
|
user_id?: number
|
||||||
|
school_id?: string | null
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
email?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
active_role?: string | null
|
||||||
|
class_section?: string | null
|
||||||
|
verification_issue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StaffIndexResponse = {
|
||||||
|
staff?: StaffListRow[]
|
||||||
|
issues_count?: number
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
schoolYears?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleOption = { id: number; name: string }
|
||||||
|
|
||||||
|
export type StaffFormOptionsResponse = {
|
||||||
|
roles?: RoleOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StaffMemberResponse = {
|
||||||
|
user?: Record<string, unknown> & {
|
||||||
|
id?: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
email?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
}
|
||||||
|
staff?: Record<string, unknown> & {
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
email?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStaffIndex(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<StaffIndexResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`${BASE}${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
|
||||||
|
return apiFetch(`${BASE}/form-options`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
|
||||||
|
return apiFetch(`${BASE}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStaff(payload: {
|
||||||
|
firstname: string
|
||||||
|
lastname: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
role_id: number
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`${BASE}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStaff(
|
||||||
|
id: number,
|
||||||
|
payload: { firstname: string; lastname: string; email: string; phone?: string },
|
||||||
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`${BASE}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Teacher portal API (`Views/teacher/*` parity).
|
||||||
|
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
||||||
|
*/
|
||||||
|
import { apiFetch, getStoredToken } from './http'
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
|
||||||
|
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
||||||
|
export type TeacherDashboardPayload = {
|
||||||
|
welcome?: string | null
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
counts?: Record<string, number | string | null>
|
||||||
|
announcements?: Array<{ title?: string | null; body?: string | null; html?: string | null }>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTeacherJson<T>(path: string): Promise<T> {
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return apiFetch<T>(`/api/v1/teacher${p}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in: set `VITE_TEACHER_DASHBOARD_API=1` in `.env` when Laravel exposes
|
||||||
|
* `GET /api/v1/teacher/dashboard`. Otherwise the SPA skips the request (avoids 404 noise).
|
||||||
|
*/
|
||||||
|
export function isTeacherDashboardApiEnabled(): boolean {
|
||||||
|
return import.meta.env.VITE_TEACHER_DASHBOARD_API === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTeacherDashboard(): Promise<TeacherDashboardPayload> {
|
||||||
|
return fetchTeacherJson<TeacherDashboardPayload>('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postTeacherJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return apiFetch<T>(`/api/v1/teacher${p}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postTeacherMultipart<T>(path: string, formData: FormData): Promise<T> {
|
||||||
|
const isAbsoluteApiPath = path.startsWith('/api/')
|
||||||
|
const p = isAbsoluteApiPath ? path : path.startsWith('/') ? path : `/${path}`
|
||||||
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
|
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (typeof body === 'object' && body !== null && 'errors' in body) {
|
||||||
|
const errors = (body as { errors?: unknown }).errors
|
||||||
|
if (errors && typeof errors === 'object') {
|
||||||
|
const firstFieldErrors = Object.values(errors as Record<string, unknown>).find(
|
||||||
|
(value) => Array.isArray(value) && value.length > 0,
|
||||||
|
)
|
||||||
|
if (Array.isArray(firstFieldErrors) && firstFieldErrors[0]) {
|
||||||
|
msg = String(firstFieldErrors[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(msg || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as T
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ export type AuthUser = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
roles: Record<string, boolean>
|
roles: Record<string, boolean>
|
||||||
|
class_section_id?: number | null
|
||||||
|
class_section_name?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoginSuccess = {
|
export type LoginSuccess = {
|
||||||
@@ -129,8 +131,13 @@ export type AdministratorAbsenceFormResponse = {
|
|||||||
admin_name?: string | null
|
admin_name?: string | null
|
||||||
semester?: string | null
|
semester?: string | null
|
||||||
schoolYear?: string | null
|
schoolYear?: string | null
|
||||||
|
/** Laravel may return snake_case */
|
||||||
|
school_year?: string | null
|
||||||
existing?: AdministratorAbsenceRow[]
|
existing?: AdministratorAbsenceRow[]
|
||||||
availableDates?: string[]
|
availableDates?: string[]
|
||||||
|
available_dates?: string[]
|
||||||
|
/** Optional labels for POST `reason_type` (from API or empty to use client defaults). */
|
||||||
|
reason_types?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdministratorAbsenceSubmitResponse = {
|
export type AdministratorAbsenceSubmitResponse = {
|
||||||
@@ -201,6 +208,95 @@ export type AdministratorAdminAttendanceResponse = {
|
|||||||
admins_grid?: AdministratorAdminAttendanceRow[]
|
admins_grid?: AdministratorAdminAttendanceRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff teacher daily grid — `GET /api/v1/attendance/staff/teachers` */
|
||||||
|
export type TeacherStaffAttendanceRow = {
|
||||||
|
teacher_id?: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
position?: string | null
|
||||||
|
status?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeacherStaffAttendanceResponse = {
|
||||||
|
semester?: string | null
|
||||||
|
school_year?: string | null
|
||||||
|
date?: string | null
|
||||||
|
class_section_id?: number | null
|
||||||
|
locked?: boolean
|
||||||
|
/** Section id → label */
|
||||||
|
sections?: Record<string, string>
|
||||||
|
grid?: TeacherStaffAttendanceRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monthly admin/teacher grid payload — `GET /api/v1/attendance/staff/month-overview` */
|
||||||
|
export type StaffMonthlyCell = { status?: string | null }
|
||||||
|
export type StaffMonthlyTeacherRow = {
|
||||||
|
user_id?: number
|
||||||
|
teacher_id?: number
|
||||||
|
name?: string | null
|
||||||
|
position?: string | null
|
||||||
|
cells?: Record<string, StaffMonthlyCell>
|
||||||
|
totals?: { p?: number; a?: number; l?: number }
|
||||||
|
}
|
||||||
|
export type StaffMonthlySection = {
|
||||||
|
section_code?: string
|
||||||
|
label?: string
|
||||||
|
teachers?: StaffMonthlyTeacherRow[]
|
||||||
|
}
|
||||||
|
export type StaffMonthlyAdminRow = {
|
||||||
|
user_id?: number
|
||||||
|
name?: string | null
|
||||||
|
role?: string | null
|
||||||
|
cells?: Record<string, StaffMonthlyCell>
|
||||||
|
totals?: { p?: number; a?: number; l?: number }
|
||||||
|
}
|
||||||
|
export type StaffMonthlyOverviewPayload = {
|
||||||
|
filters?: {
|
||||||
|
semester?: string
|
||||||
|
school_year?: string
|
||||||
|
range_label?: string
|
||||||
|
month_label?: string
|
||||||
|
}
|
||||||
|
sundays?: string[]
|
||||||
|
noSchoolDays?: string[]
|
||||||
|
sections?: StaffMonthlySection[]
|
||||||
|
admins?: StaffMonthlyAdminRow[]
|
||||||
|
missingYear?: boolean
|
||||||
|
schoolYears?: Array<{ school_year?: string } | string>
|
||||||
|
isCurrentYear?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttendanceViolationStudentRow = Record<string, unknown>
|
||||||
|
export type AttendanceViolationsResponse = {
|
||||||
|
students?: AttendanceViolationStudentRow[]
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
debug?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentAttendanceAdminReportRow = Record<string, unknown>
|
||||||
|
|
||||||
|
export type EarlyDismissalGroup = Record<string, Array<Record<string, unknown>>>
|
||||||
|
export type EarlyDismissalsResponse = {
|
||||||
|
groups?: EarlyDismissalGroup
|
||||||
|
schoolYear?: string
|
||||||
|
semester?: string
|
||||||
|
signatureByDate?: Record<string, { filename?: string; original_name?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttendanceCommentTemplate = {
|
||||||
|
id?: number
|
||||||
|
min_score?: number
|
||||||
|
max_score?: number
|
||||||
|
template_text?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttendanceCommentTemplatesResponse = {
|
||||||
|
templates?: AttendanceCommentTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ParentAttendanceRow = {
|
export type ParentAttendanceRow = {
|
||||||
firstname: string
|
firstname: string
|
||||||
lastname: string
|
lastname: string
|
||||||
@@ -539,7 +635,7 @@ export type UserListRow = {
|
|||||||
semester?: string | null
|
semester?: string | null
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
} | null
|
} | null
|
||||||
roles?: Array<{ id?: number; name?: string | null; slug?: string | null }>
|
roles?: Array<string | { id?: number; name?: string | null; slug?: string | null }>
|
||||||
role_ids?: number[]
|
role_ids?: number[]
|
||||||
parent_type?: string
|
parent_type?: string
|
||||||
second_from_parents?: string
|
second_from_parents?: string
|
||||||
@@ -580,6 +676,10 @@ export type FamilyGuardianRow = {
|
|||||||
is_primary?: boolean
|
is_primary?: boolean
|
||||||
receive_emails?: boolean
|
receive_emails?: boolean
|
||||||
receive_sms?: boolean
|
receive_sms?: boolean
|
||||||
|
address_street?: string | null
|
||||||
|
city?: string | null
|
||||||
|
state?: string | null
|
||||||
|
zip?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FamilyStudentRow = {
|
export type FamilyStudentRow = {
|
||||||
@@ -621,6 +721,50 @@ export type FamilyAdminIndexResponse = ApiEnvelope<{
|
|||||||
resolved_student_id?: number | null
|
resolved_student_id?: number | null
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
export type FamilySearchSuggestionItem = {
|
||||||
|
type: 'student' | 'guardian'
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
sub?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FamilySearchResponse = {
|
||||||
|
items: FamilySearchSuggestionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET `/api/v1/families/import-legacy` — metadata for the legacy import UI (optional; SPA uses defaults if unavailable). */
|
||||||
|
export type FamilyLegacyImportMetaResponse = {
|
||||||
|
instructions?: string | null
|
||||||
|
instructions_html?: string | null
|
||||||
|
accepted_extensions?: string[]
|
||||||
|
accepted_mime_types?: string[]
|
||||||
|
max_file_bytes?: number | null
|
||||||
|
template_url?: string | null
|
||||||
|
template_download_url?: string | null
|
||||||
|
columns?: string[]
|
||||||
|
dry_run_supported?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FamilyLegacyImportRowError = {
|
||||||
|
row?: number
|
||||||
|
line?: number
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST `/api/v1/families/import-legacy` (multipart) response */
|
||||||
|
export type FamilyLegacyImportResult = {
|
||||||
|
ok?: boolean
|
||||||
|
message?: string
|
||||||
|
imported?: number
|
||||||
|
imported_count?: number
|
||||||
|
skipped?: number
|
||||||
|
skipped_count?: number
|
||||||
|
failed?: number
|
||||||
|
errors?: FamilyLegacyImportRowError[]
|
||||||
|
validation_errors?: FamilyLegacyImportRowError[]
|
||||||
|
}
|
||||||
|
|
||||||
export type PaypalTransactionRow = {
|
export type PaypalTransactionRow = {
|
||||||
id: number
|
id: number
|
||||||
transaction_id?: string | null
|
transaction_id?: string | null
|
||||||
@@ -977,10 +1121,15 @@ export type SchoolCalendarEventRow = {
|
|||||||
title: string
|
title: string
|
||||||
start: string
|
start: string
|
||||||
description?: string
|
description?: string
|
||||||
|
backgroundColor?: string | null
|
||||||
extendedProps?: {
|
extendedProps?: {
|
||||||
event_type?: string
|
event_type?: string
|
||||||
backgroundColor?: string
|
backgroundColor?: string
|
||||||
color?: string
|
color?: string
|
||||||
|
no_school?: number | boolean
|
||||||
|
notify_parent?: number | boolean
|
||||||
|
notify_teacher?: number | boolean
|
||||||
|
notify_admin?: number | boolean
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1080,13 +1229,20 @@ export type ClassProgressReportRow = {
|
|||||||
status_label?: string | null
|
status_label?: string | null
|
||||||
covered?: string | null
|
covered?: string | null
|
||||||
homework?: string | null
|
homework?: string | null
|
||||||
|
materials?: string | null
|
||||||
support_needed?: string | null
|
support_needed?: string | null
|
||||||
flags?: string[]
|
flags?: string[]
|
||||||
week_start?: string | null
|
week_start?: string | null
|
||||||
week_end?: string | null
|
week_end?: string | null
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
attachments?: { id?: number; name?: string | null; file_path?: string | null; download_url?: string | null }[]
|
attachments?: {
|
||||||
|
id?: number
|
||||||
|
name?: string | null
|
||||||
|
file_path?: string | null
|
||||||
|
download_url?: string | null
|
||||||
|
legacy?: boolean | number | null
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassProgressGroupRow = {
|
export type ClassProgressGroupRow = {
|
||||||
@@ -1183,3 +1339,222 @@ export type ParentEventsOverviewResponse = {
|
|||||||
charges?: Record<string, unknown>[]
|
charges?: Record<string, unknown>[]
|
||||||
yourStudents?: ParentEnrollmentStudent[]
|
yourStudents?: ParentEnrollmentStudent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Class preparation (inventory vs sections) — Laravel `GET /api/v1/class-prep` */
|
||||||
|
export type ClassPrepSectionRow = {
|
||||||
|
class_section_id: number | string
|
||||||
|
class_section: string
|
||||||
|
needs_print?: boolean
|
||||||
|
last_printed_at?: string | null
|
||||||
|
student_count: number
|
||||||
|
prep_items: Record<string, number>
|
||||||
|
adjustments: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClassPrepIndexResponse = {
|
||||||
|
school_year: string
|
||||||
|
semester?: string | null
|
||||||
|
prep_results: ClassPrepSectionRow[]
|
||||||
|
total_needed: Record<string, number>
|
||||||
|
available: Record<string, number>
|
||||||
|
shortages?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClassPrepPrintResponse = {
|
||||||
|
class_section?: string | null
|
||||||
|
prep_items: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommunicationsStudentOption = {
|
||||||
|
id: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommunicationsTemplateOption = {
|
||||||
|
template_key: string
|
||||||
|
name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommunicationsComposeResponse = {
|
||||||
|
students: CommunicationsStudentOption[]
|
||||||
|
templates: CommunicationsTemplateOption[]
|
||||||
|
display_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommunicationsPreviewResponse = {
|
||||||
|
subject?: string | null
|
||||||
|
html?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FamilyListItem = {
|
||||||
|
id: number
|
||||||
|
household_name?: string | null
|
||||||
|
is_primary_home?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GuardianListItem = {
|
||||||
|
email?: string | null
|
||||||
|
receive_emails?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigurationRow = {
|
||||||
|
id: number | string
|
||||||
|
config_key: string
|
||||||
|
config_value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigurationListResponse = {
|
||||||
|
configs: ConfigurationRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscountVoucherRow = {
|
||||||
|
id: number | string
|
||||||
|
code: string
|
||||||
|
discount_type: 'percent' | 'fixed' | string
|
||||||
|
discount_value: number | string
|
||||||
|
max_uses?: number | string | null
|
||||||
|
times_used?: number | string | null
|
||||||
|
valid_from?: string | null
|
||||||
|
valid_until?: string | null
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscountsListResponse = {
|
||||||
|
vouchers: DiscountVoucherRow[]
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
school_years?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscountApplyParentRow = {
|
||||||
|
id: number | string
|
||||||
|
school_id?: string | null
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
email?: string | null
|
||||||
|
has_discount?: boolean
|
||||||
|
total_discount?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscountApplyContextResponse = {
|
||||||
|
vouchers: DiscountVoucherRow[]
|
||||||
|
parents: DiscountApplyParentRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: enroll_withdraw / new-students list */
|
||||||
|
export type RegisteredNewStudentRow = {
|
||||||
|
id: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
new_student?: string | null
|
||||||
|
class_section?: string | null
|
||||||
|
enrollment_status?: string | null
|
||||||
|
age?: string | number | null
|
||||||
|
registration_date?: string | null
|
||||||
|
parent_firstname?: string | null
|
||||||
|
parent_lastname?: string | null
|
||||||
|
parent_phone?: string | null
|
||||||
|
parent_email?: string | null
|
||||||
|
emergency_name?: string | null
|
||||||
|
emergency_relationship?: string | null
|
||||||
|
emergency_phone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegisteredNewStudentsResponse = {
|
||||||
|
new_students: RegisteredNewStudentRow[]
|
||||||
|
total_new?: number
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
school_years?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnrollmentWithdrawalClassOption = {
|
||||||
|
id?: number | string
|
||||||
|
class_section_id?: number | string
|
||||||
|
name?: string | null
|
||||||
|
class_section_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnrollmentWithdrawalStudentRow = {
|
||||||
|
student_id?: number
|
||||||
|
id?: number
|
||||||
|
parent_id?: number | null
|
||||||
|
guardian_id?: number | null
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
parent_label?: string | null
|
||||||
|
new_student?: string | null
|
||||||
|
removed_previous_year?: string | null
|
||||||
|
class_section?: string | null
|
||||||
|
enrollment_status?: string | null
|
||||||
|
registration_date?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnrollmentWithdrawalPageResponse = {
|
||||||
|
students: EnrollmentWithdrawalStudentRow[]
|
||||||
|
classes: EnrollmentWithdrawalClassOption[]
|
||||||
|
school_years?: string[]
|
||||||
|
schoolYears?: string[]
|
||||||
|
selected_year?: string | null
|
||||||
|
selectedYear?: string | null
|
||||||
|
current_year?: string | null
|
||||||
|
currentYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
missing_year?: boolean
|
||||||
|
missingYear?: boolean
|
||||||
|
is_current_year?: boolean
|
||||||
|
isCurrentYear?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin expenses (`Views/expenses/*`) */
|
||||||
|
export type ExpenseUserOption = {
|
||||||
|
id: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpenseRow = {
|
||||||
|
id: number
|
||||||
|
category: string
|
||||||
|
amount: number | string
|
||||||
|
retailor?: string | null
|
||||||
|
retailer?: string | null
|
||||||
|
description?: string | null
|
||||||
|
status: string
|
||||||
|
receipt_path?: string | null
|
||||||
|
/** Full URL if API provides it */
|
||||||
|
receipt_url?: string | null
|
||||||
|
purchaser_firstname?: string | null
|
||||||
|
purchaser_lastname?: string | null
|
||||||
|
approver_firstname?: string | null
|
||||||
|
approver_lastname?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
date_of_purchase?: string | null
|
||||||
|
purchase_date?: string | null
|
||||||
|
purchased_at?: string | null
|
||||||
|
date_purchased?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpensesListResponse = {
|
||||||
|
expenses: ExpenseRow[]
|
||||||
|
school_year?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
school_years?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpenseCreateFormResponse = {
|
||||||
|
retailors: string[]
|
||||||
|
users: ExpenseUserOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpenseDetail = ExpenseRow & {
|
||||||
|
purchased_by?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpenseEditFormResponse = {
|
||||||
|
expense: ExpenseDetail
|
||||||
|
retailors: string[]
|
||||||
|
users: ExpenseUserOption[]
|
||||||
|
receipt_url?: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* WhatsApp admin tools (legacy CI `whatsapp/*`).
|
||||||
|
* Expected Laravel routes under `/api/v1/administrator/whatsapp`.
|
||||||
|
*/
|
||||||
|
import { apiFetch } from './http'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/administrator/whatsapp'
|
||||||
|
|
||||||
|
export type WhatsappSectionRow = {
|
||||||
|
class_section_id?: number
|
||||||
|
id?: number
|
||||||
|
class_section_name?: string
|
||||||
|
name?: string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WhatsappLinkRow = {
|
||||||
|
invite_link?: string | null
|
||||||
|
active?: number | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WhatsappManageLinksResponse = {
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
sections?: WhatsappSectionRow[]
|
||||||
|
linksBySection?: Record<string, WhatsappLinkRow>
|
||||||
|
parents?: Array<{
|
||||||
|
id: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
email?: string | null
|
||||||
|
source?: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentContactRow = {
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
type?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
email?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WhatsappParentContactsResponse = {
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
contacts?: ParentContactRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentContactsByClassParentRow = {
|
||||||
|
class_section_id?: number
|
||||||
|
primary_id?: number
|
||||||
|
primary_name?: string
|
||||||
|
primary_phone?: string | null
|
||||||
|
primary_member?: string | null
|
||||||
|
second_id?: number
|
||||||
|
second_name?: string
|
||||||
|
second_phone?: string | null
|
||||||
|
second_member?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentContactsByClassSection = {
|
||||||
|
class_section_id?: number
|
||||||
|
class_section_name?: string
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
parents?: ParentContactsByClassParentRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WhatsappParentContactsByClassResponse = {
|
||||||
|
classSections?: ParentContactsByClassSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWhatsappManageLinks(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<WhatsappManageLinksResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`${BASE}/manage-links${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWhatsappLink(payload: {
|
||||||
|
class_section_id: number
|
||||||
|
class_section_name?: string
|
||||||
|
invite_link: string
|
||||||
|
active?: boolean
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`${BASE}/save-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWhatsappInvites(payload: {
|
||||||
|
mode: 'all' | 'class' | 'parents'
|
||||||
|
class_section_id?: number | null
|
||||||
|
parent_ids?: number[]
|
||||||
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
|
return apiFetch(`${BASE}/send-invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWhatsappParentContacts(params?: {
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<WhatsappParentContactsResponse> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
|
return apiFetch(`${BASE}/parent-contacts${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWhatsappParentContactsByClass(): Promise<WhatsappParentContactsByClassResponse> {
|
||||||
|
return apiFetch(`${BASE}/parent-contacts-by-class`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWhatsappMembership(payload: {
|
||||||
|
class_section_id: number
|
||||||
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
primary_id: number
|
||||||
|
second_id?: number
|
||||||
|
primary_member?: string
|
||||||
|
second_member?: string
|
||||||
|
}): Promise<{ status?: string; message?: string }> {
|
||||||
|
return apiFetch(`${BASE}/update-membership`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { setStoredToken, getStoredToken } from '../api/http'
|
import { ApiHttpError, setStoredToken, getStoredToken } from '../api/http'
|
||||||
import type { AuthUser } from '../api/types'
|
import type { AuthUser } from '../api/types'
|
||||||
import { loginRequest } from '../api/session'
|
import { loginRequest } from '../api/session'
|
||||||
|
|
||||||
@@ -98,7 +98,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const result = await loginRequest(email, password)
|
let result: Awaited<ReturnType<typeof loginRequest>>
|
||||||
|
try {
|
||||||
|
result = await loginRequest(email, password)
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof ApiHttpError
|
||||||
|
? e.message
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'Could not reach the server.'
|
||||||
|
return { ok: false as const, message }
|
||||||
|
}
|
||||||
|
|
||||||
if (result.status === false) {
|
if (result.status === false) {
|
||||||
return {
|
return {
|
||||||
ok: false as const,
|
ok: false as const,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
|
|
||||||
export type CiFlashMessage = {
|
export type CiFlashMessage = {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -527,36 +527,100 @@ export function CiNoInvoiceFound({ message = 'No invoice found.' }: { message?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CiPublicFooter({ year = new Date().getFullYear() }: { year?: number }) {
|
export function CiPublicFooter({ year = new Date().getFullYear() }: { year?: number }) {
|
||||||
|
const footerRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = footerRef.current
|
||||||
|
if (!root) return
|
||||||
|
const links = root.querySelectorAll<HTMLAnchorElement>('a.pdf-link')
|
||||||
|
const controllers: AbortController[] = []
|
||||||
|
|
||||||
|
links.forEach((link) => {
|
||||||
|
const pdfUrl = link.getAttribute('href')
|
||||||
|
if (!pdfUrl) return
|
||||||
|
const ac = new AbortController()
|
||||||
|
controllers.push(ac)
|
||||||
|
fetch(pdfUrl, { method: 'HEAD', signal: ac.signal })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
link.style.opacity = '0.7'
|
||||||
|
link.title = 'File might be temporarily unavailable'
|
||||||
|
console.warn('PDF might be missing:', pdfUrl)
|
||||||
|
link.addEventListener('click', function onClick(e) {
|
||||||
|
if (!confirm('The PDF file might be temporarily unavailable. Try to open it anyway?')) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
link.style.opacity = '0.7'
|
||||||
|
link.title = 'File check failed'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controllers.forEach((c) => c.abort())
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="footer mt-auto custom-footer text-white py-4">
|
<footer ref={footerRef} className="footer mt-auto custom-footer custom-footer--bar text-white px-3 shadow-sm">
|
||||||
<div className="container">
|
<div className="custom-footer__glow" aria-hidden />
|
||||||
<div className="row align-items-start justify-content-between">
|
<div className="container-fluid custom-footer__container">
|
||||||
<div className="col-md-6 col-12 mb-3 mb-md-0 text-md-start">
|
<div className="custom-footer__bar">
|
||||||
<ul className="list-unstyled mb-0 info-list">
|
<div className="custom-footer__meta" aria-label="Contact">
|
||||||
<li>
|
<span className="custom-footer__meta-item">
|
||||||
<i className="fa fa-map-marker-alt me-2" aria-hidden />
|
<i className="fa fa-map-marker-alt custom-footer__meta-ico" aria-hidden />
|
||||||
5 Courthouse Lane, Chelmsford, MA 01824
|
<span>5 Courthouse Lane, Chelmsford, MA 01824</span>
|
||||||
</li>
|
</span>
|
||||||
<li>
|
<a className="custom-footer__meta-link" href="tel:+19783640219">
|
||||||
<i className="fa fa-phone-alt me-2" aria-hidden />
|
<i className="fa fa-phone-alt custom-footer__meta-ico" aria-hidden />
|
||||||
+1 978-364-0219
|
+1 978-364-0219
|
||||||
</li>
|
</a>
|
||||||
<li>
|
<a className="custom-footer__meta-link" href="mailto:alrahma.isgl@gmail.com">
|
||||||
<i className="fa fa-envelope me-2" aria-hidden />
|
<i className="fa fa-envelope custom-footer__meta-ico" aria-hidden />
|
||||||
alrahma.isgl@gmail.com
|
alrahma.isgl@gmail.com
|
||||||
</li>
|
</a>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4 col-12 text-center text-md-end">
|
<nav className="custom-footer__docs" aria-label="Policies and guides">
|
||||||
<ul className="list-unstyled mb-0 info-list">
|
<a
|
||||||
<li><a href="/privacy_policy.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />Privacy Policy</a></li>
|
href="/privacy_policy.pdf"
|
||||||
<li><a href="/terms_of_service.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />Terms of Service</a></li>
|
target="_blank"
|
||||||
<li><a href="/account_creation_guide.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />How To Create An Account</a></li>
|
rel="noopener noreferrer"
|
||||||
</ul>
|
className="pdf-link custom-footer__doc-link"
|
||||||
|
data-filename="privacy_policy.pdf"
|
||||||
|
>
|
||||||
|
<i className="fas fa-file-pdf me-1" aria-hidden />
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/terms_of_service.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="pdf-link custom-footer__doc-link"
|
||||||
|
data-filename="terms_of_service.pdf"
|
||||||
|
>
|
||||||
|
<i className="fas fa-file-pdf me-1" aria-hidden />
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/account_creation_guide.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="pdf-link custom-footer__doc-link"
|
||||||
|
data-filename="account_creation_guide.pdf"
|
||||||
|
>
|
||||||
|
<i className="fas fa-file-pdf me-1" aria-hidden />
|
||||||
|
How To Create An Account
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="custom-footer__copyright-row">
|
||||||
|
<p className="custom-footer__copyright mb-0">
|
||||||
|
© {year}{' '}
|
||||||
|
<span className="custom-footer__copyright-brand">Al Rahma Sunday School</span> by ISGL. All Rights Reserved.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p className="text-center text-white mb-0 mt-3">© {year} Al Rahma Sunday School by ISGL. All Rights Reserved.</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { fetchDashboardRoute } from '../api/session'
|
||||||
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||||
|
|
||||||
|
/** Shared navigation after JWT login when the account has multiple roles (CI `welcome_back` / `select_role`). */
|
||||||
|
export function useContinueWithRole() {
|
||||||
|
const { setSelectedRole } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [busyRole, setBusyRole] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
|
||||||
|
|
||||||
|
async function continueWithRole(nextRole: string) {
|
||||||
|
setBusy(true)
|
||||||
|
setBusyRole(nextRole)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setSelectedRole(nextRole)
|
||||||
|
const roleLower = nextRole.toLowerCase()
|
||||||
|
const fromApp = from.startsWith('/app') ? from : ''
|
||||||
|
const hasDeepLink = Boolean(fromApp && fromApp !== '/app/home')
|
||||||
|
|
||||||
|
let target: string
|
||||||
|
if (hasDeepLink) {
|
||||||
|
target = fromApp
|
||||||
|
} else if (roleLower === 'parent') {
|
||||||
|
target = '/app/parent/home'
|
||||||
|
} else if (roleLower === 'teacher' || roleLower === 'teacher_assistant') {
|
||||||
|
target = '/app/teacher_dashboard'
|
||||||
|
} else {
|
||||||
|
target = '/app/home'
|
||||||
|
try {
|
||||||
|
const dash = await fetchDashboardRoute()
|
||||||
|
const route = dash.data?.dashboard?.route
|
||||||
|
const mapped = spaPathFromCiUrl(route)
|
||||||
|
if (mapped) target = mapped
|
||||||
|
} catch {
|
||||||
|
/* keep default */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(target, { replace: true })
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to continue.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
setBusyRole(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { continueWithRole, busy, busyRole, error, setError, from }
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import { CiPublicFooter } from '../components/CiPartials'
|
||||||
|
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
|
||||||
|
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
||||||
|
|
||||||
const parentNavItems = [
|
const parentNavItems = [
|
||||||
{ to: '/app/parent/home', icon: 'bi bi-house-door', label: 'Dashboard' },
|
{ to: '/app/parent/home', icon: 'bi bi-house-door', label: 'Dashboard' },
|
||||||
@@ -16,20 +19,152 @@ const parentNavItems = [
|
|||||||
{ to: '/app/parent/scores', icon: 'bi bi-clipboard-data', label: 'Scores' },
|
{ to: '/app/parent/scores', icon: 'bi bi-clipboard-data', label: 'Scores' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
/** Teacher top shortcuts (aligned with legacy teacher navbar). */
|
||||||
|
const teacherNavItems = [
|
||||||
|
{ to: '/app/teacher_dashboard', icon: 'bi bi-house-door', label: 'Dashboard' },
|
||||||
|
{ to: '/app/teacher/showupdate-attendance', icon: 'bi bi-calendar-check', label: 'Attendance' },
|
||||||
|
{ to: '/app/teacher/inventory/book-distribute', icon: 'bi bi-book', label: 'Books' },
|
||||||
|
{ to: '/app/teacher/calendar', icon: 'bi bi-calendar2-week', label: 'Calendar' },
|
||||||
|
{ to: '/app/teacher/class-view', icon: 'bi bi-pencil-square', label: 'Class' },
|
||||||
|
{ to: '/app/teacher/class-progress-submit', icon: 'bi bi-card-checklist', label: 'Class Progress' },
|
||||||
|
{ to: '/app/teacher/competition-scores', icon: 'bi bi-trophy', label: 'Competitions' },
|
||||||
|
{ to: '/app/teacher/exam-drafts', icon: 'bi bi-file-earmark-text', label: 'Exam Drafts' },
|
||||||
|
{ to: '/app/teacher/print-requests', icon: 'bi bi-printer', label: 'Print' },
|
||||||
|
{ to: '/app/student/score-card/list', icon: 'bi bi-card-text', label: 'Score Card' },
|
||||||
|
{ to: '/app/teacher/scores', icon: 'bi bi-bar-chart-line', label: 'Scores' },
|
||||||
|
{ to: '/app/teacher/absence-vacation', icon: 'bi bi-megaphone', label: 'TimeOff' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function initialsFromName(name: string | undefined | null): string {
|
||||||
|
const t = name?.trim()
|
||||||
|
if (!t) return '?'
|
||||||
|
const parts = t.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const a = parts[0][0] ?? ''
|
||||||
|
const b = parts[parts.length - 1][0] ?? ''
|
||||||
|
return (a + b).toUpperCase()
|
||||||
|
}
|
||||||
|
if (parts[0].length >= 2) return parts[0].slice(0, 2).toUpperCase()
|
||||||
|
return (parts[0][0] ?? '?').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleLabel(slug: string): string {
|
||||||
|
const t = slug.trim()
|
||||||
|
if (!t) return ''
|
||||||
|
return t.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { user, logout, selectedRole, roles } = useAuth()
|
const { user, logout, selectedRole, roles } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
const [portalStyle, setPortalStyle] = useState<PortalStylePrefs>(readPortalStylePrefs)
|
||||||
|
const [osPrefersDark, setOsPrefersDark] = useState(() =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||||
|
)
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const clusterRef = useRef<HTMLDivElement>(null)
|
||||||
|
const userMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const clearCloseTimer = useCallback(() => {
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current)
|
||||||
|
closeTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openUserMenu = useCallback(() => {
|
||||||
|
clearCloseTimer()
|
||||||
|
setUserMenuOpen(true)
|
||||||
|
}, [clearCloseTimer])
|
||||||
|
|
||||||
|
const closeUserMenu = useCallback(() => {
|
||||||
|
clearCloseTimer()
|
||||||
|
const root = clusterRef.current
|
||||||
|
if (root?.contains(document.activeElement)) {
|
||||||
|
userMenuTriggerRef.current?.focus()
|
||||||
|
}
|
||||||
|
setUserMenuOpen(false)
|
||||||
|
}, [clearCloseTimer])
|
||||||
|
|
||||||
|
const scheduleCloseUserMenu = useCallback(() => {
|
||||||
|
clearCloseTimer()
|
||||||
|
closeTimerRef.current = setTimeout(() => {
|
||||||
|
const root = clusterRef.current
|
||||||
|
if (root?.contains(document.activeElement)) {
|
||||||
|
closeTimerRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeUserMenu()
|
||||||
|
closeTimerRef.current = null
|
||||||
|
}, 200)
|
||||||
|
}, [clearCloseTimer, closeUserMenu])
|
||||||
|
|
||||||
|
useEffect(() => () => clearCloseTimer(), [clearCloseTimer])
|
||||||
|
|
||||||
|
const onClusterBlurCapture = useCallback(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const root = clusterRef.current
|
||||||
|
if (!root || !root.contains(document.activeElement)) {
|
||||||
|
closeUserMenu()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}, [closeUserMenu])
|
||||||
|
|
||||||
|
const onAvatarClick = useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
|
||||||
|
setUserMenuOpen((open) => !open)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeMenus = useCallback(() => {
|
||||||
|
closeUserMenu()
|
||||||
|
setNavOpen(false)
|
||||||
|
}, [closeUserMenu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setPortalStyle(readPortalStylePrefs())
|
||||||
|
window.addEventListener('alrahma-portal-style', sync)
|
||||||
|
return () => window.removeEventListener('alrahma-portal-style', sync)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const onChange = () => setOsPrefersDark(mq.matches)
|
||||||
|
mq.addEventListener('change', onChange)
|
||||||
|
return () => mq.removeEventListener('change', onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const role = selectedRole ?? roles[0] ?? ''
|
const role = selectedRole ?? roles[0] ?? ''
|
||||||
|
const canSwitchRole = roles.length > 1
|
||||||
const dashboardPath =
|
const dashboardPath =
|
||||||
role === 'parent'
|
isParentPortalRole(role)
|
||||||
? '/app/parent/home'
|
? '/app/parent/home'
|
||||||
: role === 'teacher' || role === 'teacher_assistant'
|
: isTeacherPortalRole(role)
|
||||||
? '/app/teacher/dashboard'
|
? '/app/teacher_dashboard'
|
||||||
: '/app/home'
|
: '/app/home'
|
||||||
|
|
||||||
|
const mainNavItems = isParentPortalRole(role)
|
||||||
|
? parentNavItems
|
||||||
|
: isTeacherPortalRole(role)
|
||||||
|
? teacherNavItems
|
||||||
|
: []
|
||||||
|
|
||||||
|
const initials = initialsFromName(user?.name)
|
||||||
|
const resolvedColorMode = resolvePortalColorMode(portalStyle, osPrefersDark)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-layout-app d-flex flex-column min-vh-100">
|
<div
|
||||||
|
className="main-layout-app d-flex flex-column min-vh-100"
|
||||||
|
data-portal-density={portalStyle.density}
|
||||||
|
data-portal-font-scale={portalStyle.fontScale}
|
||||||
|
data-portal-font-family={portalStyle.fontFamily}
|
||||||
|
data-portal-accent={portalStyle.accent}
|
||||||
|
data-portal-color-mode={resolvedColorMode}
|
||||||
|
>
|
||||||
<header className="navbar navbar-expand-lg custom-navbar sticky-top shadow-sm px-3">
|
<header className="navbar navbar-expand-lg custom-navbar sticky-top shadow-sm px-3">
|
||||||
<div className="container-fluid">
|
<div className="container-fluid">
|
||||||
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
|
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
|
||||||
@@ -47,33 +182,153 @@ export function MainLayout() {
|
|||||||
<span className="navbar-toggler-icon" />
|
<span className="navbar-toggler-icon" />
|
||||||
</button>
|
</button>
|
||||||
<div id="mainLayoutNavbar" className={`collapse navbar-collapse ${navOpen ? 'show' : ''}`}>
|
<div id="mainLayoutNavbar" className={`collapse navbar-collapse ${navOpen ? 'show' : ''}`}>
|
||||||
<nav className="ms-lg-auto me-lg-3 my-3 my-lg-0 main-layout-menu parent-navbar-menu" aria-label="Parent navigation">
|
<nav
|
||||||
{parentNavItems.map((item) => (
|
className="ms-lg-auto me-lg-2 my-2 my-lg-0 main-layout-menu"
|
||||||
|
aria-label={
|
||||||
|
isTeacherPortalRole(role)
|
||||||
|
? 'Teacher portal navigation'
|
||||||
|
: isParentPortalRole(role)
|
||||||
|
? 'Parent portal navigation'
|
||||||
|
: 'Portal navigation'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ul className="navbar-nav list-unstyled parent-navbar-menu mb-0">
|
||||||
|
{mainNavItems.map((item) => (
|
||||||
|
<li key={item.to} className="nav-item">
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) => `nav-link parent-navbar-link ${isActive ? 'active fw-semibold' : ''}`}
|
end={
|
||||||
|
item.to === '/app/teacher_dashboard' ||
|
||||||
|
item.to === '/app/parent/home' ||
|
||||||
|
item.to === '/app/student/score-card/list'
|
||||||
|
}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link parent-navbar-link ${isActive ? 'active fw-semibold' : ''}`
|
||||||
|
}
|
||||||
onClick={() => setNavOpen(false)}
|
onClick={() => setNavOpen(false)}
|
||||||
>
|
>
|
||||||
<i className={item.icon} aria-hidden />
|
<i className={item.icon} aria-hidden />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="d-flex align-items-center gap-2">
|
{user ? (
|
||||||
<span className="navbar-text small">{user?.name}</span>
|
<div
|
||||||
|
ref={clusterRef}
|
||||||
|
className="main-layout-user-cluster d-flex align-items-center ms-lg-2"
|
||||||
|
onMouseEnter={openUserMenu}
|
||||||
|
onMouseLeave={scheduleCloseUserMenu}
|
||||||
|
onFocusCapture={openUserMenu}
|
||||||
|
onBlurCapture={onClusterBlurCapture}
|
||||||
|
>
|
||||||
|
<div className="main-layout-user-avatar-wrap">
|
||||||
|
<button
|
||||||
|
ref={userMenuTriggerRef}
|
||||||
|
type="button"
|
||||||
|
className="main-layout-user-trigger"
|
||||||
|
id="main-layout-user-trigger"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={userMenuOpen}
|
||||||
|
aria-controls="main-layout-user-menu"
|
||||||
|
onClick={onAvatarClick}
|
||||||
|
>
|
||||||
|
<div className="main-layout-user-avatar rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center">
|
||||||
|
<span className="main-layout-user-initials">{initials}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{canSwitchRole ? (
|
||||||
|
<Link
|
||||||
|
to="/app/select-role"
|
||||||
|
state={{ from: location.pathname }}
|
||||||
|
className="main-layout-user-avatar-chip main-layout-user-avatar-chip--roles"
|
||||||
|
title="Switch role"
|
||||||
|
aria-label="Switch role"
|
||||||
|
onClick={closeMenus}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-left-right" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
to="/app/portal-style"
|
||||||
|
className="main-layout-user-avatar-chip main-layout-user-avatar-chip--style"
|
||||||
|
title="Style edit"
|
||||||
|
aria-label="Style edit"
|
||||||
|
onClick={closeMenus}
|
||||||
|
>
|
||||||
|
<i className="bi bi-palette" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="main-layout-user-menu"
|
||||||
|
role="region"
|
||||||
|
aria-labelledby="main-layout-user-trigger"
|
||||||
|
aria-hidden={!userMenuOpen}
|
||||||
|
className={`main-layout-user-dropdown card border-0 shadow-sm py-2 ${userMenuOpen ? 'is-open' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="px-3 pb-1">
|
||||||
|
<div className="main-layout-user-dropdown-header">Signed in as</div>
|
||||||
|
<div className="small text-dark text-truncate fw-semibold" title={user.name}>
|
||||||
|
{user.name}
|
||||||
|
</div>
|
||||||
|
{role ? (
|
||||||
|
<>
|
||||||
|
<div className="main-layout-user-dropdown-header mt-2">Role</div>
|
||||||
|
<div
|
||||||
|
className="small text-dark text-truncate fw-semibold main-layout-user-dropdown-role"
|
||||||
|
title={formatRoleLabel(role)}
|
||||||
|
>
|
||||||
|
{formatRoleLabel(role)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="dropdown-divider mx-2 my-1" />
|
||||||
|
{canSwitchRole ? (
|
||||||
|
<Link
|
||||||
|
className="dropdown-item d-flex align-items-center gap-2 py-2"
|
||||||
|
to="/app/select-role"
|
||||||
|
state={{ from: location.pathname }}
|
||||||
|
onClick={closeMenus}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-left-right" aria-hidden />
|
||||||
|
Switch role
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
className="dropdown-item d-flex align-items-center gap-2 py-2"
|
||||||
|
to="/app/portal-style"
|
||||||
|
onClick={closeMenus}
|
||||||
|
>
|
||||||
|
<i className="bi bi-palette" aria-hidden />
|
||||||
|
Style edit
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="dropdown-item d-flex align-items-center gap-2 py-2"
|
||||||
|
to="/app/account"
|
||||||
|
onClick={closeMenus}
|
||||||
|
>
|
||||||
|
<i className="bi bi-gear" aria-hidden />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<div className="dropdown-divider mx-2 my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-light btn-sm"
|
className="dropdown-item d-flex align-items-center gap-2 py-2 text-danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
closeMenus()
|
||||||
logout()
|
logout()
|
||||||
navigate('/login', { replace: true })
|
navigate('/login', { replace: true })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-box-arrow-right" aria-hidden />
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -81,12 +336,7 @@ export function MainLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer py-4 mt-auto border-top">
|
<CiPublicFooter />
|
||||||
<div className="container text-muted small d-flex flex-wrap justify-content-between gap-2">
|
|
||||||
<span>Al Rahma Sunday School</span>
|
|
||||||
<span>Parent and Teacher Portal</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
|
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
|
||||||
<div className="modal-dialog modal-xl modal-dialog-scrollable">
|
<div className="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { fetchNavMenu } from '../api/session'
|
import { fetchNavMenu } from '../api/session'
|
||||||
import type { NavItem } from '../api/types'
|
import type { NavItem } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
@@ -9,31 +9,55 @@ import {
|
|||||||
} from '../lib/ciSpaPaths'
|
} from '../lib/ciSpaPaths'
|
||||||
|
|
||||||
function normalizeNavItems(payload: unknown): NavItem[] {
|
function normalizeNavItems(payload: unknown): NavItem[] {
|
||||||
if (Array.isArray(payload)) {
|
const rawItems = Array.isArray(payload)
|
||||||
return payload as NavItem[]
|
? (payload as NavItem[])
|
||||||
}
|
: payload &&
|
||||||
if (
|
|
||||||
payload &&
|
|
||||||
typeof payload === 'object' &&
|
typeof payload === 'object' &&
|
||||||
'data' in payload &&
|
'data' in payload &&
|
||||||
Array.isArray((payload as { data: unknown }).data)
|
Array.isArray((payload as { data: unknown }).data)
|
||||||
) {
|
? ((payload as { data: NavItem[] }).data ?? [])
|
||||||
return (payload as { data: NavItem[] }).data
|
: []
|
||||||
}
|
|
||||||
return []
|
if (rawItems.length === 0) return []
|
||||||
|
if (rawItems.some((item) => Array.isArray(item.children) && item.children.length > 0)) {
|
||||||
|
return rawItems
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavTree({ items }: { items: NavItem[] }) {
|
const byId = new Map<number, NavItem>()
|
||||||
|
for (const item of rawItems) {
|
||||||
|
byId.set(item.id, { ...item, children: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree: NavItem[] = []
|
||||||
|
for (const item of byId.values()) {
|
||||||
|
const parentId = item.menu_parent_id ?? null
|
||||||
|
if (parentId != null && byId.has(parentId)) {
|
||||||
|
byId.get(parentId)?.children.push(item)
|
||||||
|
} else {
|
||||||
|
tree.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchContainsPath(item: NavItem, pathname: string): boolean {
|
||||||
|
const spa = item.url ? spaPathFromCiUrl(item.url) : null
|
||||||
|
if (spa && pathname.startsWith(spa)) return true
|
||||||
|
return (item.children ?? []).some((child) => branchContainsPath(child, pathname))
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavTree({ items, pathname }: { items: NavItem[]; pathname: string }) {
|
||||||
return (
|
return (
|
||||||
<ul className="nav flex-column gap-1 ps-0 mb-0 list-unstyled">
|
<ul className="nav flex-column gap-1 ps-0 mb-0 list-unstyled mgmt-nav-tree">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<NavBranch key={item.id} item={item} depth={0} />
|
<NavBranch key={item.id} item={item} depth={0} pathname={pathname} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
|
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
|
||||||
const enabled = item.is_enabled !== 0
|
const enabled = item.is_enabled !== 0
|
||||||
if (!enabled) return null
|
if (!enabled) return null
|
||||||
|
|
||||||
@@ -47,10 +71,26 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
|
|||||||
<i className={`${item.icon_class} me-2`} aria-hidden />
|
<i className={`${item.icon_class} me-2`} aria-hidden />
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
|
const children = (item.children ?? []).filter((c) => c.is_enabled !== 0)
|
||||||
|
const hasChildren = children.length > 0
|
||||||
|
const [open, setOpen] = useState(() => hasChildren && branchContainsPath(item, pathname))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasChildren && branchContainsPath(item, pathname)) {
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}, [hasChildren, item, pathname])
|
||||||
|
|
||||||
const linkInner = (
|
const linkInner = (
|
||||||
<>
|
<>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="nav-label-chip">{label}</span>
|
<span className="nav-label-chip">{label}</span>
|
||||||
|
{hasChildren ? (
|
||||||
|
<i
|
||||||
|
className={`bi ${open ? 'bi-chevron-down' : 'bi-chevron-right'} ms-auto`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +98,12 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
|
|||||||
`nav-link rounded py-2 px-2 ${isActive ? 'active fw-semibold' : ''}`
|
`nav-link rounded py-2 px-2 ${isActive ? 'active fw-semibold' : ''}`
|
||||||
|
|
||||||
let main: ReactNode
|
let main: ReactNode
|
||||||
|
const handleBranchToggle = () => {
|
||||||
|
if (hasChildren) {
|
||||||
|
setOpen((value) => !value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (external) {
|
if (external) {
|
||||||
main = (
|
main = (
|
||||||
<a
|
<a
|
||||||
@@ -66,33 +112,54 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
|
|||||||
target={item.target || '_self'}
|
target={item.target || '_self'}
|
||||||
rel={item.target === '_blank' ? 'noreferrer noopener' : undefined}
|
rel={item.target === '_blank' ? 'noreferrer noopener' : undefined}
|
||||||
style={{ paddingLeft: pad }}
|
style={{ paddingLeft: pad }}
|
||||||
|
onClick={hasChildren ? (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleBranchToggle()
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
{linkInner}
|
{linkInner}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
} else if (spa) {
|
} else if (spa && !hasChildren) {
|
||||||
main = (
|
main = (
|
||||||
<NavLink end to={spa} className={linkClass} style={{ paddingLeft: pad }}>
|
<NavLink end to={spa} className={linkClass} style={{ paddingLeft: pad }}>
|
||||||
{linkInner}
|
{linkInner}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
)
|
||||||
|
} else if (spa && hasChildren) {
|
||||||
|
main = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-link rounded py-2 px-2 w-100 text-start d-flex align-items-center"
|
||||||
|
style={{ paddingLeft: pad }}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={handleBranchToggle}
|
||||||
|
>
|
||||||
|
{linkInner}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
main = (
|
main = (
|
||||||
<span className="nav-link disabled" style={{ paddingLeft: pad }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-link rounded py-2 px-2 w-100 text-start d-flex align-items-center border-0 bg-transparent"
|
||||||
|
style={{ paddingLeft: pad }}
|
||||||
|
aria-expanded={hasChildren ? open : undefined}
|
||||||
|
onClick={hasChildren ? handleBranchToggle : undefined}
|
||||||
|
disabled={!hasChildren}
|
||||||
|
>
|
||||||
{linkInner}
|
{linkInner}
|
||||||
</span>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = (item.children ?? []).filter((c) => c.is_enabled !== 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li className={`mgmt-nav-branch mgmt-nav-depth-${Math.min(depth, 4)}`}>
|
||||||
{main}
|
{main}
|
||||||
{children.length > 0 ? (
|
{children.length > 0 && open ? (
|
||||||
<ul className="nav flex-column gap-1 ps-0 mb-0 mt-1 list-unstyled">
|
<ul className="nav flex-column gap-1 ps-0 mb-0 mt-1 list-unstyled mgmt-nav-subtree">
|
||||||
{children.map((ch) => (
|
{children.map((ch) => (
|
||||||
<NavBranch key={ch.id} item={ch} depth={depth + 1} />
|
<NavBranch key={ch.id} item={ch} depth={depth + 1} pathname={pathname} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -103,6 +170,7 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
|
|||||||
export function ManagementLayout() {
|
export function ManagementLayout() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const [items, setItems] = useState<NavItem[]>([])
|
const [items, setItems] = useState<NavItem[]>([])
|
||||||
const [menuError, setMenuError] = useState<string | null>(null)
|
const [menuError, setMenuError] = useState<string | null>(null)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
@@ -129,6 +197,8 @@ export function ManagementLayout() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
|
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
|
||||||
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
|
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
|
||||||
@@ -175,12 +245,15 @@ export function ManagementLayout() {
|
|||||||
<div className="wrapper flex-grow-1">
|
<div className="wrapper flex-grow-1">
|
||||||
<aside id="navbarManagement" className="mgmt-sidebar" data-mgmt-menu-mode="dark">
|
<aside id="navbarManagement" className="mgmt-sidebar" data-mgmt-menu-mode="dark">
|
||||||
<div className="mgmt-sidebar-content">
|
<div className="mgmt-sidebar-content">
|
||||||
<div className="px-2 py-2 small text-muted border-bottom mb-2">Navigation</div>
|
<div className="mgmt-sidebar-header px-2 py-2 mb-2">
|
||||||
|
<div className="mgmt-sidebar-eyebrow">Management</div>
|
||||||
|
<div className="mgmt-sidebar-title">Navigation</div>
|
||||||
|
</div>
|
||||||
<nav className="pb-3">
|
<nav className="pb-3">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/app/home"
|
to="/app/home"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`nav-link rounded mb-2 ${isActive ? 'active fw-semibold' : ''}`
|
`nav-link rounded mb-2 mgmt-static-link ${isActive ? 'active fw-semibold' : ''}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<i className="bi bi-house-door me-2" aria-hidden />
|
<i className="bi bi-house-door me-2" aria-hidden />
|
||||||
@@ -189,7 +262,7 @@ export function ManagementLayout() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
to="/app/browse"
|
to="/app/browse"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`nav-link rounded mb-2 ${isActive ? 'active fw-semibold' : ''}`
|
`nav-link rounded mb-2 mgmt-static-link ${isActive ? 'active fw-semibold' : ''}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<i className="bi bi-diagram-3 me-2" aria-hidden />
|
<i className="bi bi-diagram-3 me-2" aria-hidden />
|
||||||
@@ -198,7 +271,7 @@ export function ManagementLayout() {
|
|||||||
{menuError ? (
|
{menuError ? (
|
||||||
<div className="alert alert-warning small py-2">{menuError}</div>
|
<div className="alert alert-warning small py-2">{menuError}</div>
|
||||||
) : (
|
) : (
|
||||||
<NavTree items={items} />
|
<NavTree items={navTree} pathname={location.pathname} />
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* Public site chrome (green header) — logo + title */
|
||||||
|
.public-shell .public-navbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-shell .public-navbar-logo-wrap {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bitmap is clipped to a circle and fills the disc (same idea as `.main-layout-logo`). */
|
||||||
|
.public-shell .public-navbar-logo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.45);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import { CiPublicFooter } from '../components/CiPartials'
|
||||||
|
import './PublicLayout.css'
|
||||||
|
|
||||||
export function PublicLayout() {
|
export function PublicLayout() {
|
||||||
const { isAuthenticated, user, logout } = useAuth()
|
const { isAuthenticated, user, logout } = useAuth()
|
||||||
@@ -11,8 +13,16 @@ export function PublicLayout() {
|
|||||||
style={{ backgroundColor: '#2c9b09' }}
|
style={{ backgroundColor: '#2c9b09' }}
|
||||||
>
|
>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Link className="navbar-brand fw-semibold" to="/">
|
<Link className="navbar-brand fw-semibold public-navbar-brand" to="/">
|
||||||
Al Rahma Sunday School
|
<span className="public-navbar-logo-wrap" aria-hidden>
|
||||||
|
<img
|
||||||
|
src="/images/logo.png"
|
||||||
|
alt=""
|
||||||
|
className="public-navbar-logo"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>Al Rahma Sunday School</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="navbar-toggler"
|
className="navbar-toggler"
|
||||||
@@ -32,11 +42,13 @@ export function PublicLayout() {
|
|||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
{isAuthenticated ? (
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<Link className="nav-link" to="/docs">
|
<Link className="nav-link" to="/docs">
|
||||||
API docs
|
API docs
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
) : null}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
@@ -82,11 +94,7 @@ export function PublicLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-top bg-white py-4 mt-auto">
|
<CiPublicFooter />
|
||||||
<div className="container text-center text-muted small">
|
|
||||||
<p className="mb-0">Al Rahma Sunday School</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
/** Prefix for Laravel API when the SPA is served from another origin (production). Empty in dev with Vite proxy. */
|
const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laravel API base URL.
|
||||||
|
*
|
||||||
|
* **Vite dev:** same-origin `/api/...`; `vite.config.ts` proxies to the API (default `http://192.168.3.100:8000`).
|
||||||
|
* Override proxy target with **`VITE_PROXY_API`**. **`VITE_API_ORIGIN` is not used in dev** (avoids cross-origin from the browser).
|
||||||
|
*
|
||||||
|
* **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`.
|
||||||
|
*/
|
||||||
export function apiOrigin(): string {
|
export function apiOrigin(): string {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const v = import.meta.env.VITE_API_ORIGIN
|
const v = import.meta.env.VITE_API_ORIGIN
|
||||||
const fallback = 'https://api.alrahmaisgl.org'
|
if (typeof v === 'string' && v.trim()) {
|
||||||
return typeof v === 'string' && v.trim() ? v.replace(/\/$/, '') : fallback
|
return v.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
return PROD_API_ORIGIN
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apiUrl(path: string): string {
|
export function apiUrl(path: string): string {
|
||||||
const base = apiOrigin()
|
const base = apiOrigin()
|
||||||
const p = path.startsWith('/') ? path : `/${path}`
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
if (!base) return p
|
||||||
return `${base}${p}`
|
return `${base}${p}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,112 @@
|
|||||||
import { type CiRouteMetaEntry, ciRouteMeta } from './ciRouteMeta.generated'
|
import { type CiRouteMetaEntry, ciRouteMeta } from './ciRouteMeta.generated'
|
||||||
import { ciRouteManual } from './ciRouteManual'
|
import { ciRouteManual } from './ciRouteManual'
|
||||||
|
|
||||||
|
/** CI registry keys whose folder layout does not match the SPA route (see `App.tsx`). */
|
||||||
|
const CI_REGISTRY_APP_PATH_OVERRIDES: Record<string, string> = {
|
||||||
|
// Nested views map to flat `/app/teacher/competition-scores` (+ dynamic detail route).
|
||||||
|
'teacher/competition_scores/index': '/app/teacher/competition-scores',
|
||||||
|
'teacher/competition_scores/scores': '/app/teacher/competition-scores',
|
||||||
|
/** CI `landing_page/teacher_dashboard.php` → SPA (underscore segment per legacy URL). */
|
||||||
|
'landing_page/teacher_dashboard': '/app/teacher_dashboard',
|
||||||
|
'flags/flags_management': '/app/administrator/flags/management',
|
||||||
|
'flags/processed_flags': '/app/administrator/flags/processed',
|
||||||
|
'flags/incident_analysis': '/app/administrator/flags/incident-analysis',
|
||||||
|
'grading/grading_main': '/app/grading',
|
||||||
|
'grading/below_sixty': '/app/grading/below-60',
|
||||||
|
'grading/below_sixty_email_editor': '/app/grading/below-60/email-editor',
|
||||||
|
'grading/comments': '/app/grading/scores/comments',
|
||||||
|
'grading/final': '/app/grading/scores/final',
|
||||||
|
'grading/homework': '/app/grading/scores/homework',
|
||||||
|
'grading/homework_tracking': '/app/grading/homework-tracking',
|
||||||
|
'grading/midterm': '/app/grading/scores/midterm',
|
||||||
|
'grading/participation': '/app/grading/participation',
|
||||||
|
'grading/placement': '/app/grading/placement',
|
||||||
|
/** Batch id is in the URL segment in CI; dynamic route uses `spaPathFromCiUrl` / placement-index when no id. */
|
||||||
|
'grading/placement_batch': '/app/grading/placement-index',
|
||||||
|
'grading/placement_index': '/app/grading/placement-index',
|
||||||
|
'grading/project': '/app/grading/scores/project',
|
||||||
|
'grading/quiz': '/app/grading/scores/quiz',
|
||||||
|
'grading/schedule_meeting': '/app/grading/schedule-meeting',
|
||||||
|
'grading/test': '/app/grading/scores/test',
|
||||||
|
'inventory/book/index': '/app/inventory/book',
|
||||||
|
'inventory/book/form': '/app/inventory/book/create',
|
||||||
|
'inventory/classroom/index': '/app/administrator/inventory/classroom',
|
||||||
|
'inventory/classroom/form': '/app/administrator/inventory/classroom/create',
|
||||||
|
'inventory/classroom/audit_form': '/app/administrator/inventory/classroom',
|
||||||
|
'inventory/kitchen/index': '/app/administrator/inventory/kitchen',
|
||||||
|
'inventory/kitchen/form': '/app/administrator/inventory/kitchen/create',
|
||||||
|
'inventory/office/index': '/app/administrator/inventory/office',
|
||||||
|
'inventory/office/form': '/app/administrator/inventory/office/create',
|
||||||
|
'inventory/movements/index': '/app/administrator/inventory/movements',
|
||||||
|
'inventory/movements/form': '/app/administrator/inventory/movements/create',
|
||||||
|
'inventory/movements/edit': '/app/administrator/inventory/movements',
|
||||||
|
'inventory/summary': '/app/administrator/inventory/summary',
|
||||||
|
'inventory/adjust_form': '/app/administrator/inventory/summary',
|
||||||
|
'inventory/teacher_distribute': '/app/teacher/inventory/book-distribute',
|
||||||
|
'attendance/teacher_attendance_month': '/app/admin/teacher-attendance/month',
|
||||||
|
'attendance/violations_pending': '/app/attendance/violations/pending',
|
||||||
|
'attendance/violations_notified': '/app/attendance/violations/notified',
|
||||||
|
'attendance/early_dismissals': '/app/attendance/early-dismissals',
|
||||||
|
'attendance/early_dismissals_add': '/app/attendance/early-dismissals/new',
|
||||||
|
/** CI `enroll_withdraw/new-students.php` — registered students list (JWT `registered-new-students`). */
|
||||||
|
'enroll_withdraw/new-students': '/app/admin/enrollment/new-students',
|
||||||
|
'enroll_withdraw/new_students': '/app/admin/enrollment/new-students',
|
||||||
|
'administrator/sections_auto_distribute': '/app/administrator/sections/auto-distribute',
|
||||||
|
'administrator/sections_promotion_totals': '/app/administrator/sections/promotion-totals',
|
||||||
|
/** CI `administrator/events/event_list.php` — school calendar list (`GET .../school-calendar/events`). */
|
||||||
|
'administrator/events/event_list': '/app/administrator/events',
|
||||||
|
'administrator/events': '/app/administrator/events',
|
||||||
|
'invoice_payment/invoice_management': '/app/administrator/invoice-payment',
|
||||||
|
/** PDF is generated server-side; SPA preview lives under invoice-payment/pdf/:id */
|
||||||
|
'invoice_payment/pdf_template': '/app/administrator/invoice-payment',
|
||||||
|
'notifications/list_active': '/app/notifications/active',
|
||||||
|
'notifications/list_deleted': '/app/notifications/deleted',
|
||||||
|
'payment/manual_pay': '/app/administrator/payment/manual-pay',
|
||||||
|
'payment/manual_payment': '/app/administrator/payment/manual-payment',
|
||||||
|
'payment/notification_management': '/app/administrator/payment/notification-management',
|
||||||
|
'payment/unpaid_parents': '/app/payment/unpaid-parents',
|
||||||
|
'payment/discount': '/app/administrator/payment/discount-apply-student',
|
||||||
|
'payment/extra_charges': '/app/admin/charges',
|
||||||
|
'payment/financial_report': '/app/administrator/payment/financial-report',
|
||||||
|
'payment/financial_report_summary': '/app/administrator/payment/financial-report-summary',
|
||||||
|
'payment/payment_redirect': '/app/parent/payment-redirect',
|
||||||
|
'policy/school_policy': '/school-policy',
|
||||||
|
'policy/picture_policy': '/picture-policy',
|
||||||
|
'print_requests/admin_index': '/app/admin/print-requests',
|
||||||
|
'print_requests/teacher_index': '/app/teacher/print-requests',
|
||||||
|
'printables_reports/sticker_form': '/app/printables_reports/stickers',
|
||||||
|
'printables_reports/stickers': '/app/printables_reports/stickers',
|
||||||
|
'printables_reports/badge_form': '/app/administrator/printables/badges',
|
||||||
|
'printables_reports/report_card': '/app/administrator/printables/report-cards',
|
||||||
|
'reimbursements/index': '/app/administrator/reimbursements',
|
||||||
|
'reimbursements/create': '/app/administrator/reimbursements/create',
|
||||||
|
'reimbursements/edit': '/app/administrator/reimbursements',
|
||||||
|
'reimbursements/under_processing': '/app/administrator/reimbursements/under-processing',
|
||||||
|
'report/combined': '/app/report/combined',
|
||||||
|
'rfid/rfid_coming_soon': '/app/rfid_coming_soon',
|
||||||
|
'rolepermission/list_roles': '/app/rolepermission/roles',
|
||||||
|
'rolepermission/permissionList': '/app/rolepermission/list_permissions',
|
||||||
|
'rolepermission/list_permissions': '/app/rolepermission/list_permissions',
|
||||||
|
'rolepermission/add_permission': '/app/rolepermission/permissions/create',
|
||||||
|
'rolepermission/add_role': '/app/rolepermission/roles/create',
|
||||||
|
'rolepermission/assign_role': '/app/rolepermission/assign',
|
||||||
|
'rolepermission/edit_permission': '/app/rolepermission/list_permissions',
|
||||||
|
'rolepermission/edit_role': '/app/rolepermission/roles',
|
||||||
|
'rolepermission/edit_role_permissions': '/app/rolepermission/roles',
|
||||||
|
'score_analysis/score_prediction': '/app/administrator/score-analysis/score-prediction',
|
||||||
|
'slips/preview_list': '/app/slips/preview',
|
||||||
|
'slips/slip_pdf': '/app/slips/print',
|
||||||
|
'staff/index': '/app/staff',
|
||||||
|
'staff/create': '/app/staff/create',
|
||||||
|
'student/score_card_list': '/app/student/score-card/list',
|
||||||
|
/** Detail view is reached with an id segment in the URL; map the view key to the list entry route. */
|
||||||
|
'student/score_card': '/app/student/score-card/list',
|
||||||
|
'whatsapp/manage_links': '/app/whatsapp/manage-links',
|
||||||
|
'whatsapp/parent_contacts': '/app/whatsapp/parent-contacts',
|
||||||
|
'whatsapp/parent_contacts_by_class': '/app/whatsapp/parent-contacts-by-class',
|
||||||
|
'winners/competitions/index': '/app/winners/competitions',
|
||||||
|
}
|
||||||
|
|
||||||
/** Match CI `app/Views/...` keys: lowercase, hyphens in URL map to underscores. */
|
/** Match CI `app/Views/...` keys: lowercase, hyphens in URL map to underscores. */
|
||||||
export function getCiMeta(splat: string): CiRouteMetaEntry | null {
|
export function getCiMeta(splat: string): CiRouteMetaEntry | null {
|
||||||
const raw = splat
|
const raw = splat
|
||||||
@@ -16,6 +122,10 @@ export function getCiMeta(splat: string): CiRouteMetaEntry | null {
|
|||||||
|
|
||||||
/** Build canonical SPA path from a registry key (snake segments) using kebab-case segments. */
|
/** Build canonical SPA path from a registry key (snake segments) using kebab-case segments. */
|
||||||
export function ciRegistryKeyToAppPath(routeKey: string): string {
|
export function ciRegistryKeyToAppPath(routeKey: string): string {
|
||||||
|
const normalized = routeKey.trim().toLowerCase()
|
||||||
|
const override = CI_REGISTRY_APP_PATH_OVERRIDES[normalized]
|
||||||
|
if (override) return override
|
||||||
|
|
||||||
const parts = routeKey.split('/').map((seg) => seg.replace(/_/g, '-'))
|
const parts = routeKey.split('/').map((seg) => seg.replace(/_/g, '-'))
|
||||||
return `/app/${parts.join('/')}`
|
return `/app/${parts.join('/')}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,44 +9,130 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
|
|||||||
const path = u.replace(/^\/+/, '').replace(/\/+$/, '')
|
const path = u.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||||
if (!path) return '/app/home'
|
if (!path) return '/app/home'
|
||||||
|
|
||||||
const slug = path
|
const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : ''
|
||||||
|
const pathOnly = path.split('?')[0] ?? path
|
||||||
|
|
||||||
|
const slug = pathOnly
|
||||||
.split('/')
|
.split('/')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((s) => s.toLowerCase())
|
.map((s) => s.toLowerCase())
|
||||||
.join('/')
|
.join('/')
|
||||||
|
|
||||||
|
const competitionScoresDetail = /^teacher\/competition_scores\/scores\/(\d+)$/.exec(slug)
|
||||||
|
if (competitionScoresDetail) {
|
||||||
|
return `/app/teacher/competition-scores/${competitionScoresDetail[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const teacherMessage = /^teacher\/teacher_message\/(\d+)$/.exec(slug)
|
||||||
|
if (teacherMessage) {
|
||||||
|
return `/app/teacher/messages/${teacherMessage[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradingScore = /^grading\/(homework|quiz|project|test|midterm|final|comments)$/.exec(slug)
|
||||||
|
if (gradingScore) {
|
||||||
|
return `/app/grading/scores/${gradingScore[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const placementBatchDetail = /^grading\/placement_batch\/(\d+)$/.exec(slug)
|
||||||
|
if (placementBatchDetail) {
|
||||||
|
return `/app/grading/placement-batch/${placementBatchDetail[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryEdit = /^inventory\/edit\/(\d+)$/.exec(slug)
|
||||||
|
if (inventoryEdit) {
|
||||||
|
return `/app/administrator/inventory/edit/${inventoryEdit[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
const inventoryAudit = /^inventory\/classroom\/audit\/(\d+)$/.exec(slug)
|
||||||
|
if (inventoryAudit) {
|
||||||
|
return `/app/administrator/inventory/classroom/${inventoryAudit[1]}/audit${querySuffix}`
|
||||||
|
}
|
||||||
|
const inventoryAdjust = /^inventory\/adjust\/(\d+)$/.exec(slug)
|
||||||
|
if (inventoryAdjust) {
|
||||||
|
return `/app/administrator/inventory/items/${inventoryAdjust[1]}/adjust${querySuffix}`
|
||||||
|
}
|
||||||
|
const inventoryMovementEdit = /^inventory\/movements\/edit\/(\d+)$/.exec(slug)
|
||||||
|
if (inventoryMovementEdit) {
|
||||||
|
return `/app/administrator/inventory/movements/${inventoryMovementEdit[1]}/edit${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEdit = /^user\/edit\/(\d+)$/.exec(slug)
|
||||||
|
if (userEdit) {
|
||||||
|
return `/app/user/edit/${userEdit[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUserPw = /^user\/set_authorized_user_password\/(\d+)$/.exec(slug)
|
||||||
|
if (authUserPw) {
|
||||||
|
return `/set-authorized-user-password/${authUserPw[1]}${querySuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
const shortcuts: Record<string, string> = {
|
const shortcuts: Record<string, string> = {
|
||||||
'landing_page/guest_dashboard': '/app/home',
|
'landing_page/guest_dashboard': '/app/home',
|
||||||
'landing_page/parent_dashboard': '/app/parent/home',
|
'landing_page/parent_dashboard': '/app/parent/home',
|
||||||
'teacher/dashboard': '/app/teacher/dashboard',
|
'landing_page/teacher_dashboard': '/app/teacher_dashboard',
|
||||||
|
'teacher/dashboard': '/app/teacher_dashboard',
|
||||||
|
'teacher/no_classes': '/app/teacher/no-classes',
|
||||||
|
'teacher/select_semester': '/app/teacher/select-semester',
|
||||||
|
'teacher/trash': '/app/teacher/trash',
|
||||||
|
'teacher/inbox': '/app/teacher/inbox',
|
||||||
|
'teacher/sent': '/app/teacher/sent',
|
||||||
|
'teacher/competition_scores/index': '/app/teacher/competition-scores',
|
||||||
|
'teacher/absence_vacation': '/app/teacher/absence-vacation',
|
||||||
|
'teacher/add_final_exam': '/app/teacher/add-final-exam',
|
||||||
|
'teacher/add_homework': '/app/teacher/add-homework',
|
||||||
|
'teacher/add_midterm_exam': '/app/teacher/add-midterm-exam',
|
||||||
|
'teacher/add_participation': '/app/teacher/add-participation',
|
||||||
|
'teacher/add_project': '/app/teacher/add-project',
|
||||||
|
'teacher/add_quiz': '/app/teacher/add-quiz',
|
||||||
|
'teacher/add_quiz_column_form': '/app/teacher/add-quiz-column-form',
|
||||||
|
'teacher/calendar': '/app/teacher/calendar',
|
||||||
|
'teacher/class_progress_history': '/app/teacher/class-progress-history',
|
||||||
|
'teacher/class_progress_submit': '/app/teacher/class-progress-submit',
|
||||||
|
'teacher/class_progress_view': '/app/teacher/class-progress-view',
|
||||||
|
'teacher/class_view': '/app/teacher/class-view',
|
||||||
|
'teacher/drafts': '/app/teacher/drafts',
|
||||||
|
'teacher/exam_drafts': '/app/teacher/exam-drafts',
|
||||||
|
'teacher/homework_list': '/app/teacher/homework-list',
|
||||||
|
'teacher/scores': '/app/teacher/scores',
|
||||||
|
'teacher/showupdate_attendance': '/app/teacher/showupdate-attendance',
|
||||||
|
'teacher/teacher_assignment': '/app/teacher/teacher-assignment',
|
||||||
|
'teacher/teacher_contactus': '/app/teacher/teacher-contactus',
|
||||||
|
'teacher/teacher_support': '/app/teacher/teacher-support',
|
||||||
|
'teacher/teacher_message': '/app/teacher/inbox',
|
||||||
|
'flags/flags_management': '/app/administrator/flags/management',
|
||||||
|
'flags/processed_flags': '/app/administrator/flags/processed',
|
||||||
|
'flags/incident_analysis': '/app/administrator/flags/incident-analysis',
|
||||||
'administrator/dashboard': '/app/administrator/dashboard',
|
'administrator/dashboard': '/app/administrator/dashboard',
|
||||||
'administrator/administratordashboard': '/app/admin/dashboard',
|
'administrator/administratordashboard': '/app/admin/dashboard',
|
||||||
|
'administrator/absence': '/app/administrator/absence',
|
||||||
'administrator/daily_attendance': '/app/administrator/daily_attendance',
|
'administrator/daily_attendance': '/app/administrator/daily_attendance',
|
||||||
'administrator/daily_attendance_analysis': '/app/administrator/daily_attendance_analysis',
|
'administrator/daily_attendance_analysis': '/app/administrator/daily_attendance_analysis',
|
||||||
'administrator/exam_drafts': '/app/administrator/exam_drafts',
|
'administrator/exam_drafts': '/app/administrator/exam-drafts',
|
||||||
'administrator/grading_management': '/app/administrator/grading_management',
|
'administrator/grading_management': '/app/administrator/grading_management',
|
||||||
'administrator/ip_bans': '/app/administrator/ip_bans',
|
'administrator/ip_bans': '/app/administrator/ip_bans',
|
||||||
'administrator/late_slip_logs': '/app/administrator/late_slip_logs',
|
'administrator/late_slip_logs': '/app/administrator/late_slip_logs',
|
||||||
'administrator/manage_users': '/app/administrator/manage_users',
|
'administrator/manage_users': '/app/administrator/manage_users',
|
||||||
'administrator/notifications_alerts': '/app/administrator/notifications_alerts',
|
'administrator/notifications_alerts': '/app/administrator/notifications_alerts',
|
||||||
'administrator/parent_profile': '/app/administrator/parent_profile',
|
'administrator/parent_profile': '/app/administrator/parent_profiles',
|
||||||
'administrator/paypal_transactions': '/app/administrator/paypal_transactions',
|
'administrator/paypal_transactions': '/app/administrator/paypal_transactions',
|
||||||
'administrator/print_notification_admins': '/app/administrator/print_notification_admins',
|
'administrator/print_notification_admins': '/app/administrator/print_notification_admins',
|
||||||
'administrator/removed_students': '/app/administrator/removed_students',
|
'administrator/removed_students': '/app/administrator/removed_students',
|
||||||
'administrator/search_results': '/app/administrator/search_results',
|
'administrator/search_results': '/app/administrator/search_results',
|
||||||
'administrator/sections_auto_distribute': '/app/administrator/sections_auto_distribute',
|
'administrator/sections_auto_distribute': '/app/administrator/sections/auto-distribute',
|
||||||
|
'administrator/sections_promotion_totals': '/app/administrator/sections/promotion-totals',
|
||||||
'administrator/student_class_assignment': '/app/administrator/student_class_assignment',
|
'administrator/student_class_assignment': '/app/administrator/student_class_assignment',
|
||||||
'administrator/student_profiles': '/app/administrator/student_profiles',
|
'administrator/student_profiles': '/app/administrator/student_profiles',
|
||||||
'administrator/subject_curriculum': '/app/administrator/subject_curriculum',
|
'administrator/subject_curriculum': '/app/administrator/subject-curriculum',
|
||||||
'administrator/teacher_class_assignment': '/app/administrator/teacher_class_assignment',
|
'administrator/teacher_class_assignment': '/app/administrator/teacher_class_assignment',
|
||||||
'administrator/teacher_submissions': '/app/administrator/teacher_submissions',
|
'administrator/teacher_submissions': '/app/administrator/teacher-submissions',
|
||||||
'admin/competition_winners': '/app/admin/competition-winners',
|
'administrator/events/event_list': '/app/administrator/events',
|
||||||
'admin/competition_winners/index': '/app/admin/competition-winners',
|
'administrator/events': '/app/administrator/events',
|
||||||
'admin/competition_winners/form': '/app/admin/competition-winners/create',
|
'admin/competition_winners': '/app/competition-winners',
|
||||||
'admin/competition_winners/preview': '/app/admin/competition-winners',
|
'admin/competition_winners/index': '/app/competition-winners',
|
||||||
'admin/competition_winners/scores': '/app/admin/competition-winners',
|
'admin/competition_winners/form': '/app/competition-winners/create',
|
||||||
'admin/competition_winners/winners': '/app/admin/competition-winners',
|
'admin/competition_winners/preview': '/app/competition-winners',
|
||||||
|
'admin/competition_winners/scores': '/app/competition-winners',
|
||||||
|
'admin/competition_winners/winners': '/app/competition-winners',
|
||||||
'admin/class_progress_list': '/app/admin/progress',
|
'admin/class_progress_list': '/app/admin/progress',
|
||||||
'admin/class_progress_view': '/app/admin/progress',
|
'admin/class_progress_view': '/app/admin/progress',
|
||||||
'administrator/student_score_card': '/app/administrator/student-score-card',
|
'administrator/student_score_card': '/app/administrator/student-score-card',
|
||||||
@@ -54,6 +140,8 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
|
|||||||
'administrator/emergency_contact/index': '/app/administrator/emergency-contacts',
|
'administrator/emergency_contact/index': '/app/administrator/emergency-contacts',
|
||||||
'administrator/emergency_contact/edit': '/app/administrator/emergency-contacts',
|
'administrator/emergency_contact/edit': '/app/administrator/emergency-contacts',
|
||||||
'nav-builder': '/app/nav-builder',
|
'nav-builder': '/app/nav-builder',
|
||||||
|
'nav-builder/index': '/app/nav-builder/index',
|
||||||
|
'nav-builder/edit': '/app/nav-builder/edit',
|
||||||
'api/nav-builder': '/app/nav-builder',
|
'api/nav-builder': '/app/nav-builder',
|
||||||
'parent/parent_dashboard': '/app/parent/home',
|
'parent/parent_dashboard': '/app/parent/home',
|
||||||
parent_dashboard: '/app/parent/home',
|
parent_dashboard: '/app/parent/home',
|
||||||
@@ -88,9 +176,115 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
|
|||||||
'parent/emergency-contacts': '/app/parent/emergency-contacts',
|
'parent/emergency-contacts': '/app/parent/emergency-contacts',
|
||||||
'parent/edit_all_students': '/app/parent/edit-all-students',
|
'parent/edit_all_students': '/app/parent/edit-all-students',
|
||||||
'parent/edit-all-students': '/app/parent/edit-all-students',
|
'parent/edit-all-students': '/app/parent/edit-all-students',
|
||||||
|
'class-prep': '/app/administrator/class-prep',
|
||||||
|
communications: '/app/administrator/communications',
|
||||||
|
'communications/index': '/app/administrator/communications',
|
||||||
|
'configuration/configuration_view': '/app/administrator/configuration_view',
|
||||||
|
'discounts/list': '/app/administrator/discounts',
|
||||||
|
'discount/create': '/app/administrator/discounts/create',
|
||||||
|
'discount/apply': '/app/administrator/discounts/apply',
|
||||||
|
'errors/invalid_token': '/invalid-token',
|
||||||
|
'errors/access_denied': '/access-denied',
|
||||||
|
'discounts/reverse_discount': '/app/administrator/discounts/reverse',
|
||||||
|
'expenses/index': '/app/administrator/expenses',
|
||||||
|
'expenses/create': '/app/administrator/expenses/create',
|
||||||
|
family: '/app/administrator/family',
|
||||||
|
'family/index': '/app/administrator/family',
|
||||||
|
'family/compose-email': '/app/administrator/family/compose-email',
|
||||||
|
'families/import-legacy': '/app/families/import-legacy',
|
||||||
|
'administrator/expense_management': '/app/administrator/expenses',
|
||||||
|
'enroll_withdraw/new-students': '/app/admin/enrollment/new-students',
|
||||||
|
'enroll_withdraw/enrollment_withdrawal': '/app/administrator/enroll-withdraw/enrollment-withdrawal',
|
||||||
|
'emails/parent_email_extractor': '/app/administrator/parent-email-extractor',
|
||||||
|
'emails/broadcast': '/app/administrator/email-templates',
|
||||||
|
'grading/grading_main': '/app/grading',
|
||||||
|
'grading/below_sixty': '/app/grading/below-60',
|
||||||
|
'grading/below_sixty_email_editor': '/app/grading/below-60/email-editor',
|
||||||
|
'grading/homework_tracking': '/app/grading/homework-tracking',
|
||||||
|
'grading/participation': '/app/grading/participation',
|
||||||
|
'grading/placement_index': '/app/grading/placement-index',
|
||||||
|
'grading/placement': '/app/grading/placement',
|
||||||
|
'grading/schedule_meeting': '/app/grading/schedule-meeting',
|
||||||
|
'inventory/book/index': '/app/inventory/book',
|
||||||
|
'inventory/book/form': '/app/inventory/book/create',
|
||||||
|
'inventory/classroom/index': '/app/administrator/inventory/classroom',
|
||||||
|
'inventory/classroom/form': '/app/administrator/inventory/classroom/create',
|
||||||
|
'inventory/classroom/audit_form': '/app/administrator/inventory/classroom',
|
||||||
|
'inventory/kitchen/index': '/app/administrator/inventory/kitchen',
|
||||||
|
'inventory/kitchen/form': '/app/administrator/inventory/kitchen/create',
|
||||||
|
'inventory/office/index': '/app/administrator/inventory/office',
|
||||||
|
'inventory/office/form': '/app/administrator/inventory/office/create',
|
||||||
|
'inventory/movements/index': '/app/administrator/inventory/movements',
|
||||||
|
'inventory/movements/form': '/app/administrator/inventory/movements/create',
|
||||||
|
'inventory/movements/edit': '/app/administrator/inventory/movements',
|
||||||
|
'inventory/summary': '/app/administrator/inventory/summary',
|
||||||
|
'inventory/adjust_form': '/app/administrator/inventory/summary',
|
||||||
|
'inventory/teacher_distribute': '/app/teacher/inventory/book-distribute',
|
||||||
|
'inventory/books/distribute': '/app/teacher/inventory/book-distribute',
|
||||||
|
'attendance/teacher_attendance_month': '/app/admin/teacher-attendance/month',
|
||||||
|
'attendance/violations_pending': '/app/attendance/violations/pending',
|
||||||
|
'attendance/violations_notified': '/app/attendance/violations/notified',
|
||||||
|
'attendance/early_dismissals': '/app/attendance/early-dismissals',
|
||||||
|
'attendance/early_dismissals_add': '/app/attendance/early-dismissals/new',
|
||||||
|
'invoice_payment/invoice_management': '/app/administrator/invoice-payment',
|
||||||
|
'invoice_payment/pdf_template': '/app/administrator/invoice-payment',
|
||||||
|
'notifications/list_active': '/app/notifications/active',
|
||||||
|
'notifications/list_deleted': '/app/notifications/deleted',
|
||||||
|
'payment/manual_pay': '/app/administrator/payment/manual-pay',
|
||||||
|
'payment/manual_payment': '/app/administrator/payment/manual-payment',
|
||||||
|
'payment/notification_management': '/app/administrator/payment/notification-management',
|
||||||
|
'payment/unpaid_parents': '/app/payment/unpaid-parents',
|
||||||
|
'payment/discount': '/app/administrator/payment/discount-apply-student',
|
||||||
|
'payment/extra_charges': '/app/admin/charges',
|
||||||
|
'payment/financial_report': '/app/administrator/payment/financial-report',
|
||||||
|
'payment/financial_report_summary': '/app/administrator/payment/financial-report-summary',
|
||||||
|
'payment/payment_redirect': '/app/parent/payment-redirect',
|
||||||
|
'policy/school_policy': '/school-policy',
|
||||||
|
'policy/picture_policy': '/picture-policy',
|
||||||
|
'print_requests/admin_index': '/app/admin/print-requests',
|
||||||
|
'print_requests/teacher_index': '/app/teacher/print-requests',
|
||||||
|
'printables_reports/sticker_form': '/app/printables_reports/stickers',
|
||||||
|
'printables_reports/stickers': '/app/printables_reports/stickers',
|
||||||
|
'printables_reports/badge_form': '/app/administrator/printables/badges',
|
||||||
|
'printables_reports/report_card': '/app/administrator/printables/report-cards',
|
||||||
|
'reimbursements/index': '/app/administrator/reimbursements',
|
||||||
|
'reimbursements/create': '/app/administrator/reimbursements/create',
|
||||||
|
'reimbursements/under_processing': '/app/administrator/reimbursements/under-processing',
|
||||||
|
'refunds/list': '/app/refunds/list',
|
||||||
|
'report/combined': '/app/report/combined',
|
||||||
|
'rfid/rfid_coming_soon': '/app/rfid_coming_soon',
|
||||||
|
'rolepermission/list_roles': '/app/rolepermission/roles',
|
||||||
|
'rolepermission/permissionList': '/app/rolepermission/list_permissions',
|
||||||
|
'rolepermission/list_permissions': '/app/rolepermission/list_permissions',
|
||||||
|
'rolepermission/add_permission': '/app/rolepermission/permissions/create',
|
||||||
|
'rolepermission/add_role': '/app/rolepermission/roles/create',
|
||||||
|
'rolepermission/assign_role': '/app/rolepermission/assign',
|
||||||
|
'score_analysis/score_prediction': '/app/administrator/score-analysis/score-prediction',
|
||||||
|
'slips/preview_list': '/app/slips/preview',
|
||||||
|
'slips/slip_pdf': '/app/slips/print',
|
||||||
|
'staff/index': '/app/staff',
|
||||||
|
'staff/create': '/app/staff/create',
|
||||||
|
'student/score_card_list': '/app/student/score-card/list',
|
||||||
|
'student/score_card': '/app/student/score-card/list',
|
||||||
|
'whatsapp/manage_links': '/app/whatsapp/manage-links',
|
||||||
|
'whatsapp/parent_contacts': '/app/whatsapp/parent-contacts',
|
||||||
|
'whatsapp/parent_contacts_by_class': '/app/whatsapp/parent-contacts-by-class',
|
||||||
|
'winners/competitions/index': '/app/winners/competitions',
|
||||||
|
'user/welcome_back': '/app/user/welcome-back',
|
||||||
|
'user/login_activity': '/app/user/login-activity',
|
||||||
|
'user/user_list': '/app/user/user-list',
|
||||||
|
'user/create': '/app/user/create',
|
||||||
|
'user/forgot_password': '/forgot-password',
|
||||||
|
'user/reset_password': '/reset-password',
|
||||||
|
'user/set_password': '/set-password',
|
||||||
|
'user/password_set_success': '/password-set-success',
|
||||||
|
'user/login': '/login',
|
||||||
|
'user/register': '/register',
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortcuts[slug] ?? `/app/${slug}`
|
const mapped = shortcuts[slug]
|
||||||
|
if (mapped) return `${mapped}${querySuffix}`
|
||||||
|
return `/app/${slug}${querySuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isExternalNavUrl(url: string | null | undefined): boolean {
|
export function isExternalNavUrl(url: string | null | undefined): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/** Parent / teacher portal look & feel (localStorage + `MainLayout` data-* hooks). */
|
||||||
|
|
||||||
|
export type PortalDensity = 'comfortable' | 'compact'
|
||||||
|
export type PortalFontScale = 'sm' | 'normal' | 'large' | 'xl'
|
||||||
|
export type PortalFontFamily = 'default' | 'system' | 'readable' | 'modern'
|
||||||
|
|
||||||
|
export const PORTAL_ACCENT_VALUES = [
|
||||||
|
'default',
|
||||||
|
'ocean',
|
||||||
|
'slate',
|
||||||
|
'violet',
|
||||||
|
'earth',
|
||||||
|
'rose',
|
||||||
|
'indigo',
|
||||||
|
'sky',
|
||||||
|
'crimson',
|
||||||
|
'emerald',
|
||||||
|
] as const
|
||||||
|
export type PortalAccent = (typeof PORTAL_ACCENT_VALUES)[number]
|
||||||
|
/** `system` follows the device light/dark preference. */
|
||||||
|
export type PortalColorMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
export type PortalStylePrefs = {
|
||||||
|
density: PortalDensity
|
||||||
|
fontScale: PortalFontScale
|
||||||
|
fontFamily: PortalFontFamily
|
||||||
|
accent: PortalAccent
|
||||||
|
colorMode: PortalColorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_DENSITY = 'alrahma_portal_density'
|
||||||
|
const STORAGE_FONT_SCALE = 'alrahma_portal_font_scale'
|
||||||
|
/** Legacy key: `normal` | `large` — migrated into `fontScale` */
|
||||||
|
const LEGACY_FONT_FLAG = 'alrahma_portal_font'
|
||||||
|
const STORAGE_FONT_FAMILY = 'alrahma_portal_font_family'
|
||||||
|
const STORAGE_ACCENT = 'alrahma_portal_accent'
|
||||||
|
const STORAGE_COLOR_MODE = 'alrahma_portal_color_mode'
|
||||||
|
|
||||||
|
function parseFontScale(raw: string | null): PortalFontScale {
|
||||||
|
if (raw === 'sm' || raw === 'normal' || raw === 'large' || raw === 'xl') return raw
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFontFamily(raw: string | null): PortalFontFamily {
|
||||||
|
if (raw === 'system' || raw === 'readable' || raw === 'modern') return raw
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccent(raw: string | null): PortalAccent {
|
||||||
|
if (raw && (PORTAL_ACCENT_VALUES as readonly string[]).includes(raw)) return raw as PortalAccent
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorMode(raw: string | null): PortalColorMode {
|
||||||
|
if (raw === 'dark' || raw === 'system') return raw
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPortalStylePrefs(): PortalStylePrefs {
|
||||||
|
const d = localStorage.getItem(STORAGE_DENSITY)
|
||||||
|
let fontScale = parseFontScale(localStorage.getItem(STORAGE_FONT_SCALE))
|
||||||
|
if (!localStorage.getItem(STORAGE_FONT_SCALE)) {
|
||||||
|
const legacy = localStorage.getItem(LEGACY_FONT_FLAG)
|
||||||
|
if (legacy === 'large') fontScale = 'large'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
density: d === 'compact' ? 'compact' : 'comfortable',
|
||||||
|
fontScale,
|
||||||
|
fontFamily: parseFontFamily(localStorage.getItem(STORAGE_FONT_FAMILY)),
|
||||||
|
accent: parseAccent(localStorage.getItem(STORAGE_ACCENT)),
|
||||||
|
colorMode: parseColorMode(localStorage.getItem(STORAGE_COLOR_MODE)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePortalStylePrefs(prefs: PortalStylePrefs): void {
|
||||||
|
localStorage.setItem(STORAGE_DENSITY, prefs.density)
|
||||||
|
localStorage.setItem(STORAGE_FONT_SCALE, prefs.fontScale)
|
||||||
|
localStorage.setItem(STORAGE_FONT_FAMILY, prefs.fontFamily)
|
||||||
|
localStorage.setItem(STORAGE_ACCENT, prefs.accent)
|
||||||
|
localStorage.setItem(STORAGE_COLOR_MODE, prefs.colorMode)
|
||||||
|
localStorage.removeItem(LEGACY_FONT_FLAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve stored `colorMode` to `light` | `dark`.
|
||||||
|
* When `colorMode` is `system`, pass `osPrefersDark` from a `matchMedia` listener, or omit to read once.
|
||||||
|
*/
|
||||||
|
export function resolvePortalColorMode(
|
||||||
|
prefs: PortalStylePrefs,
|
||||||
|
osPrefersDark?: boolean,
|
||||||
|
): 'light' | 'dark' {
|
||||||
|
if (prefs.colorMode === 'dark') return 'dark'
|
||||||
|
if (prefs.colorMode === 'light') return 'light'
|
||||||
|
if (typeof osPrefersDark === 'boolean') return osPrefersDark ? 'dark' : 'light'
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return 'light'
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Normalize API role keys for comparisons (handles casing and spaces). */
|
||||||
|
export function normalizePortalRole(role: string): string {
|
||||||
|
return role.trim().toLowerCase().replace(/\s+/g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTeacherPortalRole(role: string): boolean {
|
||||||
|
const r = normalizePortalRole(role)
|
||||||
|
return r === 'teacher' || r === 'teacher_assistant'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isParentPortalRole(role: string): boolean {
|
||||||
|
return normalizePortalRole(role) === 'parent'
|
||||||
|
}
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
/**
|
||||||
|
* Barrel: class progress + student score card live under `pages/admin/`.
|
||||||
|
* Emergency contact admin screens remain here.
|
||||||
|
*/
|
||||||
|
export { ClassProgressListPage as AdminClassProgressListPage } from './classProgress/ClassProgressListPage'
|
||||||
|
export { ClassProgressViewPage as AdminClassProgressViewPage } from './classProgress/ClassProgressViewPage'
|
||||||
|
export { AdminStudentScoreCardPage } from './admin/AdminStudentScoreCardPage'
|
||||||
|
|
||||||
|
import { type FormEvent, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
deleteAdministratorEmergencyContact,
|
deleteAdministratorEmergencyContact,
|
||||||
fetchAdministratorEmergencyContact,
|
fetchAdministratorEmergencyContact,
|
||||||
fetchAdministratorEmergencyContacts,
|
fetchAdministratorEmergencyContacts,
|
||||||
fetchClassProgressDetail,
|
|
||||||
fetchClassProgressGroups,
|
|
||||||
fetchClassProgressMeta,
|
|
||||||
fetchClassSections,
|
|
||||||
fetchStudentScoreCard,
|
|
||||||
searchAdministratorDashboard,
|
|
||||||
updateAdministratorEmergencyContact,
|
updateAdministratorEmergencyContact,
|
||||||
} from '../api/session'
|
} from '../api/session'
|
||||||
import type {
|
import type { EmergencyContactGroupRow } from '../api/types'
|
||||||
AdministratorDashboardSearchResponse,
|
|
||||||
ClassProgressGroupRow,
|
|
||||||
ClassProgressReportRow,
|
|
||||||
EmergencyContactGroupRow,
|
|
||||||
StudentScoreCardRow,
|
|
||||||
} from '../api/types'
|
|
||||||
|
|
||||||
function PageShell({ title, children }: { title: string; children: ReactNode }) {
|
function PageShell({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -31,476 +27,6 @@ function PageShell({ title, children }: { title: string; children: ReactNode })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value?: string | null, withTime = false) {
|
|
||||||
if (!value) return '—'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return value
|
|
||||||
return withTime ? date.toLocaleString() : date.toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitUnitTitle(unitTitle?: string | null) {
|
|
||||||
const raw = String(unitTitle ?? '').trim()
|
|
||||||
if (!raw) return { curriculum: [] as string[], custom: [] as string[] }
|
|
||||||
const segments = raw
|
|
||||||
.split(/\s*[;|]\s*/)
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
const curriculum: string[] = []
|
|
||||||
const custom: string[] = []
|
|
||||||
for (const segment of segments) {
|
|
||||||
if (/custom/i.test(segment)) custom.push(segment.replace(/^custom[:\s-]*/i, '').trim() || segment)
|
|
||||||
else curriculum.push(segment)
|
|
||||||
}
|
|
||||||
if (curriculum.length === 0 && custom.length === 0) curriculum.push(raw)
|
|
||||||
return { curriculum, custom }
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClassProgressUnitDisplay({
|
|
||||||
unitTitle,
|
|
||||||
isQuran,
|
|
||||||
compact,
|
|
||||||
}: {
|
|
||||||
unitTitle?: string | null
|
|
||||||
isQuran?: boolean
|
|
||||||
compact?: boolean
|
|
||||||
}) {
|
|
||||||
const { curriculum, custom } = splitUnitTitle(unitTitle)
|
|
||||||
const labelCurriculum = isQuran ? 'Surah / curriculum' : 'Unit / chapter'
|
|
||||||
const labelCustom = isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)'
|
|
||||||
|
|
||||||
if (!String(unitTitle ?? '').trim()) return <span className="text-muted">-</span>
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{custom.length > 0 ? (
|
|
||||||
<div className="small">
|
|
||||||
<span className="badge text-bg-info me-1">{isQuran ? 'Custom' : 'Custom subject'}</span>
|
|
||||||
{custom.join(', ')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{curriculum.length > 0 ? (
|
|
||||||
<div className={`small text-muted${custom.length > 0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{curriculum.length > 0 ? <div className="mb-2"><strong>{labelCurriculum}:</strong> {curriculum.join(' ; ')}</div> : null}
|
|
||||||
{custom.length > 0 ? <div className="mb-2"><strong>{labelCustom}:</strong> {custom.join(' ; ')}</div> : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminClassProgressListPage() {
|
|
||||||
const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
|
|
||||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
|
||||||
const [statusOptions, setStatusOptions] = useState<Record<string, string>>({})
|
|
||||||
const [classSections, setClassSections] = useState<Array<{ class_section_id?: number | null; class_section_name?: string | null }>>([])
|
|
||||||
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const [meta, sections, groups] = await Promise.all([
|
|
||||||
fetchClassProgressMeta(),
|
|
||||||
fetchClassSections({ perPage: 200 }),
|
|
||||||
fetchClassProgressGroups({
|
|
||||||
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
|
||||||
status: filters.status || null,
|
|
||||||
weekStart: filters.weekStart || null,
|
|
||||||
weekEnd: filters.weekEnd || null,
|
|
||||||
perPage: 200,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
|
||||||
setStatusOptions(meta.data?.status_options ?? {})
|
|
||||||
setClassSections(sections.data?.sections ?? [])
|
|
||||||
setItems(groups.data?.items ?? [])
|
|
||||||
setError(null)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Unable to load class progress.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const itemsBySection = useMemo(() => {
|
|
||||||
const map = new Map<string, ClassProgressGroupRow[]>()
|
|
||||||
for (const item of items) {
|
|
||||||
const key = String(item.class_section_id ?? '0')
|
|
||||||
const bucket = map.get(key) ?? []
|
|
||||||
bucket.push(item)
|
|
||||||
map.set(key, bucket)
|
|
||||||
}
|
|
||||||
return Array.from(map.entries())
|
|
||||||
}, [items])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Class Progress Reports">
|
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
||||||
<div className="d-flex align-items-center justify-content-between mb-4">
|
|
||||||
<div className="text-muted">Filter by week, class, and status</div>
|
|
||||||
<Link className="btn btn-sm btn-outline-warning" to="/app/administrator/teacher_submissions">
|
|
||||||
Teachers < 50%
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<form className="card shadow-sm mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-md-3"><label className="form-label">From</label><input type="date" className="form-control" value={filters.weekStart} onChange={(e) => setFilters((current) => ({ ...current, weekStart: e.target.value }))} /></div>
|
|
||||||
<div className="col-md-3"><label className="form-label">To</label><input type="date" className="form-control" value={filters.weekEnd} onChange={(e) => setFilters((current) => ({ ...current, weekEnd: e.target.value }))} /></div>
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Class/Section</label>
|
|
||||||
<select className="form-select" value={filters.classSectionId} onChange={(e) => setFilters((current) => ({ ...current, classSectionId: e.target.value }))}>
|
|
||||||
<option value="">All</option>
|
|
||||||
{classSections.map((section) => <option key={String(section.class_section_id ?? '')} value={section.class_section_id ?? ''}>{section.class_section_name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label className="form-label">Status</label>
|
|
||||||
<select className="form-select" value={filters.status} onChange={(e) => setFilters((current) => ({ ...current, status: e.target.value }))}>
|
|
||||||
<option value="">All</option>
|
|
||||||
{Object.entries(statusOptions).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex gap-2 mt-3">
|
|
||||||
<button className="btn btn-primary" type="submit">Apply</button>
|
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={() => { setFilters({ weekStart: '', weekEnd: '', classSectionId: '', status: '' }); void setTimeout(load, 0) }}>Reset</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="card shadow-sm mt-4">
|
|
||||||
<div className="card-body pt-4">
|
|
||||||
{itemsBySection.length === 0 ? (
|
|
||||||
<div className="text-center text-muted py-4">No reports found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="accordion" id="adminProgressAccordion">
|
|
||||||
{itemsBySection.map(([sectionId, groups]) => (
|
|
||||||
<div className="accordion-item mb-2" key={sectionId}>
|
|
||||||
<h2 className="accordion-header" id={`progress-heading-${sectionId}`}>
|
|
||||||
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target={`#progress-section-${sectionId}`}>
|
|
||||||
{groups[0]?.class_section_name ?? `Section ${sectionId}`}
|
|
||||||
<span className="badge bg-secondary ms-2">{groups.length} weeks</span>
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id={`progress-section-${sectionId}`} className="accordion-collapse collapse" data-bs-parent="#adminProgressAccordion">
|
|
||||||
<div className="accordion-body">
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-hover align-middle mb-0" data-no-mgmt-sticky>
|
|
||||||
<thead className="table-light">
|
|
||||||
<tr><th>Week</th><th>Subjects</th><th>Teacher</th><th className="text-end">Action</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{groups.map((group) => {
|
|
||||||
const reports = group.reports ?? {}
|
|
||||||
const teacherSet = Array.from(new Set(Object.values(reports).map((report) => report.teacher_name).filter(Boolean)))
|
|
||||||
const firstReport = Object.values(reports)[0]
|
|
||||||
return (
|
|
||||||
<tr key={`${sectionId}-${group.week_start}`}>
|
|
||||||
<td>
|
|
||||||
<div className="fw-semibold">{formatDate(group.week_start)} - {formatDate(group.week_end)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="d-flex flex-column gap-2">
|
|
||||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
|
||||||
const subjectName = section.db_subject ?? section.label ?? slug
|
|
||||||
const report = reports[subjectName]
|
|
||||||
return (
|
|
||||||
<div key={slug} className="border rounded-3 p-2">
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<strong className="small mb-0">{section.label ?? subjectName}</strong>
|
|
||||||
<span className={`badge ${report ? 'bg-secondary' : 'bg-light text-muted'}`}>{report?.status_label ?? 'No entry'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="small">
|
|
||||||
{report ? (
|
|
||||||
<ClassProgressUnitDisplay unitTitle={report.unit_title} isQuran={subjectName === 'Quran/Arabic'} compact />
|
|
||||||
) : (
|
|
||||||
<span className="text-muted">No submission</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{teacherSet.join(', ') || '—'}</td>
|
|
||||||
<td className="text-end">
|
|
||||||
{firstReport?.id ? (
|
|
||||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => navigate(`/app/admin/progress/view/${firstReport.id}`)}>
|
|
||||||
View
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminClassProgressViewPage() {
|
|
||||||
const { id } = useParams()
|
|
||||||
const reportId = Number(id)
|
|
||||||
const [report, setReport] = useState<ClassProgressReportRow | null>(null)
|
|
||||||
const [weeklyReports, setWeeklyReports] = useState<ClassProgressReportRow[]>([])
|
|
||||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!reportId) return
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const [detail, meta] = await Promise.all([fetchClassProgressDetail(reportId), fetchClassProgressMeta()])
|
|
||||||
setReport(detail.data?.report ?? null)
|
|
||||||
setWeeklyReports(detail.data?.weekly_reports ?? [])
|
|
||||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
|
||||||
setError(null)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Unable to load progress report.')
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [reportId])
|
|
||||||
|
|
||||||
const reportsBySubject = useMemo(
|
|
||||||
() => Object.fromEntries(weeklyReports.map((entry) => [String(entry.subject ?? ''), entry])),
|
|
||||||
[weeklyReports],
|
|
||||||
)
|
|
||||||
|
|
||||||
const attachmentList = weeklyReports.flatMap((entry) => (entry.attachments ?? []).map((attachment) => ({
|
|
||||||
subject: entry.subject ?? 'Attachment',
|
|
||||||
attachment,
|
|
||||||
})))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Progress Report">
|
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
||||||
{report ? (
|
|
||||||
<>
|
|
||||||
<div className="d-flex align-items-center justify-content-between mb-3">
|
|
||||||
<div className="text-muted">{report.class_section_name ?? '-'} • {formatDate(report.week_start)} → {formatDate(report.week_end)}</div>
|
|
||||||
<Link to="/app/admin/progress" className="btn btn-outline-secondary">Back</Link>
|
|
||||||
</div>
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-lg-8">
|
|
||||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
|
||||||
const subjectName = section.db_subject ?? section.label ?? slug
|
|
||||||
const entry = reportsBySubject[subjectName]
|
|
||||||
return (
|
|
||||||
<div className="card shadow-sm mb-3" key={slug}>
|
|
||||||
<div className="card-header bg-white"><strong>{section.label ?? subjectName}</strong></div>
|
|
||||||
<div className="card-body">
|
|
||||||
{!entry ? (
|
|
||||||
<div className="text-muted">No entry submitted for this subject this week.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-2">
|
|
||||||
<ClassProgressUnitDisplay unitTitle={entry.unit_title} isQuran={subjectName === 'Quran/Arabic'} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3"><strong>Activities</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.covered || '-'}</div></div>
|
|
||||||
<div className="mb-3"><strong>{subjectName === 'Quran/Arabic' ? 'Arabic Practice / Homework' : 'Assigned Homework'}:</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.homework || '-'}</div></div>
|
|
||||||
{(entry.attachments ?? []).length > 0 ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<strong>Attachments:</strong>
|
|
||||||
<div className="d-flex flex-column gap-2 mt-2">
|
|
||||||
{(entry.attachments ?? []).map((attachment) => (
|
|
||||||
<a key={attachment.id ?? attachment.download_url} className="btn btn-sm btn-outline-secondary text-start" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
|
|
||||||
{attachment.name ?? 'Attachment'}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="col-lg-4">
|
|
||||||
<div className="card shadow-sm mb-3">
|
|
||||||
<div className="card-header bg-white"><strong>Summary</strong></div>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="mb-2"><strong>Teacher:</strong> {report.teacher_name ?? '-'}</div>
|
|
||||||
<div className="mb-2"><strong>Submitted:</strong> {formatDate(report.created_at, true)}</div>
|
|
||||||
<div><strong>Status:</strong> {report.status_label ?? 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(report.flags ?? []).length > 0 ? (
|
|
||||||
<div className="card shadow-sm mb-3">
|
|
||||||
<div className="card-header bg-white"><strong>Checklist</strong></div>
|
|
||||||
<div className="card-body d-flex flex-wrap gap-2">
|
|
||||||
{(report.flags ?? []).map((flag) => <span key={flag} className="badge bg-light text-dark border">{flag.replace(/_/g, ' ')}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{attachmentList.length > 0 ? (
|
|
||||||
<div className="card shadow-sm">
|
|
||||||
<div className="card-header bg-white"><strong>Attachments</strong></div>
|
|
||||||
<div className="card-body">
|
|
||||||
{attachmentList.map(({ subject, attachment }, index) => (
|
|
||||||
<a key={`${attachment.id ?? index}`} className="btn btn-outline-primary w-100 text-start mb-2" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
|
|
||||||
{`${subject} • ${attachment.name ?? 'Attachment'}`}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminStudentScoreCardPage() {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [results, setResults] = useState<Array<Record<string, unknown>>>([])
|
|
||||||
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
|
|
||||||
const [selectedStudent, setSelectedStudent] = useState<{ firstname?: string | null; lastname?: string | null; school_id?: string | null } | null>(null)
|
|
||||||
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function runSearch(e?: FormEvent) {
|
|
||||||
e?.preventDefault()
|
|
||||||
if (!query.trim()) return
|
|
||||||
try {
|
|
||||||
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard(query.trim())
|
|
||||||
setResults(data.results?.students ?? [])
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to search students.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openScoreCard(studentId: number) {
|
|
||||||
try {
|
|
||||||
const data = await fetchStudentScoreCard(studentId)
|
|
||||||
setSelectedStudentId(studentId)
|
|
||||||
setSelectedStudent(data.student)
|
|
||||||
setRows(data.rows ?? [])
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to load student score card.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Student Score Card (Admin)">
|
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
||||||
<div className="card shadow-sm mb-3">
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={(e) => void runSearch(e)}>
|
|
||||||
<div className="row g-2 justify-content-center">
|
|
||||||
<div className="col-12 col-md-8 col-lg-6">
|
|
||||||
<div className="input-group">
|
|
||||||
<span className="input-group-text">Search Student</span>
|
|
||||||
<input className="form-control" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type School ID or Student Name" />
|
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={() => setQuery('')}>Clear</button>
|
|
||||||
</div>
|
|
||||||
<small className="text-muted">Searches all grades.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="d-none">Search</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{query.trim() && results.length === 0 ? <div className="alert alert-warning">No students found.</div> : null}
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<div className="card shadow-sm mb-4">
|
|
||||||
<div className="card-header d-flex justify-content-between align-items-center"><strong>Results</strong><span className="text-muted small">Showing matching students</span></div>
|
|
||||||
<div className="card-body table-responsive pt-2">
|
|
||||||
<table className="table table-striped table-hover align-middle">
|
|
||||||
<thead className="table-light"><tr><th>School ID</th><th>Student</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{results.map((row) => (
|
|
||||||
<tr key={String(row.id ?? '')}>
|
|
||||||
<td>{String(row.school_id ?? '')}</td>
|
|
||||||
<td>{`${String(row.firstname ?? '')} ${String(row.lastname ?? '')}`.trim()}</td>
|
|
||||||
<td className="text-end">
|
|
||||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void openScoreCard(Number(row.id))}>Open Score Card</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{selectedStudentId && selectedStudent ? (
|
|
||||||
<div className="card shadow-sm">
|
|
||||||
<div className="card-header">
|
|
||||||
<strong>{`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()}</strong>
|
|
||||||
<span className="text-muted ms-2">{selectedStudent.school_id ?? ''}</span>
|
|
||||||
</div>
|
|
||||||
<div className="card-body table-responsive">
|
|
||||||
<table className="table table-bordered table-sm align-middle mb-0">
|
|
||||||
<thead className="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>School Year</th>
|
|
||||||
<th>Semester</th>
|
|
||||||
<th>Class</th>
|
|
||||||
<th>Homework</th>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Participation</th>
|
|
||||||
<th>Quiz</th>
|
|
||||||
<th>Test</th>
|
|
||||||
<th>Attendance</th>
|
|
||||||
<th>PTAP</th>
|
|
||||||
<th>Midterm</th>
|
|
||||||
<th>Semester</th>
|
|
||||||
<th>Year Avg</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.length === 0 ? <tr><td colSpan={13} className="text-muted">No score rows found.</td></tr> : rows.map((row, index) => (
|
|
||||||
<tr key={`${row.school_year}-${row.semester}-${index}`}>
|
|
||||||
<td>{row.school_year ?? '—'}</td>
|
|
||||||
<td>{row.semester ?? '—'}</td>
|
|
||||||
<td>{row.class_section_name ?? '—'}</td>
|
|
||||||
<td>{row.homework_avg ?? '—'}</td>
|
|
||||||
<td>{row.project_avg ?? '—'}</td>
|
|
||||||
<td>{row.participation_score ?? '—'}</td>
|
|
||||||
<td>{row.quiz_avg ?? '—'}</td>
|
|
||||||
<td>{row.test_avg ?? '—'}</td>
|
|
||||||
<td>{row.attendance_score ?? '—'}</td>
|
|
||||||
<td>{row.ptap_score ?? '—'}</td>
|
|
||||||
<td>{row.midterm_exam_score ?? '—'}</td>
|
|
||||||
<td>{row.semester_score ?? '—'}</td>
|
|
||||||
<td>{row.year_score ?? '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminEmergencyContactIndexPage() {
|
export function AdminEmergencyContactIndexPage() {
|
||||||
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -530,25 +56,53 @@ export function AdminEmergencyContactIndexPage() {
|
|||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-bordered table-striped align-middle">
|
<table className="table table-bordered table-striped align-middle">
|
||||||
<thead className="table-dark">
|
<thead className="table-dark">
|
||||||
<tr><th>Parent Name</th><th>Students</th><th>Contact Name</th><th>Relation</th><th>Phone</th><th>Actions</th></tr>
|
<tr>
|
||||||
|
<th>Parent Name</th>
|
||||||
|
<th>Students</th>
|
||||||
|
<th>Contact Name</th>
|
||||||
|
<th>Relation</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{groups.flatMap((group) =>
|
{groups.flatMap((group) =>
|
||||||
(group.contacts ?? []).map((contact) => (
|
(group.contacts ?? []).map((contact) => (
|
||||||
<tr key={contact.id}>
|
<tr key={contact.id}>
|
||||||
<td>{group.parent_name ?? '—'}</td>
|
<td>{group.parent_name ?? '—'}</td>
|
||||||
<td>{(group.students ?? []).map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()).join(', ') || '—'}</td>
|
<td>
|
||||||
|
{(group.students ?? [])
|
||||||
|
.map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim())
|
||||||
|
.join(', ') || '—'}
|
||||||
|
</td>
|
||||||
<td>{contact.name ?? '—'}</td>
|
<td>{contact.name ?? '—'}</td>
|
||||||
<td>{contact.relation ?? '—'}</td>
|
<td>{contact.relation ?? '—'}</td>
|
||||||
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td>
|
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td>
|
||||||
<td className="d-flex gap-2">
|
<td className="d-flex gap-2">
|
||||||
<Link className="btn btn-sm btn-primary" to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`}>Edit</Link>
|
<Link
|
||||||
<button className="btn btn-sm btn-danger" type="button" onClick={() => void removeContact(contact.id, group.parent_id)}>Delete</button>
|
className="btn btn-sm btn-primary"
|
||||||
|
to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void removeContact(contact.id, group.parent_id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)),
|
)),
|
||||||
)}
|
)}
|
||||||
{groups.length === 0 ? <tr><td colSpan={6} className="text-muted">No emergency contacts found.</td></tr> : null}
|
{groups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-muted">
|
||||||
|
No emergency contacts found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -609,13 +163,48 @@ export function AdminEmergencyContactEditPage() {
|
|||||||
<div className="card shadow-sm" style={{ maxWidth: 720 }}>
|
<div className="card shadow-sm" style={{ maxWidth: 720 }}>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form onSubmit={(e) => void onSubmit(e)}>
|
<form onSubmit={(e) => void onSubmit(e)}>
|
||||||
<div className="mb-3"><label className="form-label">Name</label><input className="form-control" value={form.name} onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))} required /></div>
|
<div className="mb-3">
|
||||||
<div className="mb-3"><label className="form-label">Phone</label><input className="form-control" value={form.cellphone} onChange={(e) => setForm((current) => ({ ...current, cellphone: e.target.value }))} required /></div>
|
<label className="form-label">Name</label>
|
||||||
<div className="mb-3"><label className="form-label">Email</label><input type="email" className="form-control" value={form.email} onChange={(e) => setForm((current) => ({ ...current, email: e.target.value }))} /></div>
|
<input
|
||||||
<div className="mb-3"><label className="form-label">Relation</label><input className="form-control" value={form.relation} onChange={(e) => setForm((current) => ({ ...current, relation: e.target.value }))} /></div>
|
className="form-control"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Phone</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.cellphone}
|
||||||
|
onChange={(e) => setForm((current) => ({ ...current, cellphone: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((current) => ({ ...current, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Relation</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.relation}
|
||||||
|
onChange={(e) => setForm((current) => ({ ...current, relation: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button className="btn btn-primary" type="submit">Update</button>
|
<button className="btn btn-primary" type="submit">
|
||||||
<Link className="btn btn-secondary" to="/app/administrator/emergency-contacts">Cancel</Link>
|
Update
|
||||||
|
</button>
|
||||||
|
<Link className="btn btn-secondary" to="/app/administrator/emergency-contacts">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { ApiHttpError } from '../api/http'
|
||||||
import {
|
import {
|
||||||
assignTeacherClass,
|
assignTeacherClass,
|
||||||
assignStudentClass,
|
assignStudentClass,
|
||||||
@@ -769,7 +770,7 @@ function CalendarEventForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-footer d-flex gap-2 justify-content-end">
|
<div className="card-footer d-flex gap-2 justify-content-end">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/administrator/calendar_view')}>
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/administrator/events')}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" type="submit" disabled={submitting}>
|
<button className="btn btn-primary" type="submit" disabled={submitting}>
|
||||||
@@ -817,7 +818,7 @@ export function AdminCalendarAddEventPage() {
|
|||||||
submitting={false}
|
submitting={false}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
await createAdministratorCalendarEvent(payload as never)
|
await createAdministratorCalendarEvent(payload as never)
|
||||||
navigate('/app/administrator/calendar_view')
|
navigate('/app/administrator/events')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -867,12 +868,14 @@ export function AdminCalendarEditPage() {
|
|||||||
submitting={false}
|
submitting={false}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
await updateAdministratorCalendarEvent(Number(eventId), payload)
|
await updateAdministratorCalendarEvent(Number(eventId), payload)
|
||||||
navigate('/app/administrator/calendar_view')
|
navigate('/app/administrator/events')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */
|
||||||
|
|
||||||
export function AdminCalendarViewPage() {
|
export function AdminCalendarViewPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
@@ -909,10 +912,13 @@ export function AdminCalendarViewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminParityShell title="School Calendar">
|
<AdminParityShell title="Event list">
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
<div className="d-flex gap-2 mb-3 justify-content-end">
|
<div className="d-flex gap-2 mb-3 flex-wrap justify-content-between align-items-center">
|
||||||
|
<Link to="/app/administrator/calendar" className="btn btn-outline-secondary">
|
||||||
|
Browse calendar
|
||||||
|
</Link>
|
||||||
<Link to={`/app/administrator/calendar_add_event${schoolYear || semester ? `?${new URLSearchParams({ ...(schoolYear ? { school_year: schoolYear } : {}), ...(semester ? { semester } : {}) }).toString()}` : ''}`} className="btn btn-success">
|
<Link to={`/app/administrator/calendar_add_event${schoolYear || semester ? `?${new URLSearchParams({ ...(schoolYear ? { school_year: schoolYear } : {}), ...(semester ? { semester } : {}) }).toString()}` : ''}`} className="btn btn-success">
|
||||||
Add New Event
|
Add New Event
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1040,10 +1046,10 @@ export function AdminCalendarPage() {
|
|||||||
}, [events])
|
}, [events])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminParityShell title="School Calendar">
|
<AdminParityShell title="School calendar">
|
||||||
<div className="d-flex justify-content-end mb-3">
|
<div className="d-flex justify-content-end mb-3">
|
||||||
<Link to="/app/administrator/calendar_view" className="btn btn-outline-secondary">
|
<Link to="/app/administrator/events" className="btn btn-outline-secondary">
|
||||||
Manage Events
|
Manage events (list)
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
@@ -2314,7 +2320,7 @@ export function AdminParentProfilePage() {
|
|||||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Unable to load parent profiles.')
|
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -2323,8 +2329,13 @@ export function AdminParentProfilePage() {
|
|||||||
|
|
||||||
async function loadGuardian(guardianId: number) {
|
async function loadGuardian(guardianId: number) {
|
||||||
setSelectedGuardianId(guardianId)
|
setSelectedGuardianId(guardianId)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
const data = await fetchFamilyAdminIndex({ guardianId })
|
const data = await fetchFamilyAdminIndex({ guardianId })
|
||||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load families.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2622,6 +2633,106 @@ export function AdminSearchResultsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
|
||||||
|
export function AdminSectionsPromotionTotalsPage() {
|
||||||
|
const [schoolYear, setSchoolYear] = useState('')
|
||||||
|
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function load(year = schoolYear) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchStudentPromotionTotals(year || undefined)
|
||||||
|
setRows(data.rows ?? [])
|
||||||
|
setSchoolYear(data.year ?? year ?? '')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to load promotion totals.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminParityShell title="Section promotion totals">
|
||||||
|
<p className="text-muted small mb-3">
|
||||||
|
Per-class/section student counts from the promotion pipeline. Pair with{' '}
|
||||||
|
<Link to="/app/administrator/sections/auto-distribute">auto-distribute sections</Link> to create
|
||||||
|
sections from these totals.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="row g-2 align-items-end mb-3 flex-wrap">
|
||||||
|
<div className="col-sm-4 col-lg-3">
|
||||||
|
<label className="form-label">School year</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={schoolYear}
|
||||||
|
onChange={(e) => setSchoolYear(e.target.value)}
|
||||||
|
placeholder="e.g. 2025-2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/sections/auto-distribute">
|
||||||
|
Auto-distribute sections
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped align-middle">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Class / section</th>
|
||||||
|
<th className="text-end">Total students</th>
|
||||||
|
<th className="text-muted small">class_id</th>
|
||||||
|
<th className="text-muted small">class_section_id</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-muted">
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-muted">
|
||||||
|
No promotion totals for this school year.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row, i) => {
|
||||||
|
const key = String(row.class_section_id ?? row.class_id ?? i)
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td>{row.class_section_name ?? '—'}</td>
|
||||||
|
<td className="text-end">{Number(row.total ?? 0)}</td>
|
||||||
|
<td className="text-muted small">{row.class_id ?? '—'}</td>
|
||||||
|
<td className="text-muted small">{row.class_section_id ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</AdminParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminSectionsAutoDistributePage() {
|
export function AdminSectionsAutoDistributePage() {
|
||||||
const [schoolYear, setSchoolYear] = useState('')
|
const [schoolYear, setSchoolYear] = useState('')
|
||||||
const [studentsPerSection, setStudentsPerSection] = useState(20)
|
const [studentsPerSection, setStudentsPerSection] = useState(20)
|
||||||
@@ -2990,12 +3101,23 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
const data = await fetchSubjectCurriculum()
|
const data = await fetchSubjectCurriculum()
|
||||||
setClasses(data.classes ?? [])
|
setClasses(data.classes ?? [])
|
||||||
setEntries(data.entries ?? [])
|
setEntries(data.entries ?? [])
|
||||||
setSubjects(data.subject_labels ?? {})
|
setSubjects(data.subject_labels ?? {})
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3004,6 +3126,8 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
|
|
||||||
async function submitForm(e: FormEvent) {
|
async function submitForm(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
const payload = {
|
const payload = {
|
||||||
class_id: Number(form.class_id),
|
class_id: Number(form.class_id),
|
||||||
subject: form.subject,
|
subject: form.subject,
|
||||||
@@ -3011,6 +3135,7 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
unit_title: form.unit_title || null,
|
unit_title: form.unit_title || null,
|
||||||
chapter_name: form.chapter_name,
|
chapter_name: form.chapter_name,
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await updateSubjectCurriculum(editingId, payload)
|
await updateSubjectCurriculum(editingId, payload)
|
||||||
setMessage('Curriculum entry updated.')
|
setMessage('Curriculum entry updated.')
|
||||||
@@ -3021,17 +3146,38 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||||
await load()
|
await load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setMessage(null)
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to save curriculum entry.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEntry(id: number) {
|
||||||
|
if (!confirm('Delete this curriculum entry?')) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await deleteSubjectCurriculum(id)
|
||||||
|
setMessage('Curriculum entry deleted.')
|
||||||
|
await load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setMessage(null)
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to delete curriculum entry.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminParityShell title="Subject Curriculum">
|
<AdminParityShell title="Subject Curriculum">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
<div className="col-lg-5">
|
<div className="col-lg-5">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header">{editingId ? 'Edit curriculum entry' : 'Add new curriculum entry'}</div>
|
<div className="card-header">{editingId ? 'Edit curriculum entry' : 'Add new curriculum entry'}</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form onSubmit={(e) => void submitForm(e)}>
|
<form onSubmit={(e) => void submitForm(e)}>
|
||||||
|
<fieldset disabled={loading || saving} className="border-0 m-0 p-0">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Class</label>
|
<label className="form-label">Class</label>
|
||||||
<select className="form-select" value={form.class_id} onChange={(e) => setForm((current) => ({ ...current, class_id: e.target.value }))} required>
|
<select className="form-select" value={form.class_id} onChange={(e) => setForm((current) => ({ ...current, class_id: e.target.value }))} required>
|
||||||
@@ -3049,9 +3195,28 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
<div className="mb-3"><label className="form-label">Unit title</label><input className="form-control" value={form.unit_title} onChange={(e) => setForm((current) => ({ ...current, unit_title: e.target.value }))} /></div>
|
<div className="mb-3"><label className="form-label">Unit title</label><input className="form-control" value={form.unit_title} onChange={(e) => setForm((current) => ({ ...current, unit_title: e.target.value }))} /></div>
|
||||||
<div className="mb-3"><label className="form-label">Chapter / Surah</label><input className="form-control" value={form.chapter_name} onChange={(e) => setForm((current) => ({ ...current, chapter_name: e.target.value }))} required /></div>
|
<div className="mb-3"><label className="form-label">Chapter / Surah</label><input className="form-control" value={form.chapter_name} onChange={(e) => setForm((current) => ({ ...current, chapter_name: e.target.value }))} required /></div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button className="btn btn-primary flex-grow-1" type="submit">{editingId ? 'Apply changes' : 'Save curriculum entry'}</button>
|
<button
|
||||||
{editingId ? <button className="btn btn-outline-secondary" type="button" onClick={() => { setEditingId(null); setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' }) }}>Cancel</button> : null}
|
className="btn btn-primary flex-grow-1"
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : editingId ? 'Apply changes' : 'Save curriculum entry'}
|
||||||
|
</button>
|
||||||
|
{editingId ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null)
|
||||||
|
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3063,7 +3228,20 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
<table className="table table-bordered table-hover align-middle mb-0">
|
<table className="table table-bordered table-hover align-middle mb-0">
|
||||||
<thead className="table-light"><tr><th>Class</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
|
<thead className="table-light"><tr><th>Class</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.length === 0 ? <tr><td colSpan={6} className="text-muted">No curriculum records yet.</td></tr> : entries.map((entry) => (
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-muted">
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-muted">
|
||||||
|
No curriculum records yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
<td>{entry.class_name ?? '—'}</td>
|
<td>{entry.class_name ?? '—'}</td>
|
||||||
<td>{subjects[entry.subject] ?? entry.subject}</td>
|
<td>{subjects[entry.subject] ?? entry.subject}</td>
|
||||||
@@ -3071,11 +3249,35 @@ export function AdminSubjectCurriculumPage() {
|
|||||||
<td>{entry.unit_title ?? '—'}</td>
|
<td>{entry.unit_title ?? '—'}</td>
|
||||||
<td>{entry.chapter_name}</td>
|
<td>{entry.chapter_name}</td>
|
||||||
<td className="d-flex gap-2">
|
<td className="d-flex gap-2">
|
||||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => { setEditingId(entry.id); setForm({ class_id: String(entry.class_id), subject: entry.subject, unit_number: entry.unit_number ? String(entry.unit_number) : '', unit_title: entry.unit_title ?? '', chapter_name: entry.chapter_name }) }}>Edit</button>
|
<button
|
||||||
<button className="btn btn-sm btn-outline-danger" type="button" onClick={() => void deleteSubjectCurriculum(entry.id).then(load)}>Delete</button>
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(entry.id)
|
||||||
|
setForm({
|
||||||
|
class_id: String(entry.class_id),
|
||||||
|
subject: entry.subject,
|
||||||
|
unit_number: entry.unit_number ? String(entry.unit_number) : '',
|
||||||
|
unit_title: entry.unit_title ?? '',
|
||||||
|
chapter_name: entry.chapter_name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void removeEntry(entry.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -3183,24 +3385,35 @@ export function AdminTeacherClassAssignmentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminTeacherSubmissionsPage() {
|
export function AdminTeacherSubmissionsPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
|
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
|
||||||
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
|
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
|
||||||
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [semester, setSemester] = useState<string | null>(null)
|
const [semester, setSemester] = useState<string | null>(null)
|
||||||
const [schoolYear, setSchoolYear] = useState<string | null>(null)
|
const [schoolYear, setSchoolYear] = useState<string | null>(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await fetchTeacherSubmissions()
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchTeacherSubmissions(searchParams)
|
||||||
setRows(data.rows ?? [])
|
setRows(data.rows ?? [])
|
||||||
setSummary(data.summary ?? {})
|
setSummary(data.summary ?? {})
|
||||||
setSemester(data.semester ?? null)
|
setSemester(data.semester ?? null)
|
||||||
setSchoolYear(data.schoolYear ?? null)
|
setSchoolYear(data.schoolYear ?? null)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to load teacher submissions.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [])
|
}, [searchParams])
|
||||||
|
|
||||||
async function sendNotifications() {
|
async function sendNotifications() {
|
||||||
const notify = Object.entries(selected).filter(([, checked]) => checked).map(([id]) => Number(id))
|
const notify = Object.entries(selected).filter(([, checked]) => checked).map(([id]) => Number(id))
|
||||||
@@ -3210,13 +3423,20 @@ export function AdminTeacherSubmissionsPage() {
|
|||||||
(row.teachers ?? []).map((teacher) => [String(teacher.id), row.missing_items ?? []]),
|
(row.teachers ?? []).map((teacher) => [String(teacher.id), row.missing_items ?? []]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
try {
|
||||||
const result = await notifyTeacherSubmissions({ notify, missing_items: missingItems })
|
const result = await notifyTeacherSubmissions({ notify, missing_items: missingItems })
|
||||||
setMessage(result.message ?? 'Notifications sent.')
|
setMessage(result.message ?? 'Notifications sent.')
|
||||||
|
setError(null)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setMessage(null)
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Unable to send notifications.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminParityShell title="Teacher Submission Dashboard">
|
<AdminParityShell title="Teacher Submission Dashboard">
|
||||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="row g-3 mb-4">
|
<div className="row g-3 mb-4">
|
||||||
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div>
|
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div>
|
||||||
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div>
|
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div>
|
||||||
@@ -3224,15 +3444,31 @@ export function AdminTeacherSubmissionsPage() {
|
|||||||
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Term</div><div className="fs-6 fw-semibold">{`${semester ?? '—'} ${schoolYear ?? ''}`.trim()}</div></div></div></div>
|
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Term</div><div className="fs-6 fw-semibold">{`${semester ?? '—'} ${schoolYear ?? ''}`.trim()}</div></div></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex justify-content-end mb-3">
|
<div className="d-flex justify-content-end mb-3">
|
||||||
<button className="btn btn-primary" type="button" onClick={() => void sendNotifications()}>Notify Selected Teachers</button>
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void sendNotifications()}
|
||||||
|
>
|
||||||
|
Notify Selected Teachers
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
|
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||||
<table className="table table-striped table-bordered align-middle">
|
<table className="table table-striped table-bordered align-middle">
|
||||||
<thead className="table-light">
|
<thead className="table-light">
|
||||||
<tr><th>Notify</th><th>Class-Section</th><th>Teachers</th><th>Midterm Score</th><th>Midterm Comment</th><th>Participation</th><th>PTAP Comment</th><th>Attendance</th><th>Missing Items</th><th>Student Count</th></tr>
|
<tr><th>Notify</th><th>Class-Section</th><th>Teachers</th><th>Midterm Score</th><th>Midterm Comment</th><th>Participation</th><th>PTAP Comment</th><th>Attendance</th><th>Missing Items</th><th>Student Count</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 ? <tr><td colSpan={10} className="text-muted">No teacher-class assignments found for this term.</td></tr> : rows.map((row) => (
|
{!loading && rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="text-muted">
|
||||||
|
No teacher-class assignments found for this term.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!loading
|
||||||
|
? rows.map((row) => (
|
||||||
<tr key={String(row.class_section_id ?? row.class_section ?? '')}>
|
<tr key={String(row.class_section_id ?? row.class_section ?? '')}>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex flex-column gap-1">
|
<div className="d-flex flex-column gap-1">
|
||||||
@@ -3254,7 +3490,8 @@ export function AdminTeacherSubmissionsPage() {
|
|||||||
<td>{(row.missing_items ?? []).join(', ') || '—'}</td>
|
<td>{(row.missing_items ?? []).join(', ') || '—'}</td>
|
||||||
<td>{row.student_count ?? 0}</td>
|
<td>{row.student_count ?? 0}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
|
: null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -3310,7 +3547,7 @@ export function AdminCourseMaterialsPage() {
|
|||||||
<Link className="btn btn-outline-primary" to="/app/administrator/class_section">
|
<Link className="btn btn-outline-primary" to="/app/administrator/class_section">
|
||||||
Class Sections
|
Class Sections
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="btn btn-outline-primary" to="/app/administrator/calendar_view">
|
<Link className="btn btn-outline-primary" to="/app/administrator/events">
|
||||||
Calendar Events
|
Calendar Events
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { importLegacySecondParents } from '../api/session'
|
||||||
|
|
||||||
|
type ImportSummary = {
|
||||||
|
created_users?: number
|
||||||
|
guardians_linked?: number
|
||||||
|
skipped?: number
|
||||||
|
total_source?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamiliesImportLegacyPage() {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [summary, setSummary] = useState<ImportSummary | null>(null)
|
||||||
|
|
||||||
|
async function runImport() {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Run the legacy second-parent import now? This will create users and attach guardians based on legacy records.',
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await importLegacySecondParents()
|
||||||
|
setSummary(res.data ?? null)
|
||||||
|
setMessage(res.message ?? 'Legacy import completed.')
|
||||||
|
} catch (e) {
|
||||||
|
setSummary(null)
|
||||||
|
setError(e instanceof Error ? e.message : 'Legacy import failed.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-3">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="h4 mb-1">Import Legacy Families</h2>
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Import second parents from the legacy <code>parents</code> table into the new family
|
||||||
|
structure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-xl-7">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">Action</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="alert alert-warning small mb-3">
|
||||||
|
This operation can create secondary user accounts, link guardians to families, and
|
||||||
|
backfill student-family associations. Run it intentionally.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="small text-muted mb-4">
|
||||||
|
<li>Creates users for email-only second parents when no user already exists.</li>
|
||||||
|
<li>Links imported guardians into the family records for the primary parent.</li>
|
||||||
|
<li>Ensures students for the primary parent are attached to the same family.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => void runImport()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? 'Importing…' : 'Run Legacy Import'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-xl-5">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">Latest Result</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{error ? <div className="alert alert-danger small">{error}</div> : null}
|
||||||
|
{message ? <div className="alert alert-success small">{message}</div> : null}
|
||||||
|
|
||||||
|
{summary ? (
|
||||||
|
<dl className="row mb-0">
|
||||||
|
<dt className="col-7">Source rows</dt>
|
||||||
|
<dd className="col-5 text-end">{summary.total_source ?? 0}</dd>
|
||||||
|
|
||||||
|
<dt className="col-7">Users created</dt>
|
||||||
|
<dd className="col-5 text-end">{summary.created_users ?? 0}</dd>
|
||||||
|
|
||||||
|
<dt className="col-7">Guardians linked</dt>
|
||||||
|
<dd className="col-5 text-end">{summary.guardians_linked ?? 0}</dd>
|
||||||
|
|
||||||
|
<dt className="col-7">Skipped rows</dt>
|
||||||
|
<dd className="col-5 text-end">{summary.skipped ?? 0}</dd>
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted mb-0 small">
|
||||||
|
No import has been run in this session yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,51 @@
|
|||||||
|
import { type FormEvent, useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { requestForgotPassword } from '../api/passwordFlow'
|
||||||
|
|
||||||
/** Mirrors CodeIgniter `Views/user/forgot_password.php` shell; reset flow remains on the Laravel site. */
|
/** Mirrors CodeIgniter `user/forgot_password.php` + confirmation modal from `password_reset_confirmation.php`. */
|
||||||
export function ForgotPasswordPage() {
|
export function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [confirmEmail, setConfirmEmail] = useState<string | null>(null)
|
||||||
|
const confirmModalRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmEmail) return
|
||||||
|
const el = confirmModalRef.current
|
||||||
|
const B = (
|
||||||
|
window as unknown as {
|
||||||
|
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
|
||||||
|
}
|
||||||
|
).bootstrap
|
||||||
|
if (el && B?.Modal?.getOrCreateInstance) {
|
||||||
|
B.Modal.getOrCreateInstance(el).show()
|
||||||
|
}
|
||||||
|
}, [confirmEmail])
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await requestForgotPassword(email)
|
||||||
|
setConfirmEmail(email.trim())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Request failed.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ci-auth-bg flex-grow-1 py-4">
|
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||||
<div className="registration-form ci-parity container mt-5 mb-5 text-center">
|
<div className="registration-form ci-parity container mt-5 mb-5">
|
||||||
|
<form
|
||||||
|
className="text-start mx-auto"
|
||||||
|
style={{ maxWidth: 600 }}
|
||||||
|
onSubmit={(e) => void onSubmit(e)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<img
|
<img
|
||||||
@@ -22,22 +63,84 @@ export function ForgotPasswordPage() {
|
|||||||
Reset Your Password
|
Reset Your Password
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="mx-auto mb-4 text-muted" style={{ maxWidth: 560 }}>
|
<div className="centered-paragraph mb-4 text-center text-muted">
|
||||||
<p>Password reset requests are handled through the school's main website.</p>
|
<p className="mb-1">Please enter the email address you used to register with us.</p>
|
||||||
<p className="mb-0">
|
<p className="mb-0">We'll send you a link to reset your password.</p>
|
||||||
If this SPA is only the API client, open the legacy site or contact the office for a
|
|
||||||
reset link.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-grid gap-2" style={{ maxWidth: 400, margin: '0 auto' }}>
|
{error ? (
|
||||||
<Link className="btn btn-success item" to="/login">
|
<div className="alert alert-danger text-center" role="alert">
|
||||||
Back to Sign in
|
{error}
|
||||||
</Link>
|
</div>
|
||||||
<Link className="btn btn-secondary item" to="/register">
|
) : null}
|
||||||
|
|
||||||
|
<div className="form-group mb-3">
|
||||||
|
<label className="form-label visually-hidden" htmlFor="forgot-email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control item"
|
||||||
|
id="forgot-email"
|
||||||
|
name="email"
|
||||||
|
maxLength={50}
|
||||||
|
placeholder="Email Address"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-grid mb-3">
|
||||||
|
<button type="submit" className="btn btn-success item" disabled={loading}>
|
||||||
|
{loading ? 'Sending…' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-0">Don't have an account?</p>
|
||||||
|
<Link className="d-inline-block mt-1" to="/register">
|
||||||
Register now
|
Register now
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="modal fade"
|
||||||
|
id="emailConfirmModal"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={confirmModalRef}
|
||||||
|
aria-labelledby="emailConfirmLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div className="modal-content rounded-4 shadow border-0" style={{ maxWidth: 600, margin: 'auto' }}>
|
||||||
|
<div className="modal-body text-center">
|
||||||
|
<img
|
||||||
|
src="/alrahma_logo.png"
|
||||||
|
alt=""
|
||||||
|
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||||
|
onError={(ev) => {
|
||||||
|
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h5 className="modal-title text-success" id="emailConfirmLabel">
|
||||||
|
Check Your Email
|
||||||
|
</h5>
|
||||||
|
<p className="lead mt-3 mb-2">
|
||||||
|
A link to reset the password has been sent to this email:
|
||||||
|
<br />
|
||||||
|
<strong>{confirmEmail ?? ''}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">Please check your inbox and follow the instructions to reset the password.</p>
|
||||||
|
<Link className="btn btn-success mt-3" to="/login">
|
||||||
|
Return to Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/* Ported from CodeIgniter `Views/index.php` — scoped to avoid overriding global layout. */
|
||||||
|
.home-landing {
|
||||||
|
--home-primary: #3a8fd1;
|
||||||
|
--home-secondary: #2c5e8a;
|
||||||
|
--home-light-green: #e8f5e9;
|
||||||
|
font-family:
|
||||||
|
'Heebo',
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-landing-brand {
|
||||||
|
box-shadow: 0 2px 10px rgba(53, 52, 52, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-landing-brand-inner {
|
||||||
|
min-height: 3.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-landing-logo-wrap {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 10%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-landing-logo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-landing-brand-title {
|
||||||
|
color: var(--home-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(1.25rem, 2.5vw, 1.75rem);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .green-title {
|
||||||
|
color: var(--home-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .bg-light-green {
|
||||||
|
background-color: var(--home-light-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .btn-home-cta {
|
||||||
|
background-color: #28a745;
|
||||||
|
border-color: #28a745;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .btn-home-cta:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
border-color: #1e7e34;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-item {
|
||||||
|
height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-item img {
|
||||||
|
object-fit: cover;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption {
|
||||||
|
bottom: 20%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(56, 55, 55, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
left: 10%;
|
||||||
|
right: auto;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption h2 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-section-xl {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .stats-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .stat-circle {
|
||||||
|
width: 190px;
|
||||||
|
height: 190px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .stat-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .stat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .stat-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .circle-primary {
|
||||||
|
background: radial-gradient(circle at 30% 30%, #0d6efd, #0044aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .circle-success {
|
||||||
|
background: radial-gradient(circle at 30% 30%, #198754, #0c4f2c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .circle-warning {
|
||||||
|
background: radial-gradient(circle at 30% 30%, #daa544, #a66f00);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .circle-info {
|
||||||
|
background: radial-gradient(circle at 30% 30%, #0dcaf0, #047e9c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .circle-light {
|
||||||
|
background: radial-gradient(circle at 30% 30%, #63af4c, #00eb7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .row.bg-light.squared {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .row.bg-light.squared .col-lg-6.text-center {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .image-container {
|
||||||
|
margin-right: 30px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0 15px 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .image-container img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .image-container:hover img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .row.bg-light.squared .image-container {
|
||||||
|
position: relative !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .image-column {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .about-img img {
|
||||||
|
border: 5px solid #fff;
|
||||||
|
box-shadow: 0 0 15px rgba(72, 236, 7, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .back-to-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
z-index: 99;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .back-to-top.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.home-landing .home-carousel .carousel-item {
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption {
|
||||||
|
bottom: 15%;
|
||||||
|
padding: 15px;
|
||||||
|
width: 80%;
|
||||||
|
left: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .image-column {
|
||||||
|
min-height: 300px;
|
||||||
|
border-radius: 0 0 10px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .rounded-end {
|
||||||
|
border-radius: 0 0 10px 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.home-landing .home-carousel .carousel-item {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption {
|
||||||
|
bottom: 10%;
|
||||||
|
width: 85%;
|
||||||
|
left: 7.5%;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-stats .stat-circle {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .row.bg-light.squared .image-container {
|
||||||
|
max-width: 360px;
|
||||||
|
min-height: 0 !important;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .row.bg-light.squared .overlapping-image {
|
||||||
|
position: relative !important;
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto;
|
||||||
|
margin: 15px 0;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
border: 5px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .row.bg-light.squared.position-relative.align-items-center {
|
||||||
|
margin-bottom: 3rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.home-landing .home-carousel .carousel-item {
|
||||||
|
height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption {
|
||||||
|
bottom: 5%;
|
||||||
|
width: 90%;
|
||||||
|
left: 5%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-landing .home-carousel .carousel-caption p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +1,424 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { fetchAdministratorDashboardMetrics } from '../api/session'
|
||||||
|
import type { AdministratorDashboardMetricsResponse, AuthUser } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import './HomePage.css'
|
||||||
|
|
||||||
|
/** Public home must not call administrator APIs unless the user is allowed (avoids 401 for teachers/guests). */
|
||||||
|
function userMayLoadAdministratorHomeMetrics(user: AuthUser | null): boolean {
|
||||||
|
if (!user?.roles) return false
|
||||||
|
const r = user.roles
|
||||||
|
return Boolean(r.administrator || r.admin || r.super_admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carousel + sections ported from CodeIgniter `Views/index.php` (Al Rahma public home). */
|
||||||
|
const CAROUSEL_SLIDES = [
|
||||||
|
{
|
||||||
|
image: '/images/carousel-1.png',
|
||||||
|
alt: 'Not only a book to be read but a guide illuminating our lives.',
|
||||||
|
title: 'Not only a book to be read but a guide illuminating our lives.',
|
||||||
|
body:
|
||||||
|
'At Al Rahma Sunday School, we teach the Quran as a living guide, nurturing love and respect for the words of Allah (SWT). Our program supports each child’s journey through recitation, memorization, and age-appropriate translation and understanding.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: '/images/carousel-0.png',
|
||||||
|
alt: 'Faith and Knowledge Hand in Hand',
|
||||||
|
title: 'Faith and Knowledge Hand in Hand',
|
||||||
|
body:
|
||||||
|
'Our Sunday School program (Grades 1-9, Youth) is evenly divided between Islamic Studies in English—exploring topics about Allah, Islamic values, Muslim life and lessons from the Prophets—and Quran/Arabic studies focused on recitation, memorization and language. This balanced approach nurtures both understanding and practice of Islam.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: '/images/carousel-3.png',
|
||||||
|
alt: 'Nurturing Hearts and Minds',
|
||||||
|
title: 'Nurturing Hearts and Minds',
|
||||||
|
body:
|
||||||
|
'At our school, we inspire a lifelong love for Islamic values, for our worship rituals and our prophets through engaging lessons, interactive activities, and a supportive environment. Every student grows in knowledge, faith and character, learning to live the teachings of Islam in everyday life.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: '/images/carousel-4.png',
|
||||||
|
alt: 'Exciting Fun, Inside and Out!',
|
||||||
|
title: 'Exciting Fun, Inside and Out!',
|
||||||
|
body:
|
||||||
|
'From festive Eid celebrations to adventurous outdoor outings, our school events bring learning, laughter, and unforgettable memories together.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: '/images/carousel-5.png',
|
||||||
|
alt: 'Strengthening Faith, Character, and Community Bonds',
|
||||||
|
title: 'Strengthening Faith, Character, and Community Bonds',
|
||||||
|
body:
|
||||||
|
'A warm, faith-centered community helps children thrive, build confidence, compassion, and friendships.',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const STAT_DEF = [
|
||||||
|
{ key: 'students', label: 'Students', circle: 'circle-primary', icon: 'bi-mortarboard' },
|
||||||
|
{ key: 'teachers', label: 'Teachers', circle: 'circle-info', icon: 'bi-person-video3' },
|
||||||
|
{ key: 'teacherAssistants', label: 'TAs', circle: 'circle-success', icon: 'bi-person-gear' },
|
||||||
|
{ key: 'admins', label: 'Admins', circle: 'circle-warning', icon: 'bi-shield-check' },
|
||||||
|
{ key: 'parents', label: 'Parents', circle: 'circle-light', icon: 'bi-people' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type CountKey = (typeof STAT_DEF)[number]['key']
|
||||||
|
|
||||||
|
function formatStat(
|
||||||
|
n: number | undefined,
|
||||||
|
numberFmt: Intl.NumberFormat,
|
||||||
|
): string {
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? numberFmt.format(n) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
/** Public landing — structure inspired by CodeIgniter `Views/landing_page/guest_dashboard.php` hero. */
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { isAuthenticated } = useAuth()
|
const { token, user } = useAuth()
|
||||||
|
const numberFmt = useMemo(() => new Intl.NumberFormat(), [])
|
||||||
|
const [counts, setCounts] = useState<AdministratorDashboardMetricsResponse['counts']>({})
|
||||||
|
const [showTop, setShowTop] = useState(false)
|
||||||
|
|
||||||
|
const mayLoadMetrics = Boolean(token && userMayLoadAdministratorHomeMetrics(user))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mayLoadMetrics) {
|
||||||
|
setCounts({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchAdministratorDashboardMetrics()
|
||||||
|
if (!cancelled) setCounts(data.counts ?? {})
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCounts({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadStats()
|
||||||
|
const interval = window.setInterval(() => void loadStats(), 60_000)
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') void loadStats()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(interval)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
}
|
||||||
|
}, [mayLoadMetrics])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setShowTop(window.scrollY > 100)
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
onScroll()
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function countFor(key: CountKey): string {
|
||||||
|
return formatStat(counts?.[key], numberFmt)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-grow-1">
|
<div className="home-landing flex-grow-1">
|
||||||
<section className="hero-section position-relative overflow-hidden bg-white">
|
<div
|
||||||
<div className="welcome-message text-center py-5 px-3">
|
id="homeMainCarousel"
|
||||||
<h1 className="display-5 fw-semibold text-success mb-2">
|
className="carousel slide carousel-fade home-carousel"
|
||||||
Welcome to Al Rahma Sunday School
|
data-bs-ride="carousel"
|
||||||
</h1>
|
>
|
||||||
<h2 className="h4 text-secondary mb-3">
|
<div className="carousel-indicators">
|
||||||
{isAuthenticated ? 'Welcome back' : 'Welcome, Guest!'}
|
{CAROUSEL_SLIDES.map((_, i) => (
|
||||||
</h2>
|
<button
|
||||||
<p className="lead text-muted mx-auto mb-4" style={{ maxWidth: 640 }}>
|
key={i}
|
||||||
Use the portal to manage enrollment, attendance, grades, and family information — the
|
type="button"
|
||||||
same workflows as the CodeIgniter site, backed by the Laravel API.
|
data-bs-target="#homeMainCarousel"
|
||||||
</p>
|
data-bs-slide-to={i}
|
||||||
<div className="d-flex flex-wrap justify-content-center gap-2">
|
className={i === 0 ? 'active' : undefined}
|
||||||
{isAuthenticated ? (
|
aria-current={i === 0 ? 'true' : undefined}
|
||||||
<Link className="btn btn-success btn-lg px-4" to="/app/home">
|
aria-label={`Slide ${i + 1}`}
|
||||||
Go to dashboard
|
/>
|
||||||
</Link>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<>
|
<div className="carousel-inner">
|
||||||
<Link className="btn btn-success btn-lg px-4" to="/login">
|
{CAROUSEL_SLIDES.map((slide, i) => (
|
||||||
Sign in
|
<div key={slide.image} className={`carousel-item${i === 0 ? ' active' : ''}`}>
|
||||||
</Link>
|
<img src={slide.image} className="d-block w-100" alt={slide.alt} />
|
||||||
<Link className="btn btn-outline-success btn-lg px-4" to="/register">
|
<div className="carousel-caption">
|
||||||
Registration Form
|
<h2 className="mb-3">{slide.title}</h2>
|
||||||
</Link>
|
<p className="mb-0">{slide.body}</p>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link className="btn btn-outline-secondary btn-lg" to="/docs">
|
|
||||||
API documentation
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="carousel-control-prev"
|
||||||
|
type="button"
|
||||||
|
data-bs-target="#homeMainCarousel"
|
||||||
|
data-bs-slide="prev"
|
||||||
|
>
|
||||||
|
<span className="carousel-control-prev-icon" aria-hidden />
|
||||||
|
<span className="visually-hidden">Previous</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="carousel-control-next"
|
||||||
|
type="button"
|
||||||
|
data-bs-target="#homeMainCarousel"
|
||||||
|
data-bs-slide="next"
|
||||||
|
>
|
||||||
|
<span className="carousel-control-next-icon" aria-hidden />
|
||||||
|
<span className="visually-hidden">Next</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="text-center my-3">
|
||||||
|
<a
|
||||||
|
href="/account_creation_guide.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-decoration-none"
|
||||||
|
style={{ fontWeight: 900, color: '#000' }}
|
||||||
|
>
|
||||||
|
<h3 className="fw-bold my-0">
|
||||||
|
<span className="text-decoration-underline">
|
||||||
|
Join Al Rahma family today! Click here for a quick guide on how to create an account.
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="py-5 border-top bg-light">
|
<div className="container-xxl py-3 home-section-xl content-section">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="row g-4 justify-content-center">
|
<div className="home-stats">
|
||||||
<div className="col-md-4">
|
<h2 className="d-flex justify-content-center p-md-1 fs-3 fw-bold green-title mb-0">
|
||||||
<div className="card border-0 shadow-sm h-100">
|
Active Participants
|
||||||
<div className="card-body">
|
</h2>
|
||||||
<h3 className="h6 text-success">
|
<br />
|
||||||
<i className="bi bi-house-door me-2" aria-hidden />
|
<div className="stats-row">
|
||||||
Families
|
{STAT_DEF.map((s) => (
|
||||||
</h3>
|
<div key={s.key} className={`stat-circle ${s.circle}`}>
|
||||||
<p className="small text-muted mb-0">
|
<div className="stat-icon">
|
||||||
Parents sign in after registration is activated by email.
|
<i className={`bi ${s.icon}`} aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{countFor(s.key)}</div>
|
||||||
|
<div className="stat-title">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container-xxl py-3 home-section-xl content-section">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row bg-light squared align-items-center g-0">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="d-flex flex-column justify-content-center p-4 p-md-5">
|
||||||
|
<h2 className="mb-4 fs-2 fw-bold green-title">
|
||||||
|
Faith, Education and Community for Over 30 Years
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
The Islamic Society of Greater Lowell (ISGL), established in 1993 in Chelmsford,
|
||||||
|
Massachusetts, serves as both a mosque and a vibrant community center. It provides a
|
||||||
|
place of worship for Allah (SWT), offers Islamic education and facilitates religious
|
||||||
|
and social activities for Muslims in the Merrimack Valley. As a non-profit,
|
||||||
|
charitable, and tax-exempt organization, ISGL also provides essential services such as
|
||||||
|
marriage ceremonies, burial arrangements and community support while promoting a
|
||||||
|
greater understanding of Islam in America.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Under the umbrella of ISGL, Al Rahma Sunday School has been serving the community for
|
||||||
|
over thirty years. Throughout the decades, it has provided students with a strong
|
||||||
|
foundation in Islamic Studies and Quran, fostering both knowledge and character. With
|
||||||
|
its dedicated teachers and well-rounded academic program, the school continues to
|
||||||
|
guide generations of Muslim youth in their faith, values and practice of Islam.
|
||||||
|
</p>
|
||||||
|
<p>For more information, click below to visit ISGL official website.</p>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
className="btn btn-home-cta px-4 py-2 fw-semibold"
|
||||||
|
href="https://isgl.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
ISGL Official Website
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 image-column pe-lg-3">
|
||||||
|
<div className="position-relative h-100 w-100">
|
||||||
|
<img
|
||||||
|
className="w-100 h-100 rounded-end"
|
||||||
|
src="/images/isgl_home.png"
|
||||||
|
alt="ISGL"
|
||||||
|
style={{ objectFit: 'cover', minHeight: 280 }}
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget
|
||||||
|
img.onerror = null
|
||||||
|
img.src = '/images/call-to-action.jpg'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container-xxl py-3 home-section-xl content-section">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row bg-light squared align-items-center g-0">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="d-flex flex-column justify-content-center p-4 p-md-5">
|
||||||
|
<h2 className="mb-4 fs-2 fw-bold green-title">Our Mission</h2>
|
||||||
|
<p>
|
||||||
|
Al Rahma Sunday School's mission is to make the Masjid a central part of our
|
||||||
|
Muslim children's lives from a very young age. We are dedicated to offering a
|
||||||
|
strong, well-rounded curriculum in Quran, Arabic and Islamic Studies. Our goal is to
|
||||||
|
deliver the highest quality education to help instill Islamic values, beliefs and
|
||||||
|
practices in each student. Additionally, we regularly organize engaging and fun events
|
||||||
|
throughout the year to make the learning experience more enjoyable and spiritually
|
||||||
|
enriching.
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
We deeply appreciate your continued support and cooperation in helping us fulfill this
|
||||||
|
mission. Together, we can provide a strong Islamic foundation for our children and shape
|
||||||
|
a future generation that is confident in their faith and rooted in Islamic values.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-lg-6 about-img py-4 py-lg-0">
|
||||||
|
<div className="row g-2 justify-content-center">
|
||||||
|
<div className="col-12 text-center">
|
||||||
|
<img
|
||||||
|
className="img-fluid w-75 rounded-circle bg-light p-3"
|
||||||
|
src="/images/about-1.jpg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-6 text-start" style={{ marginTop: '-150px' }}>
|
||||||
<div className="card border-0 shadow-sm h-100">
|
<img
|
||||||
<div className="card-body">
|
className="img-fluid w-100 rounded-circle bg-light p-3"
|
||||||
<h3 className="h6 text-success">
|
src="/images/about-2.jpg"
|
||||||
<i className="bi bi-shield-lock me-2" aria-hidden />
|
alt=""
|
||||||
Secure portal
|
/>
|
||||||
</h3>
|
</div>
|
||||||
<p className="small text-muted mb-0">
|
<div className="col-6 text-end" style={{ marginTop: '-150px' }}>
|
||||||
Sessions use JWT from the Laravel API (<code>/api/v1/auth/login</code>).
|
<img
|
||||||
|
className="img-fluid w-100 rounded-circle bg-light p-3"
|
||||||
|
src="/images/about-3.jpg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container-xxl py-3 home-section-xl content-section">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row bg-light squared align-items-center g-0">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="d-flex flex-column justify-content-center p-4 p-md-5">
|
||||||
|
<h2 className="mb-4 fs-2 fw-bold green-title">Become A Teacher or Admin</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
Volunteering at Al Rahma Sunday School is a deeply fulfilling way to give back to the
|
||||||
|
community and help shape the next generation. Whether you're passionate about
|
||||||
|
teaching or prefer working behind the scenes, your time and dedication can make a
|
||||||
|
lasting difference. Join a team committed to nurturing young minds and strengthening
|
||||||
|
their connection to faith.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
As a volunteer teacher or teacher assistant, you'll have the opportunity to
|
||||||
|
inspire, educate, and guide children on their spiritual journey. Teachers play a vital
|
||||||
|
role in helping students understand the Quran, Islamic values, and the Arabic language
|
||||||
|
in a warm and engaging classroom environment. Teacher assistants support instruction,
|
||||||
|
help manage classroom activities, and provide one-on-one assistance to students when
|
||||||
|
needed. Whether leading the class or lending a helping hand, your commitment can
|
||||||
|
spark a lifelong love for learning and faith in the hearts of our students.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Administrative volunteers are essential to the success of our school. From organizing
|
||||||
|
materials and managing communications to helping coordinate school events and
|
||||||
|
logistics, your work ensures a smooth and efficient learning experience for students
|
||||||
|
and teachers alike. If you enjoy structured tasks, planning, or simply being helpful
|
||||||
|
in a quiet but powerful way, this role is perfect for you.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Link className="btn btn-home-cta py-3 px-5 mt-1" to="/register">
|
||||||
|
Get Started Now <i className="bi bi-arrow-right ms-2" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 image-column pe-lg-3">
|
||||||
|
<div className="position-relative h-100 w-100">
|
||||||
|
<img
|
||||||
|
className="w-100 h-100 rounded-end"
|
||||||
|
src="/images/call-to-action.jpg"
|
||||||
|
alt=""
|
||||||
|
style={{ objectFit: 'cover', minHeight: 280 }}
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget
|
||||||
|
img.onerror = null
|
||||||
|
img.src = '/images/isgl_home.png'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<div className="container-xxl py-3 home-section-xl content-section">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row bg-light squared position-relative align-items-center g-0">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="h-100 d-flex flex-column justify-content-center p-4 p-md-5">
|
||||||
|
<h2 className="mb-4 fs-2 fw-bold green-title">Our Curriculum and Grade Structure</h2>
|
||||||
|
<p>
|
||||||
|
Our program spans nine structured grade levels, each building upon the previous
|
||||||
|
year's knowledge to ensure a solid foundation in faith, character, and Islamic
|
||||||
|
learning.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Upon completing the 9th grade, students transition into our three-year Youth Program,
|
||||||
|
which emphasizes deeper community engagement, personal development and practical
|
||||||
|
application of Islamic principles. Participation in the Youth Program requires students
|
||||||
|
to be at least 15 years old, ensuring they are mature enough to benefit from its
|
||||||
|
advanced content.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Starting this academic year, children must be at least 6 years old by 12-31-2025 to
|
||||||
|
enroll in Grade 1. For younger learners, we are pleased to offer our newly established
|
||||||
|
Kindergarten class, welcoming children who are at least 5 years old by 12-31-2025. This
|
||||||
|
early education program is designed to introduce young minds to the basics of Islamic
|
||||||
|
teachings in a warm, age-appropriate environment.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Link className="btn btn-home-cta py-3 px-5 mt-3" to="/register">
|
||||||
|
Get Started Now <i className="bi bi-arrow-right ms-2" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6 text-start py-4 py-lg-0">
|
||||||
|
<div className="image-container mx-auto mx-lg-0">
|
||||||
|
<img
|
||||||
|
src="/images/12.jpeg"
|
||||||
|
className="overlapping-image image1"
|
||||||
|
alt="Students and activities"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-home-cta btn-lg rounded-1 back-to-top${showTop ? ' visible' : ''}`}
|
||||||
|
aria-label="Back to top"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-up" aria-hidden />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,19 @@ export function LoginPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isParentLogin =
|
const singleRole = result.roles.length === 1 ? result.roles[0].toLowerCase() : ''
|
||||||
result.selectedRole?.toLowerCase() === 'parent' ||
|
|
||||||
result.roles.some((role) => role.toLowerCase() === 'parent')
|
let target = '/app/home'
|
||||||
let target = from.startsWith('/app') ? from : '/app/home'
|
if (from.startsWith('/app')) target = from
|
||||||
if (isParentLogin && target === '/app/home') target = '/app/parent/home'
|
else if (from.startsWith('/docs')) target = from
|
||||||
|
|
||||||
|
if (result.roles.length === 1 && target === '/app/home') {
|
||||||
|
if (singleRole === 'parent') target = '/app/parent/home'
|
||||||
|
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') {
|
||||||
|
target = '/app/teacher_dashboard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dash = await fetchDashboardRoute()
|
const dash = await fetchDashboardRoute()
|
||||||
const route = dash.data?.dashboard?.route
|
const route = dash.data?.dashboard?.route
|
||||||
@@ -50,6 +58,19 @@ export function LoginPage() {
|
|||||||
/* keep default */
|
/* keep default */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* API default dashboard can point parents at teacher URLs (or vice versa); honor single-role account. */
|
||||||
|
if (result.roles.length === 1) {
|
||||||
|
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') {
|
||||||
|
if (target === '/app/home' || target.startsWith('/app/parent')) {
|
||||||
|
target = '/app/teacher_dashboard'
|
||||||
|
}
|
||||||
|
} else if (singleRole === 'parent') {
|
||||||
|
if (target === '/app/home' || target.startsWith('/app/teacher')) {
|
||||||
|
target = '/app/parent/home'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigate(target, { replace: true })
|
navigate(target, { replace: true })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ export function NavBuilderPage() {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editingItem = form.id
|
||||||
|
? payload.items.find((item) => String(item.id) === form.id) ?? null
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid px-0 nav-builder-page">
|
<div className="container-fluid px-0 nav-builder-page">
|
||||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
@@ -240,9 +244,18 @@ export function NavBuilderPage() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-5">
|
<div className="col-lg-5">
|
||||||
<div className="card mb-3">
|
<div className="card mb-3">
|
||||||
<div className="card-header">Add / Edit Item</div>
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>{editingItem ? `Edit Item #${editingItem.id}` : 'Add / Edit Item'}</span>
|
||||||
|
{editingItem ? <span className="badge bg-primary">Editing</span> : null}
|
||||||
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
{editingItem ? (
|
||||||
|
<div className="alert alert-info py-2">
|
||||||
|
Editing <strong>{editingItem.label}</strong>. Save will update the existing menu item.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
|
<input type="hidden" name="id" value={form.id} />
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<label className="form-label" htmlFor="menu_parent_id">Parent</label>
|
<label className="form-label" htmlFor="menu_parent_id">Parent</label>
|
||||||
<select
|
<select
|
||||||
@@ -360,10 +373,10 @@ export function NavBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? 'Saving...' : editingItem ? 'Update' : 'Save'}
|
||||||
</button>{' '}
|
</button>{' '}
|
||||||
<button className="btn btn-secondary" type="button" onClick={() => setForm(blankForm)}>
|
<button className="btn btn-secondary" type="button" onClick={() => setForm(blankForm)}>
|
||||||
Reset
|
{editingItem ? 'Cancel edit' : 'Reset'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,79 +1,59 @@
|
|||||||
import { useState } from 'react'
|
import { Link } from 'react-router-dom'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
|
||||||
import { fetchDashboardRoute } from '../api/session'
|
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
import { useContinueWithRole } from '../hooks/useContinueWithRole'
|
||||||
|
|
||||||
export function RoleSelectPage() {
|
export function RoleSelectPage() {
|
||||||
const { user, roles, selectedRole, setSelectedRole } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const navigate = useNavigate()
|
const { continueWithRole, busy, busyRole, error } = useContinueWithRole()
|
||||||
const location = useLocation()
|
|
||||||
const [busyRole, setBusyRole] = useState<string | null>(null)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
|
|
||||||
|
|
||||||
async function continueLogin(nextRole: string) {
|
|
||||||
setBusy(true)
|
|
||||||
setBusyRole(nextRole)
|
|
||||||
setError(null)
|
|
||||||
setSelectedRole(nextRole)
|
|
||||||
|
|
||||||
let target = from.startsWith('/app') ? from : '/app/home'
|
|
||||||
if (nextRole.toLowerCase() === 'parent' && target === '/app/home') {
|
|
||||||
target = '/app/parent/home'
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const dash = await fetchDashboardRoute()
|
|
||||||
const route = dash.data?.dashboard?.route
|
|
||||||
const mapped = spaPathFromCiUrl(route)
|
|
||||||
if (mapped) target = mapped
|
|
||||||
} catch {
|
|
||||||
/* keep fallback target */
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(target, { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mt-5">
|
<div className="registration-form container mt-5 mb-5">
|
||||||
<div className="card shadow-sm border-0 mx-auto" style={{ maxWidth: 720 }}>
|
<div className="row justify-content-center">
|
||||||
<div className="card-header bg-primary text-white">
|
<div className="text-center mb-4">
|
||||||
<h4 className="mb-0">Select Account Type</h4>
|
<Link to="/">
|
||||||
|
<img
|
||||||
|
src="/images/alrahma_logo.png"
|
||||||
|
alt="Al Rahma Sunday School"
|
||||||
|
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
|
||||||
<p className="mb-4">
|
<h3 className="text-center text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||||
{user?.name ? `${user.name}, y` : 'Y'}ou have multiple roles. Please select the account
|
Select Your Role to Log In
|
||||||
you'd like to log into:
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-center text-muted mt-2">
|
||||||
|
{user?.name ? `${user.name}, you` : 'You'} have multiple roles. Choose how you want to
|
||||||
|
continue.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-3">
|
<div className="d-flex flex-wrap justify-content-center gap-3 mt-4">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => {
|
||||||
|
const label = role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||||
|
return (
|
||||||
|
<div key={role} className="mb-3 d-grid">
|
||||||
<button
|
<button
|
||||||
key={role}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn ${
|
className="btn btn-lg btn-success"
|
||||||
selectedRole === role ? 'btn-primary' : 'btn-outline-primary'
|
title={`Log in as ${label}`}
|
||||||
}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void continueLogin(role)
|
void continueWithRole(role)
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{busy && busyRole === role
|
{busy && busyRole === role ? 'Opening…' : label}
|
||||||
? 'Opening...'
|
|
||||||
: role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="alert alert-danger py-2 mt-3" role="alert">
|
<div className="alert alert-danger text-center mt-3" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
fetchStudentScoreCard,
|
||||||
|
searchAdministratorDashboard,
|
||||||
|
} from '../../api/session'
|
||||||
|
import type { AdministratorDashboardSearchResponse, StudentScoreCardRow } from '../../api/types'
|
||||||
|
|
||||||
|
const MAX_RESULTS = 50
|
||||||
|
|
||||||
|
function buildSuggestionValue(stu: Record<string, unknown>): string {
|
||||||
|
const schoolId = String(stu.school_id ?? '').trim()
|
||||||
|
const label = `${String(stu.firstname ?? '')} ${String(stu.lastname ?? '')}`.trim()
|
||||||
|
return schoolId !== '' ? `${schoolId} - ${label}` : label
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CI `admin/student_score_card.php` — search, datalist, status badges, score table. */
|
||||||
|
export function AdminStudentScoreCardPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const qParam = searchParams.get('q') ?? ''
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(qParam)
|
||||||
|
const [suggestions, setSuggestions] = useState<Array<Record<string, unknown>>>([])
|
||||||
|
const [results, setResults] = useState<Array<Record<string, unknown>>>([])
|
||||||
|
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
|
||||||
|
const [selectedStudent, setSelectedStudent] = useState<{
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
school_id?: string | null
|
||||||
|
} | null>(null)
|
||||||
|
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const lastAutoQuery = useRef('')
|
||||||
|
|
||||||
|
const suggestionOptions = useMemo(() => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const stu of suggestions) {
|
||||||
|
const v = buildSuggestionValue(stu).trim()
|
||||||
|
if (!v || seen.has(v)) continue
|
||||||
|
seen.add(v)
|
||||||
|
out.push(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQuery(qParam)
|
||||||
|
}, [qParam])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard('')
|
||||||
|
setSuggestions(data.results?.students ?? [])
|
||||||
|
} catch {
|
||||||
|
setSuggestions([])
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!qParam.trim()) return
|
||||||
|
void runSearchQuery(qParam)
|
||||||
|
}, [qParam])
|
||||||
|
|
||||||
|
async function runSearchQuery(q: string) {
|
||||||
|
const trimmed = q.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
try {
|
||||||
|
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard(trimmed)
|
||||||
|
const list = data.results?.students ?? []
|
||||||
|
setResults(list.slice(0, MAX_RESULTS))
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to search students.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch(e?: FormEvent) {
|
||||||
|
e?.preventDefault()
|
||||||
|
const trimmed = query.trim()
|
||||||
|
setSearchParams(trimmed ? { q: trimmed } : {})
|
||||||
|
await runSearchQuery(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScoreCard(studentId: number) {
|
||||||
|
try {
|
||||||
|
const data = await fetchStudentScoreCard(studentId)
|
||||||
|
setSelectedStudentId(studentId)
|
||||||
|
setSelectedStudent(data.student)
|
||||||
|
setRows(data.rows ?? [])
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to load student score card.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAutoRun(currentValue: string) {
|
||||||
|
const val = currentValue.trim()
|
||||||
|
if (!val) return
|
||||||
|
if (suggestionOptions.includes(val) && val !== lastAutoQuery.current) {
|
||||||
|
lastAutoQuery.current = val
|
||||||
|
void runSearchQuery(val)
|
||||||
|
setSearchParams({ q: val })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-4">
|
||||||
|
<style>{`
|
||||||
|
.admin-score-table { margin-top: 4px; }
|
||||||
|
.admin-score-table thead th { position: static; }
|
||||||
|
`}</style>
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-0">Student Score Card (Admin)</h2>
|
||||||
|
<div className="text-muted small">Search for a student, then open their score card.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<form
|
||||||
|
id="studentSearchForm"
|
||||||
|
onSubmit={(e) => void runSearch(e)}
|
||||||
|
>
|
||||||
|
<div className="row g-2 justify-content-center">
|
||||||
|
<div className="col-12 col-md-8 col-lg-6">
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text">Search Student</span>
|
||||||
|
<input
|
||||||
|
id="globalStudentSearch"
|
||||||
|
className="form-control"
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
placeholder="Type School ID or Student Name"
|
||||||
|
list="studentSuggestions"
|
||||||
|
autoComplete="off"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const v = (e.target as HTMLInputElement).value
|
||||||
|
window.setTimeout(() => tryAutoRun(v), 50)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="globalStudentSearchClear"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
title="Clear search"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('')
|
||||||
|
lastAutoQuery.current = ''
|
||||||
|
setSearchParams({})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">Searches all grades.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="d-none">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<datalist id="studentSuggestions">
|
||||||
|
{suggestionOptions.map((v) => (
|
||||||
|
<option key={v} value={v} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{query.trim() && results.length === 0 ? (
|
||||||
|
<div className="alert alert-warning">No students found.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Results</strong>
|
||||||
|
<span className="text-muted small">Showing up to {MAX_RESULTS}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body table-responsive pt-2">
|
||||||
|
<table className="table table-striped table-hover align-middle admin-score-table no-mgmt-sticky" data-no-mgmt-sticky="1">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>School ID</th>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((s) => (
|
||||||
|
<tr key={String(s.id ?? '')}>
|
||||||
|
<td>{String(s.school_id ?? '')}</td>
|
||||||
|
<td>{`${String(s.firstname ?? '')} ${String(s.lastname ?? '')}`.trim()}</td>
|
||||||
|
<td>
|
||||||
|
{Number(s.is_active ?? 0) === 1 ? (
|
||||||
|
<span className="badge text-bg-success">Active</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge text-bg-secondary">Inactive</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openScoreCard(Number(s.id))}
|
||||||
|
>
|
||||||
|
Open Score Card
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedStudentId && selectedStudent ? (
|
||||||
|
<div className="card shadow-sm mt-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<strong>{`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()}</strong>
|
||||||
|
<span className="text-muted ms-2">{selectedStudent.school_id ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-bordered table-sm align-middle mb-0">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>School Year</th>
|
||||||
|
<th>Semester</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Homework</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Participation</th>
|
||||||
|
<th>Quiz</th>
|
||||||
|
<th>Test</th>
|
||||||
|
<th>Attendance</th>
|
||||||
|
<th>PTAP</th>
|
||||||
|
<th>Midterm</th>
|
||||||
|
<th>Semester</th>
|
||||||
|
<th>Year Avg</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={13} className="text-muted">
|
||||||
|
No score rows found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row, index) => (
|
||||||
|
<tr key={`${row.school_year}-${row.semester}-${index}`}>
|
||||||
|
<td>{row.school_year ?? '—'}</td>
|
||||||
|
<td>{row.semester ?? '—'}</td>
|
||||||
|
<td>{row.class_section_name ?? '—'}</td>
|
||||||
|
<td>{row.homework_avg ?? '—'}</td>
|
||||||
|
<td>{row.project_avg ?? '—'}</td>
|
||||||
|
<td>{row.participation_score ?? '—'}</td>
|
||||||
|
<td>{row.quiz_avg ?? '—'}</td>
|
||||||
|
<td>{row.test_avg ?? '—'}</td>
|
||||||
|
<td>{row.attendance_score ?? '—'}</td>
|
||||||
|
<td>{row.ptap_score ?? '—'}</td>
|
||||||
|
<td>{row.midterm_exam_score ?? '—'}</td>
|
||||||
|
<td>{row.semester_score ?? '—'}</td>
|
||||||
|
<td>{row.year_score ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
fetchAdministratorAbsenceForm,
|
||||||
|
submitAdministratorAbsence,
|
||||||
|
} from '../../api/session'
|
||||||
|
import type { AdministratorAbsenceFormResponse } from '../../api/types'
|
||||||
|
|
||||||
|
const DEFAULT_REASON_TYPES: Record<string, string> = {
|
||||||
|
sick: 'Illness / medical',
|
||||||
|
vacation: 'Vacation',
|
||||||
|
personal: 'Personal',
|
||||||
|
professional: 'Professional development',
|
||||||
|
other: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYmd(d: string): string {
|
||||||
|
const t = String(d).trim()
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}/.test(t)) return t
|
||||||
|
const date = new Date(t.slice(0, 10) + 'T12:00:00')
|
||||||
|
if (Number.isNaN(date.getTime())) return t
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormPayload(body: AdministratorAbsenceFormResponse): {
|
||||||
|
adminName: string
|
||||||
|
semester: string
|
||||||
|
schoolYear: string
|
||||||
|
existing: NonNullable<AdministratorAbsenceFormResponse['existing']>
|
||||||
|
available: string[]
|
||||||
|
reasonTypes: Record<string, string>
|
||||||
|
} {
|
||||||
|
const available =
|
||||||
|
body.availableDates?.length ? body.availableDates : body.available_dates ?? []
|
||||||
|
const schoolYear = body.schoolYear ?? body.school_year ?? ''
|
||||||
|
const reasonTypes =
|
||||||
|
body.reason_types && Object.keys(body.reason_types).length > 0
|
||||||
|
? body.reason_types
|
||||||
|
: DEFAULT_REASON_TYPES
|
||||||
|
return {
|
||||||
|
adminName: String(body.admin_name ?? '').trim() || 'Administrator',
|
||||||
|
semester: String(body.semester ?? '').trim(),
|
||||||
|
schoolYear: String(schoolYear).trim(),
|
||||||
|
existing: body.existing ?? [],
|
||||||
|
available: [...new Set(available.map((d) => String(d).slice(0, 10)))].sort(),
|
||||||
|
reasonTypes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator absence requests — `GET`/`POST /api/v1/administrator/absence` (JWT Bearer).
|
||||||
|
* Pair with Laravel `routes/api.php` + OpenAPI docs.
|
||||||
|
*/
|
||||||
|
export function AdministratorAbsencePage() {
|
||||||
|
const [raw, setRaw] = useState<AdministratorAbsenceFormResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
const [selectedDates, setSelectedDates] = useState<Set<string>>(() => new Set())
|
||||||
|
const [reasonType, setReasonType] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
|
||||||
|
const normalized = useMemo(() => (raw ? normalizeFormPayload(raw) : null), [raw])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const body = await fetchAdministratorAbsenceForm()
|
||||||
|
setRaw(body)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to load absence form.')
|
||||||
|
setRaw(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalized) return
|
||||||
|
const firstKey = Object.keys(normalized.reasonTypes)[0] ?? ''
|
||||||
|
setReasonType((current) => current || firstKey)
|
||||||
|
}, [normalized])
|
||||||
|
|
||||||
|
function toggleDate(ymd: string) {
|
||||||
|
setSelectedDates((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(ymd)) next.delete(ymd)
|
||||||
|
else next.add(ymd)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSuccess(null)
|
||||||
|
setError(null)
|
||||||
|
const dates = Array.from(selectedDates).sort()
|
||||||
|
if (dates.length === 0) {
|
||||||
|
setError('Select at least one date to request absence.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setError('Please enter a reason.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await submitAdministratorAbsence({
|
||||||
|
dates,
|
||||||
|
reason_type: reasonType || undefined,
|
||||||
|
reason: reason.trim(),
|
||||||
|
})
|
||||||
|
const msg =
|
||||||
|
res.message ??
|
||||||
|
(typeof res.saved === 'number'
|
||||||
|
? `Saved ${res.saved} date(s).`
|
||||||
|
: 'Absence request submitted.')
|
||||||
|
setSuccess(msg)
|
||||||
|
setSelectedDates(new Set())
|
||||||
|
setReason('')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to submit absence request.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-4" style={{ maxWidth: 920 }}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="h3 mb-1">Administrator absence</h1>
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Request scheduled time away. Uses{' '}
|
||||||
|
<code className="small">GET</code> / <code className="small">POST</code>{' '}
|
||||||
|
<code className="small">/api/v1/administrator/absence</code> with your session JWT.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-muted">Loading…</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<div className="alert alert-success" role="alert">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{normalized ? (
|
||||||
|
<>
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-2 small">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<span className="text-muted">Admin</span>
|
||||||
|
<div className="fw-semibold">{normalized.adminName}</div>
|
||||||
|
</div>
|
||||||
|
{normalized.semester ? (
|
||||||
|
<div className="col-md-4">
|
||||||
|
<span className="text-muted">Semester</span>
|
||||||
|
<div className="fw-semibold">{normalized.semester}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{normalized.schoolYear ? (
|
||||||
|
<div className="col-md-4">
|
||||||
|
<span className="text-muted">School year</span>
|
||||||
|
<div className="fw-semibold">{normalized.schoolYear}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<strong>Recorded absences</strong>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
{normalized.existing.length === 0 ? (
|
||||||
|
<div className="p-3 text-muted small">No absence entries on file for this period.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped mb-0 align-middle">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{normalized.existing.map((row, i) => (
|
||||||
|
<tr key={`${row.date}-${i}`}>
|
||||||
|
<td>{row.date ? formatYmd(String(row.date)) : '—'}</td>
|
||||||
|
<td>{row.status ?? '—'}</td>
|
||||||
|
<td>{row.reason ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">
|
||||||
|
<strong>Request absence</strong>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{normalized.available.length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
No open dates are available to select right now. Check back later or contact the office if you need an
|
||||||
|
exception.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<fieldset disabled={saving}>
|
||||||
|
<legend className="form-label fw-semibold">Dates</legend>
|
||||||
|
<div className="row row-cols-1 row-cols-md-2 g-2 mb-4">
|
||||||
|
{normalized.available.map((ymd) => (
|
||||||
|
<div key={ymd} className="col">
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`abs-${ymd}`}
|
||||||
|
checked={selectedDates.has(ymd)}
|
||||||
|
onChange={() => toggleDate(ymd)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor={`abs-${ymd}`}>
|
||||||
|
{formatYmd(ymd)}
|
||||||
|
<span className="text-muted small ms-1">({ymd})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor="absenceReasonType">
|
||||||
|
Reason type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="absenceReasonType"
|
||||||
|
className="form-select"
|
||||||
|
value={reasonType}
|
||||||
|
onChange={(e) => setReasonType(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(normalized.reasonTypes).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor="absenceReason">
|
||||||
|
Details <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="absenceReason"
|
||||||
|
className="form-control"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Brief explanation for HR / scheduling records."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Submitting…' : 'Submit request'}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
fetchAdministratorAdminAttendance,
|
||||||
|
saveAdministratorAdminAttendance,
|
||||||
|
} from '../../api/session'
|
||||||
|
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||||
|
import type { AdministratorAdminAttendanceRow } from '../../api/types'
|
||||||
|
|
||||||
|
function formatUsDate(ymd: string): string {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||||
|
const [y, m, d] = ymd.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/admins_attendance_form.php` — admin staff daily attendance. */
|
||||||
|
export function AdminsAttendanceFormPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
const date =
|
||||||
|
searchParams.get('date') ?? new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<AdministratorAdminAttendanceRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchAdministratorAdminAttendance({
|
||||||
|
date,
|
||||||
|
semester: semester || null,
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
})
|
||||||
|
if (!cancelled) setRows(data.admins_grid ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load admin attendance.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [date, semester, schoolYear])
|
||||||
|
|
||||||
|
const dateLabel = useMemo(() => formatUsDate(date), [date])
|
||||||
|
|
||||||
|
function setField(
|
||||||
|
userId: number,
|
||||||
|
patch: Partial<Pick<AdministratorAdminAttendanceRow, 'status' | 'reason'>>,
|
||||||
|
) {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
(r.user_id ?? 0) === userId ? { ...r, ...patch } : r,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllPresent() {
|
||||||
|
setRows((prev) => prev.map((r) => ({ ...r, status: 'present' })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
setRows((prev) => prev.map((r) => ({ ...r, status: '', reason: '' })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setMessage(null)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const admins: Record<
|
||||||
|
string,
|
||||||
|
{ status?: string | null; reason?: string | null; role_name?: string | null }
|
||||||
|
> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
const uid = r.user_id ?? 0
|
||||||
|
if (!uid) continue
|
||||||
|
admins[String(uid)] = {
|
||||||
|
status: r.status ?? '',
|
||||||
|
reason: r.reason ?? '',
|
||||||
|
role_name: r.role_name ?? 'Admin',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveAdministratorAdminAttendance({
|
||||||
|
date,
|
||||||
|
semester,
|
||||||
|
school_year: schoolYear,
|
||||||
|
admins,
|
||||||
|
})
|
||||||
|
setMessage('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(next: { school_year: string; semester: string; date: string }) {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (next.school_year) p.set('school_year', next.school_year)
|
||||||
|
if (next.semester) p.set('semester', next.semester)
|
||||||
|
if (next.date) p.set('date', next.date)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<h2 className="mb-0">Admin Attendance</h2>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
to={`${TEACHER_ATTENDANCE_MONTH_PATH}?${new URLSearchParams({ date, semester, school_year: schoolYear }).toString()}`}
|
||||||
|
>
|
||||||
|
← Back to Monthly Attendance
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="row gy-2 gx-3 align-items-end mb-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
applyFilters({
|
||||||
|
school_year: String(fd.get('school_year') ?? ''),
|
||||||
|
semester: String(fd.get('semester') ?? ''),
|
||||||
|
date: String(fd.get('date') ?? ''),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label">School Year</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="school_year"
|
||||||
|
className="form-control"
|
||||||
|
defaultValue={schoolYear}
|
||||||
|
placeholder="e.g. 2025-2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-2">
|
||||||
|
<label className="form-label">Semester</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="semester"
|
||||||
|
className="form-control"
|
||||||
|
defaultValue={semester}
|
||||||
|
placeholder="Fall"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label">Date</label>
|
||||||
|
<input type="date" name="date" className="form-control" defaultValue={date} />
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4 d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-secondary">
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setSearchParams(new URLSearchParams())}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>Admins on {dateLabel}</strong>
|
||||||
|
<span className="badge bg-secondary ms-2">{rows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-success" onClick={markAllPresent}>
|
||||||
|
Mark All Present
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={clearAll}>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted mb-0">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="alert alert-info mb-0">No Admin users found for this day/term.</div>
|
||||||
|
) : (
|
||||||
|
<table className="table table-bordered table-striped align-middle" id="adminsAttTable">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th className="text-center" style={{ width: 70 }}>
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th className="text-center" style={{ width: 180 }}>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="text-center" style={{ width: 220 }}>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, idx) => {
|
||||||
|
const uid = row.user_id ?? 0
|
||||||
|
const name =
|
||||||
|
`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `User#${uid}`
|
||||||
|
const stat = String(row.status ?? '').toLowerCase()
|
||||||
|
return (
|
||||||
|
<tr key={uid || idx}>
|
||||||
|
<td className="text-center">{idx + 1}</td>
|
||||||
|
<td>{name}</td>
|
||||||
|
<td className="text-center">{row.role_name ?? 'Admin'}</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={stat === 'present' || stat === 'absent' || stat === 'late' ? stat : ''}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setField(uid, { status: ev.target.value || null })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="present">Present</option>
|
||||||
|
<option value="absent">Absent</option>
|
||||||
|
<option value="late">Late</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Optional reason"
|
||||||
|
value={row.reason ?? ''}
|
||||||
|
onChange={(ev) => setField(uid, { reason: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-footer d-flex justify-content-end">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || rows.length === 0}>
|
||||||
|
{saving ? 'Saving…' : 'Save Admins Attendance'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { type FormEvent, useId, useState, type ChangeEvent } from 'react'
|
||||||
|
import { updateAdministratorAttendance } from '../../api/session'
|
||||||
|
|
||||||
|
export type AttendanceEditModalProps = {
|
||||||
|
studentId: string | number
|
||||||
|
studentFirst?: string | null
|
||||||
|
studentLast?: string | null
|
||||||
|
classId: number
|
||||||
|
sectionId: number
|
||||||
|
/** Called after successful save */
|
||||||
|
onSaved?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port of `Views/attendance/edit_modal.php` — embedded in flows that need inline attendance edits.
|
||||||
|
* Mount inside a Bootstrap modal wrapper or render with `show` pattern.
|
||||||
|
*/
|
||||||
|
export function AttendanceEditModalBody({
|
||||||
|
studentId,
|
||||||
|
studentFirst,
|
||||||
|
studentLast,
|
||||||
|
classId,
|
||||||
|
sectionId,
|
||||||
|
onSaved,
|
||||||
|
}: AttendanceEditModalProps) {
|
||||||
|
const uid = useId()
|
||||||
|
const statusId = `status-${uid}`
|
||||||
|
const dateId = `date-${uid}`
|
||||||
|
const reasonId = `reason-${uid}`
|
||||||
|
const reportedId = `reported-${uid}`
|
||||||
|
|
||||||
|
const [status, setStatus] = useState('present')
|
||||||
|
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [reported, setReported] = useState('0')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const name = `${studentFirst ?? ''} ${studentLast ?? ''}`.trim() || `Student #${studentId}`
|
||||||
|
|
||||||
|
const reasonRequired = status === 'absent' || status === 'late'
|
||||||
|
const showReported = status === 'absent'
|
||||||
|
|
||||||
|
function onStatusChange(ev: ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const v = ev.target.value
|
||||||
|
setStatus(v)
|
||||||
|
if (v !== 'absent') setReported('0')
|
||||||
|
if (v === 'present') setReason('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateAdministratorAttendance({
|
||||||
|
class_section_id: sectionId,
|
||||||
|
student_id: Number(studentId),
|
||||||
|
class_id: classId,
|
||||||
|
status: status as 'present' | 'absent' | 'late',
|
||||||
|
date,
|
||||||
|
reason: reasonRequired ? reason : '',
|
||||||
|
is_reported: showReported ? (reported === '1' ? 'yes' : 'no') : undefined,
|
||||||
|
})
|
||||||
|
onSaved?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor={statusId}>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={statusId}
|
||||||
|
className="form-select attendance-status"
|
||||||
|
required
|
||||||
|
value={status}
|
||||||
|
onChange={onStatusChange}
|
||||||
|
>
|
||||||
|
<option value="present">Present</option>
|
||||||
|
<option value="late">Late</option>
|
||||||
|
<option value="absent">Absent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor={dateId}>
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={dateId}
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
value={date}
|
||||||
|
onChange={(ev) => setDate(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor={reasonId}>
|
||||||
|
Reason
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={reasonId}
|
||||||
|
name="reason"
|
||||||
|
className="form-control attendance-reason"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Enter reason for absence or lateness"
|
||||||
|
value={reason}
|
||||||
|
disabled={!reasonRequired}
|
||||||
|
required={reasonRequired}
|
||||||
|
onChange={(ev) => setReason(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mb-3${showReported ? '' : ' d-none'}`}>
|
||||||
|
<label className="form-label" htmlFor={reportedId}>
|
||||||
|
Absence Reported?
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={reportedId}
|
||||||
|
className="form-select"
|
||||||
|
value={reported}
|
||||||
|
onChange={(ev) => setReported(ev.target.value)}
|
||||||
|
>
|
||||||
|
<option value="0">No</option>
|
||||||
|
<option value="1">Yes</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text">Select “Yes” if the parent/guardian reported this absence.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="student_id" value={String(studentId)} />
|
||||||
|
|
||||||
|
<div className="modal-footer px-0 pb-0">
|
||||||
|
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="small text-muted mb-0 mt-2">{name}</p>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { fetchAttendanceStudentViolationsView } from '../../api/session'
|
||||||
|
import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths'
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/view.php` — student violation history. */
|
||||||
|
export function AttendanceStudentViolationsViewPage() {
|
||||||
|
const { studentId } = useParams()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const code = searchParams.get('code')
|
||||||
|
const incidentDate = searchParams.get('incident_date')
|
||||||
|
const includeNotified = searchParams.get('include_notified')
|
||||||
|
|
||||||
|
const [data, setData] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!studentId) return
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetchAttendanceStudentViolationsView(studentId, {
|
||||||
|
code,
|
||||||
|
incident_date: incidentDate,
|
||||||
|
include_notified: includeNotified ?? undefined,
|
||||||
|
})
|
||||||
|
if (!cancelled) setData(res)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [studentId, code, incidentDate, includeNotified])
|
||||||
|
|
||||||
|
const title = typeof data?.title === 'string' ? data.title : 'Attendance History'
|
||||||
|
const attendance = (data?.attendance as Array<Record<string, unknown>> | undefined) ?? []
|
||||||
|
const showViolationSummary = Boolean(data?.show_violation_summary)
|
||||||
|
const viCode = data?.violation_code
|
||||||
|
const viDateRaw = String(data?.violation_date ?? '')
|
||||||
|
const viNotif = data?.violation_notified
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<h2 className="text-center mt-2 mb-3">{title}</h2>
|
||||||
|
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<Link to={ATTENDANCE_VIOLATIONS_PENDING_PATH} className="btn btn-secondary mb-3">
|
||||||
|
Back to Attendance
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p className="text-muted small mb-3">
|
||||||
|
Showing absences and lates for this student within the violation window.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h5 className="card-title mb-3">Attendance Violations</h5>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<div className="alert alert-danger">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped attendance-history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Notified</th>
|
||||||
|
<th>Reported</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{showViolationSummary && (viCode || viDateRaw) ? (
|
||||||
|
<tr className="table-info">
|
||||||
|
<td>{viDateRaw ? formatMdY(viDateRaw.slice(0, 10)) : '—'}</td>
|
||||||
|
<td>{String(viCode ?? '—')}</td>
|
||||||
|
<td>Violation summary</td>
|
||||||
|
<td>{truthy(viNotif) ? 'Yes' : 'No'}</td>
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{attendance.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center text-muted">
|
||||||
|
No attendance violations found for this student.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
attendance.map((record, i) => (
|
||||||
|
<ViolationRow key={i} record={record} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function truthy(v: unknown): boolean {
|
||||||
|
if (v === true || v === 1) return true
|
||||||
|
if (typeof v === 'string') return ['yes', '1', 'true'].includes(v.toLowerCase().trim())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViolationRow({ record }: { record: Record<string, unknown> }) {
|
||||||
|
const dateStr = String(record.date ?? '').slice(0, 10)
|
||||||
|
const dateLabel = dateStr ? formatMdY(dateStr) : '—'
|
||||||
|
const statusRaw = String(record.status ?? '').toLowerCase().trim()
|
||||||
|
const reason = String(record.reason ?? '').trim()
|
||||||
|
const isNotified = truthy(record.is_notified)
|
||||||
|
const isReported = truthy(record.is_reported)
|
||||||
|
|
||||||
|
const isAbs =
|
||||||
|
statusRaw === 'absent' ||
|
||||||
|
statusRaw === 'abs' ||
|
||||||
|
statusRaw === 'a' ||
|
||||||
|
statusRaw.startsWith('abs')
|
||||||
|
const isLate = statusRaw === 'late' || statusRaw === 'l' || statusRaw.startsWith('late')
|
||||||
|
|
||||||
|
let status = statusRaw !== '' ? capitalize(statusRaw) : 'Unknown'
|
||||||
|
if (isAbs) status = isReported ? 'Reported Absence' : 'Unreported Absence'
|
||||||
|
else if (isLate) status = isReported ? 'Reported Late' : 'Unreported Late'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{dateLabel}</td>
|
||||||
|
<td>{status}</td>
|
||||||
|
<td>{reason !== '' ? reason : 'No reason provided'}</td>
|
||||||
|
<td>{isNotified ? 'Yes' : 'No'}</td>
|
||||||
|
<td>{isReported ? 'Yes' : 'No'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMdY(ymd: string): string {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||||
|
const [y, m, d] = ymd.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
deleteAttendanceCommentTemplate,
|
||||||
|
fetchAttendanceCommentTemplates,
|
||||||
|
saveAttendanceCommentTemplate,
|
||||||
|
} from '../../api/session'
|
||||||
|
import type { AttendanceCommentTemplate } from '../../api/types'
|
||||||
|
|
||||||
|
/** Port of `Views/attendance_templates/index.php` — comment templates CRUD. */
|
||||||
|
export function AttendanceTemplatesIndexPage() {
|
||||||
|
const [templates, setTemplates] = useState<AttendanceCommentTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [modal, setModal] = useState<AttendanceCommentTemplate | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceCommentTemplates()
|
||||||
|
if (!cancelled) setTemplates(data.templates ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load templates.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function refreshTemplates() {
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceCommentTemplates()
|
||||||
|
setTemplates(data.templates ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load templates.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(t?: AttendanceCommentTemplate) {
|
||||||
|
setModal(
|
||||||
|
t ?? {
|
||||||
|
id: undefined,
|
||||||
|
min_score: undefined,
|
||||||
|
max_score: undefined,
|
||||||
|
template_text: '',
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!modal) return
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const data: Record<string, string> = {}
|
||||||
|
fd.forEach((v, k) => {
|
||||||
|
data[k] = String(v)
|
||||||
|
})
|
||||||
|
if (modal.id != null) data.id = String(modal.id)
|
||||||
|
data.is_active = fd.get('is_active') === 'on' ? 'on' : ''
|
||||||
|
try {
|
||||||
|
await saveAttendanceCommentTemplate(data)
|
||||||
|
setModal(null)
|
||||||
|
await refreshTemplates()
|
||||||
|
} catch {
|
||||||
|
setError('Save failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: number | undefined) {
|
||||||
|
if (id == null) return
|
||||||
|
if (!confirm('Delete this template?')) return
|
||||||
|
try {
|
||||||
|
await deleteAttendanceCommentTemplate(id)
|
||||||
|
await refreshTemplates()
|
||||||
|
} catch {
|
||||||
|
setError('Delete failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-4">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h2 className="mt-2 mb-3">Attendance Comment Templates</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<button type="button" className="btn btn-success" onClick={() => openModal()}>
|
||||||
|
Add New Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Min Score</th>
|
||||||
|
<th>Max Score</th>
|
||||||
|
<th>Template Text</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center text-muted">
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center text-muted">
|
||||||
|
No templates.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
templates.map((t, ti) => (
|
||||||
|
<tr key={t.id ?? `tpl-${ti}`}>
|
||||||
|
<td>{t.id ?? '—'}</td>
|
||||||
|
<td>{t.min_score ?? '—'}</td>
|
||||||
|
<td>{t.max_score ?? '—'}</td>
|
||||||
|
<td style={{ maxWidth: 420, whiteSpace: 'pre-wrap' }}>{t.template_text ?? ''}</td>
|
||||||
|
<td>{t.is_active ? 'Yes' : 'No'}</td>
|
||||||
|
<td className="d-flex gap-1">
|
||||||
|
<button type="button" className="btn btn-sm btn-warning" onClick={() => openModal(t)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => onDelete(t.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal ? (
|
||||||
|
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<form className="modal-content" onSubmit={onSave}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">{modal.id ? 'Edit Template' : 'New Template'}</h5>
|
||||||
|
<button type="button" className="btn-close" aria-label="Close" onClick={() => setModal(null)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<input type="hidden" name="id" value={modal.id ?? ''} />
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Min Score</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="min_score"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
defaultValue={modal.min_score ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Max Score</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="max_score"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
defaultValue={modal.max_score ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Template Text</label>
|
||||||
|
<textarea
|
||||||
|
name="template_text"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
defaultValue={modal.template_text ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-check mb-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="is_active"
|
||||||
|
id="is_active"
|
||||||
|
defaultChecked={Boolean(modal.is_active)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="is_active">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setModal(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { fetchAttendanceTrackingStudents, recordAttendanceTrackingEntry } from '../../api/session'
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/attendance_tracking.php` — record + student summary list. */
|
||||||
|
export function AttendanceTrackingPage() {
|
||||||
|
const [title, setTitle] = useState('Attendance Tracking')
|
||||||
|
const [students, setStudents] = useState<Array<Record<string, unknown>>>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [studentId, setStudentId] = useState('')
|
||||||
|
const [recDate, setRecDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
const [isPresent, setIsPresent] = useState('1')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [isReported, setIsReported] = useState(false)
|
||||||
|
const [notified, setNotified] = useState(false)
|
||||||
|
const [flash, setFlash] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceTrackingStudents()
|
||||||
|
if (!cancelled) {
|
||||||
|
setTitle(typeof data.title === 'string' ? data.title : 'Attendance Tracking')
|
||||||
|
setStudents(Array.isArray(data.students) ? data.students : [])
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onRecord(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFlash(null)
|
||||||
|
try {
|
||||||
|
await recordAttendanceTrackingEntry({
|
||||||
|
student_id: Number(studentId),
|
||||||
|
date: recDate,
|
||||||
|
is_present: isPresent === '1',
|
||||||
|
reason: isPresent === '0' ? reason : '',
|
||||||
|
is_reported: isReported ? 1 : 0,
|
||||||
|
notified: notified ? 1 : 0,
|
||||||
|
})
|
||||||
|
setFlash('Recorded.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Record failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showReason = isPresent === '0'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<h2 className="text-center mt-2 mb-3">{title}</h2>
|
||||||
|
{flash ? <div className="alert alert-success">{flash}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-header">
|
||||||
|
<h5 className="mb-0">Record Attendance</h5>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={onRecord}>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-4 mb-3">
|
||||||
|
<label className="form-label" htmlFor="student_id">
|
||||||
|
Student
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="student_id"
|
||||||
|
className="form-select"
|
||||||
|
required
|
||||||
|
value={studentId}
|
||||||
|
onChange={(ev) => setStudentId(ev.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select Student</option>
|
||||||
|
{students.map((s, i) => {
|
||||||
|
const id = String(s.id ?? s.student_id ?? '')
|
||||||
|
const name = String((s.name ?? `${s.firstname ?? ''} ${s.lastname ?? ''}`.trim()) || id)
|
||||||
|
return (
|
||||||
|
<option key={id || String(i)} value={id}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 mb-3">
|
||||||
|
<label className="form-label" htmlFor="date">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
value={recDate}
|
||||||
|
onChange={(ev) => setRecDate(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 mb-3">
|
||||||
|
<label className="form-label" htmlFor="is_present">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="is_present"
|
||||||
|
className="form-select"
|
||||||
|
required
|
||||||
|
value={isPresent}
|
||||||
|
onChange={(ev) => setIsPresent(ev.target.value)}
|
||||||
|
>
|
||||||
|
<option value="1">Present</option>
|
||||||
|
<option value="0">Absent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2 mb-3 d-flex align-items-end">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReason ? (
|
||||||
|
<div className="row mt-2">
|
||||||
|
<div className="col-md-6 mb-3">
|
||||||
|
<label className="form-label" htmlFor="reason">
|
||||||
|
Reason for Absence
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="reason"
|
||||||
|
className="form-control"
|
||||||
|
rows={2}
|
||||||
|
value={reason}
|
||||||
|
onChange={(ev) => setReason(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 mb-3 d-flex align-items-end gap-4 flex-wrap">
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="is_reported"
|
||||||
|
checked={isReported}
|
||||||
|
onChange={(ev) => setIsReported(ev.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="is_reported">
|
||||||
|
Reported Absence
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="notified"
|
||||||
|
checked={notified}
|
||||||
|
onChange={(ev) => setNotified(ev.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="notified">
|
||||||
|
Parent Notified
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h5 className="mb-0">Student List</h5>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted mb-0">Loading students…</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Student Name</th>
|
||||||
|
<th>Total Absences</th>
|
||||||
|
<th>Last Absence Date</th>
|
||||||
|
<th>Last Absence Reason</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{students.map((student, idx) => {
|
||||||
|
const sid = String(student.id ?? student.student_id ?? idx)
|
||||||
|
const name = String(student.name ?? `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim())
|
||||||
|
const totalAbs = student.total_absences
|
||||||
|
const last = student.last_absence as Record<string, unknown> | undefined
|
||||||
|
const lastDate = last?.date ? String(last.date).slice(0, 10) : ''
|
||||||
|
const lastReason = last?.reason != null ? String(last.reason) : ''
|
||||||
|
return (
|
||||||
|
<tr key={sid}>
|
||||||
|
<td>{name}</td>
|
||||||
|
<td>{totalAbs != null ? String(totalAbs) : '—'}</td>
|
||||||
|
<td>{lastDate ? formatMdY(lastDate) : 'N/A'}</td>
|
||||||
|
<td>{lastReason || 'N/A'}</td>
|
||||||
|
<td>
|
||||||
|
<Link className="btn btn-sm btn-info" to={`/app/administrator/attendance/view/${sid}`}>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMdY(ymd: string): string {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||||
|
const [y, m, d] = ymd.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { type FormEvent, useMemo } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
|
||||||
|
ATTENDANCE_VIOLATIONS_PENDING_PATH,
|
||||||
|
} from './attendancePaths'
|
||||||
|
|
||||||
|
/** Landing screen for attendance violations — links to pending / notified lists (same APIs as subpages). */
|
||||||
|
export function AttendanceViolationsHubPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
|
||||||
|
const filterQuery = useMemo(() => {
|
||||||
|
const s = searchParams.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
if (sem) p.set('semester', sem)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid px-0">
|
||||||
|
<h2 className="text-center mt-4 mb-3">Attendance Violations</h2>
|
||||||
|
<p className="text-center text-muted small mb-4">
|
||||||
|
Review students who have crossed absence or late thresholds. Data loads on each list via the API.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="d-flex justify-content-center mb-4">
|
||||||
|
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label small mb-0">School Year</label>
|
||||||
|
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label small mb-0">Semester</label>
|
||||||
|
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button type="submit" className="btn btn-sm btn-secondary">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3 justify-content-center px-2 pb-4">
|
||||||
|
<div className="col-12 col-md-5">
|
||||||
|
<div className="card border-0 rounded-3 shadow-sm h-100">
|
||||||
|
<div className="card-body d-flex flex-column">
|
||||||
|
<h5 className="card-title">Pending</h5>
|
||||||
|
<p className="card-text small text-muted flex-grow-1">
|
||||||
|
Students who need notification or follow-up per policy (team notify, auto email, etc.).
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="btn btn-primary"
|
||||||
|
to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`}
|
||||||
|
>
|
||||||
|
Open pending list
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-5">
|
||||||
|
<div className="card border-0 rounded-3 shadow-sm h-100">
|
||||||
|
<div className="card-body d-flex flex-column">
|
||||||
|
<h5 className="card-title">Notified</h5>
|
||||||
|
<p className="card-text small text-muted flex-grow-1">
|
||||||
|
Students whose parents were already notified; add or edit notes as needed.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
|
||||||
|
>
|
||||||
|
Open notified list
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { fetchAttendanceComposeEmailContext, sendAttendanceViolationEmail } from '../../api/session'
|
||||||
|
import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths'
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/compose_email.php` — rich body via large textarea (TinyMCE optional later). */
|
||||||
|
export function ComposeAttendanceEmailPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const studentId = searchParams.get('student_id') ?? ''
|
||||||
|
const code = searchParams.get('code') ?? ''
|
||||||
|
const variant = searchParams.get('variant') ?? ''
|
||||||
|
const incidentDate =
|
||||||
|
searchParams.get('incident_date') ?? new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [bodyHtml, setBodyHtml] = useState('')
|
||||||
|
const [studentName, setStudentName] = useState('')
|
||||||
|
const [secondaryEmail, setSecondaryEmail] = useState('')
|
||||||
|
const [secondaryName, setSecondaryName] = useState('')
|
||||||
|
const [schoolYear, setSchoolYear] = useState('')
|
||||||
|
const [semester, setSemester] = useState('')
|
||||||
|
const missingParams = !studentId || !code
|
||||||
|
const [loading, setLoading] = useState(!missingParams)
|
||||||
|
const [error, setError] = useState<string | null>(
|
||||||
|
missingParams ? 'Missing student_id or code query parameters.' : null,
|
||||||
|
)
|
||||||
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (missingParams) return
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const ctx = await fetchAttendanceComposeEmailContext({
|
||||||
|
studentId,
|
||||||
|
code,
|
||||||
|
variant: variant || null,
|
||||||
|
incidentDate,
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
setTo(String(ctx.parent_email ?? ctx.to ?? ''))
|
||||||
|
setSubject(String(ctx.subject ?? ''))
|
||||||
|
const initial = String(ctx.body_html ?? ctx.body_text ?? '')
|
||||||
|
setBodyHtml(initial)
|
||||||
|
setStudentName(String(ctx.student_name ?? ''))
|
||||||
|
setSecondaryEmail(String(ctx.secondary_email ?? ''))
|
||||||
|
setSecondaryName(String(ctx.secondary_name ?? ''))
|
||||||
|
setSchoolYear(String(ctx.school_year ?? ''))
|
||||||
|
setSemester(String(ctx.semester ?? ''))
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load compose context.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [studentId, code, variant, incidentDate, missingParams])
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setMsg(null)
|
||||||
|
try {
|
||||||
|
await sendAttendanceViolationEmail({
|
||||||
|
student_id: studentId,
|
||||||
|
code,
|
||||||
|
variant,
|
||||||
|
incident_date: incidentDate,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body_html: bodyHtml,
|
||||||
|
})
|
||||||
|
setMsg('Email sent.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Send failed.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container my-4">
|
||||||
|
<p className="text-muted">Loading compose form…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container my-4">
|
||||||
|
<h2 className="text-center mb-3">Compose Attendance Email</h2>
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
{msg ? <div className="alert alert-success">{msg}</div> : null}
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>{studentName}</strong>
|
||||||
|
<span className="badge bg-secondary ms-2">
|
||||||
|
{code}
|
||||||
|
{variant ? ` • ${variant}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="badge bg-primary">School Year: {schoolYear}</span>
|
||||||
|
{semester ? (
|
||||||
|
<span className="badge bg-info text-dark ms-1">Semester: {semester}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">To</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
value={to}
|
||||||
|
onChange={(ev) => setTo(ev.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="form-text">Primary parent email (you can change it here).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{secondaryEmail ? (
|
||||||
|
<div className="alert alert-light border d-flex align-items-start mb-3">
|
||||||
|
<div className="me-2">
|
||||||
|
<i className="bi bi-files" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Second Parent (CC):</strong> {secondaryEmail}
|
||||||
|
{secondaryName ? ` — ${secondaryName}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={subject}
|
||||||
|
onChange={(ev) => setSubject(ev.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Body (HTML allowed)</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control font-monospace"
|
||||||
|
rows={14}
|
||||||
|
value={bodyHtml}
|
||||||
|
onChange={(ev) => setBodyHtml(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="form-text">Paste HTML or plain text; backend may sanitize.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link to={ATTENDANCE_VIOLATIONS_PENDING_PATH} className="btn btn-outline-secondary">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? 'Sending…' : 'Send Email'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { createEarlyDismissal, fetchEarlyDismissalStudentOptions } from '../../api/session'
|
||||||
|
import { EARLY_DISMISSALS_PATH } from './attendancePaths'
|
||||||
|
|
||||||
|
type StudentOpt = {
|
||||||
|
id: number
|
||||||
|
firstname: string
|
||||||
|
lastname: string
|
||||||
|
section?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/early_dismissals_add.php` */
|
||||||
|
export function EarlyDismissalsAddPage() {
|
||||||
|
const [students, setStudents] = useState<StudentOpt[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [studentId, setStudentId] = useState<number | null>(null)
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
const [dismissTime, setDismissTime] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchEarlyDismissalStudentOptions()
|
||||||
|
if (cancelled) return
|
||||||
|
const raw = data.students ?? []
|
||||||
|
setStudents(
|
||||||
|
raw
|
||||||
|
.map((s) => ({
|
||||||
|
id: Number(s.id ?? 0),
|
||||||
|
firstname: String(s.firstname ?? ''),
|
||||||
|
lastname: String(s.lastname ?? ''),
|
||||||
|
section: s.class_section_name ?? null,
|
||||||
|
}))
|
||||||
|
.filter((s) => s.id > 0),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load students.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const enriched = useMemo(() => {
|
||||||
|
const norm = (s: string) => s.toLowerCase().trim()
|
||||||
|
return students.map((s) => {
|
||||||
|
const display = `${s.firstname} ${s.lastname}${s.section ? ` — ${s.section}` : ''}`
|
||||||
|
const key = `${norm(s.firstname)} ${norm(s.lastname)} ${norm(s.section ?? '')}`
|
||||||
|
return { ...s, display, key }
|
||||||
|
})
|
||||||
|
}, [students])
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
const q = query.toLowerCase().trim()
|
||||||
|
if (!q) return []
|
||||||
|
return enriched.filter((s) => s.key.includes(q)).slice(0, 20)
|
||||||
|
}, [enriched, query])
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setMsg(null)
|
||||||
|
if (!studentId) {
|
||||||
|
setError('Please select a student from the list.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createEarlyDismissal({
|
||||||
|
student_id: studentId,
|
||||||
|
date,
|
||||||
|
dismiss_time: dismissTime,
|
||||||
|
reason: reason || null,
|
||||||
|
})
|
||||||
|
setMsg('Early dismissal saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="mb-0">Add Early Dismissal</h2>
|
||||||
|
<Link to={EARLY_DISMISSALS_PATH} className="btn btn-outline-secondary">
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
{msg ? <div className="alert alert-success">{msg}</div> : null}
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="row gy-3">
|
||||||
|
<div className="col-md-5 position-relative">
|
||||||
|
<label className="form-label">Student</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control${error && !studentId ? ' is-invalid' : ''}`}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Type student name to search…"
|
||||||
|
value={label}
|
||||||
|
onChange={(ev) => {
|
||||||
|
setLabel(ev.target.value)
|
||||||
|
setStudentId(null)
|
||||||
|
setQuery(ev.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{query.trim() && matches.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className="list-group position-absolute w-100 shadow-sm"
|
||||||
|
style={{ zIndex: 1000, maxHeight: 240, overflow: 'auto' }}
|
||||||
|
>
|
||||||
|
{matches.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
className="list-group-item list-group-item-action"
|
||||||
|
onClick={() => {
|
||||||
|
setStudentId(m.id)
|
||||||
|
setLabel(m.display)
|
||||||
|
setQuery('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.display}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="form-text">Start typing first or last name, then pick from the list.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Date (Sunday)</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
value={date}
|
||||||
|
onChange={(ev) => setDate(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-2">
|
||||||
|
<label className="form-label">Dismissal Time</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
value={dismissTime}
|
||||||
|
onChange={(ev) => setDismissTime(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Reason (optional)</label>
|
||||||
|
<textarea
|
||||||
|
name="reason"
|
||||||
|
className="form-control"
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g., Family appointment"
|
||||||
|
value={reason}
|
||||||
|
onChange={(ev) => setReason(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
Save Early Dismissal
|
||||||
|
</button>
|
||||||
|
<Link to={EARLY_DISMISSALS_PATH} className="btn btn-outline-secondary">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 small text-muted">
|
||||||
|
Note: Early dismissals do not change daily present/absent status; they are shown for staff awareness and
|
||||||
|
pickup coordination.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session'
|
||||||
|
import { apiUrl } from '../../lib/apiOrigin'
|
||||||
|
import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths'
|
||||||
|
|
||||||
|
function badgeClass(status: string): string {
|
||||||
|
if (status === 'processed') return 'success'
|
||||||
|
if (status === 'seen') return 'info'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/early_dismissals.php` */
|
||||||
|
export function EarlyDismissalsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
|
||||||
|
const [data, setData] = useState<Awaited<ReturnType<typeof fetchEarlyDismissals>> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [flash, setFlash] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetchEarlyDismissals({
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
semester: semester || null,
|
||||||
|
})
|
||||||
|
if (!cancelled) setData(res)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load early dismissals.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [schoolYear, semester])
|
||||||
|
|
||||||
|
async function onSignatureSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
try {
|
||||||
|
await uploadEarlyDismissalSignature(fd)
|
||||||
|
setFlash('Signature uploaded.')
|
||||||
|
const res = await fetchEarlyDismissals({
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
semester: semester || null,
|
||||||
|
})
|
||||||
|
setData(res)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = data?.groups ?? {}
|
||||||
|
const sigMap = data?.signatureByDate ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-0">Early Dismissals</h2>
|
||||||
|
<small className="text-muted">Parent-submitted early dismissal requests</small>
|
||||||
|
</div>
|
||||||
|
<Link to={EARLY_DISMISSALS_NEW_PATH} className="btn btn-primary">
|
||||||
|
<i className="bi bi-plus-circle me-1" aria-hidden />
|
||||||
|
Add Early Dismissal
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="row gy-2 gx-3 align-items-end mb-4"
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
const fd = new FormData(ev.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
if (sem) p.set('semester', sem)
|
||||||
|
setSearchParams(p)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label">School Year</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="school_year"
|
||||||
|
className="form-control"
|
||||||
|
defaultValue={schoolYear}
|
||||||
|
placeholder="e.g. 2025-2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Semester</label>
|
||||||
|
<select name="semester" className="form-select" defaultValue={semester}>
|
||||||
|
<option value="">All Semesters</option>
|
||||||
|
<option value="Fall">Fall</option>
|
||||||
|
<option value="Spring">Spring</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-secondary mt-4">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<Link className="btn btn-outline-secondary mt-4" to={EARLY_DISMISSALS_PATH}>
|
||||||
|
Reset
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{flash ? <div className="alert alert-success">{flash}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : Object.keys(groups).length === 0 ? (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
No early dismissals found for the selected school year{semester ? ' and semester' : ''}.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(groups).map(([date, rows]) => {
|
||||||
|
const dateLabel =
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(date) && date !== 'Unknown'
|
||||||
|
? formatMdY(date)
|
||||||
|
: date
|
||||||
|
const sig = sigMap[date]
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm mb-3" key={date}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<strong>{dateLabel}</strong>
|
||||||
|
{date !== 'Unknown' ? (
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
{sig?.filename ? (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
href={apiUrl(`/api/v1/files/early-dismissal-signatures/${encodeURIComponent(sig.filename ?? '')}`)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View Signature
|
||||||
|
</a>
|
||||||
|
{sig.original_name ? (
|
||||||
|
<span className="small text-muted">{sig.original_name}</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="small text-muted">No signature uploaded</span>
|
||||||
|
)}
|
||||||
|
<form className="d-flex flex-wrap align-items-center gap-2" onSubmit={onSignatureSubmit}>
|
||||||
|
<input type="hidden" name="report_date" value={date} />
|
||||||
|
<input type="hidden" name="school_year" value={schoolYear} />
|
||||||
|
<input type="hidden" name="semester" value={semester} />
|
||||||
|
<input type="hidden" name="return_url" value={typeof window !== 'undefined' ? window.location.href : ''} />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="signature_file"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.gif,.pdf"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-sm btn-primary">
|
||||||
|
{sig?.filename ? 'Replace' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Class/Section</th>
|
||||||
|
<th>Dismissal Time</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(rows as Array<Record<string, unknown>>).map((r, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{`${r.firstname ?? ''} ${r.lastname ?? ''}`.trim()}</td>
|
||||||
|
<td>{String(r.class_section_name ?? '—')}</td>
|
||||||
|
<td>
|
||||||
|
{r.dismiss_time ? String(String(r.dismiss_time).slice(0, 5)) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ maxWidth: 420, whiteSpace: 'normal' }}>{String(r.reason ?? '')}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge bg-${badgeClass(String(r.status ?? '').toLowerCase())}`}>
|
||||||
|
{String(r.status ?? 'new').replace(/^\w/, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMdY(ymd: string): string {
|
||||||
|
const [y, m, d] = ymd.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { fetchParentAttendanceReportsAdmin } from '../../api/session'
|
||||||
|
import type { ParentAttendanceAdminReportRow } from '../../api/types'
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/parent_reports.php` */
|
||||||
|
export function ParentAttendanceReportsAdminPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
const start = searchParams.get('start') ?? new Date().toISOString().slice(0, 10)
|
||||||
|
const end = searchParams.get('end') ?? ''
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentAttendanceReportsAdmin({
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
semester: semester || null,
|
||||||
|
start: start || null,
|
||||||
|
end: end || null,
|
||||||
|
})
|
||||||
|
if (!cancelled) setRows(data.rows ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load reports.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [schoolYear, semester, start, end])
|
||||||
|
|
||||||
|
function apply(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
const st = String(fd.get('start') ?? '')
|
||||||
|
const en = String(fd.get('end') ?? '')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
if (sem) p.set('semester', sem)
|
||||||
|
if (st) p.set('start', st)
|
||||||
|
if (en) p.set('end', en)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="mb-0">Parent Attendance Reports</h2>
|
||||||
|
<small className="text-muted">Submitted by parents (absent/late/early dismissal)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">School Year</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="school_year"
|
||||||
|
className="form-control"
|
||||||
|
defaultValue={schoolYear}
|
||||||
|
placeholder="e.g. 2025-2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<label className="form-label">Semester</label>
|
||||||
|
<select name="semester" className="form-select" defaultValue={semester}>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="Fall">Fall</option>
|
||||||
|
<option value="Spring">Spring</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<label className="form-label">Start Date</label>
|
||||||
|
<input type="date" name="start" className="form-control" defaultValue={start} />
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<label className="form-label">End Date</label>
|
||||||
|
<input type="date" name="end" className="form-control" defaultValue={end} />
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-secondary mt-4">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<Link className="btn btn-outline-secondary mt-4" to="/app/administrator/attendance/parent-reports">
|
||||||
|
Reset
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted mb-0">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table className="table table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Class/Section</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-center text-muted">
|
||||||
|
No reports found for the selected range.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((r, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{formatCellDate(r.report_date)}</td>
|
||||||
|
<td>{`${String(r['firstname'] ?? '')} ${String(r['lastname'] ?? '')}`.trim()}</td>
|
||||||
|
<td>{String(r.class_section_name ?? '—')}</td>
|
||||||
|
<td className="text-capitalize">{String(r.type ?? '').replace(/_/g, ' ')}</td>
|
||||||
|
<td>{timeCell(r)}</td>
|
||||||
|
<td style={{ maxWidth: 360, whiteSpace: 'normal' }}>{String(r.reason ?? '')}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`badge bg-${
|
||||||
|
r.status === 'processed' ? 'success' : r.status === 'seen' ? 'info' : 'secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{String(r.status ?? 'new').replace(/^\w/, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCellDate(v: unknown): string {
|
||||||
|
if (v == null || v === '') return ''
|
||||||
|
const s = String(v).slice(0, 10)
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return String(v)
|
||||||
|
const [y, m, d] = s.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeCell(r: ParentAttendanceAdminReportRow): string {
|
||||||
|
const t = String(r.type ?? '')
|
||||||
|
const arrival = r['arrival_time']
|
||||||
|
const dismiss = r['dismiss_time']
|
||||||
|
if (t === 'late' && arrival) return `Arrival: ${String(arrival).slice(0, 5)}`
|
||||||
|
if (t === 'early_dismissal' && dismiss) return `Dismiss: ${String(dismiss).slice(0, 5)}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
|
||||||
|
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||||
|
import type { TeacherStaffAttendanceRow } from '../../api/types'
|
||||||
|
|
||||||
|
function formatUsDate(ymd: string): string {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||||
|
const [y, m, d] = ymd.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/teacher_attendance_form.php` */
|
||||||
|
export function TeacherAttendanceFormPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
const date =
|
||||||
|
searchParams.get('date') ?? new Date().toISOString().slice(0, 10)
|
||||||
|
const classSectionId = Number(searchParams.get('class_section_id') ?? '0')
|
||||||
|
|
||||||
|
const [sections, setSections] = useState<Record<string, string>>({})
|
||||||
|
const [grid, setGrid] = useState<TeacherStaffAttendanceRow[]>([])
|
||||||
|
const [locked, setLocked] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchTeacherStaffAttendance({
|
||||||
|
date,
|
||||||
|
semester: semester || null,
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
classSectionId: classSectionId || null,
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
setSections(data.sections ?? {})
|
||||||
|
setGrid(data.grid ?? [])
|
||||||
|
setLocked(Boolean(data.locked))
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load teacher attendance.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [date, semester, schoolYear, classSectionId])
|
||||||
|
|
||||||
|
const dateLabel = useMemo(() => formatUsDate(date), [date])
|
||||||
|
|
||||||
|
function setRow(tid: number, patch: Partial<TeacherStaffAttendanceRow>) {
|
||||||
|
setGrid((prev) =>
|
||||||
|
prev.map((r) => ((r.teacher_id ?? 0) === tid ? { ...r, ...patch } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllPresent() {
|
||||||
|
setGrid((prev) => prev.map((r) => ({ ...r, status: 'present' })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
setGrid((prev) => prev.map((r) => ({ ...r, status: '', reason: '' })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!classSectionId) return
|
||||||
|
setSaving(true)
|
||||||
|
setMessage(null)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const teachers: Record<string, { status?: string | null; reason?: string | null }> = {}
|
||||||
|
for (const r of grid) {
|
||||||
|
const tid = r.teacher_id ?? 0
|
||||||
|
if (!tid) continue
|
||||||
|
teachers[String(tid)] = {
|
||||||
|
status: r.status ?? '',
|
||||||
|
reason: r.reason ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveTeacherStaffAttendance({
|
||||||
|
date,
|
||||||
|
semester,
|
||||||
|
school_year: schoolYear,
|
||||||
|
class_section_id: classSectionId,
|
||||||
|
teachers,
|
||||||
|
})
|
||||||
|
setMessage('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
const dt = String(fd.get('date') ?? '')
|
||||||
|
const cs = String(fd.get('class_section_id') ?? '0')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
if (sem) p.set('semester', sem)
|
||||||
|
if (dt) p.set('date', dt)
|
||||||
|
if (cs && cs !== '0') p.set('class_section_id', cs)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionEntries = Object.entries(sections)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xxl py-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<h2 className="mb-0">Teacher Attendance</h2>
|
||||||
|
<Link
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
to={`${TEACHER_ATTENDANCE_MONTH_PATH}?${new URLSearchParams({ date, semester, school_year: schoolYear }).toString()}`}
|
||||||
|
>
|
||||||
|
← Back to Monthly Attendance
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
|
||||||
|
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label">School Year</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="school_year"
|
||||||
|
className="form-control"
|
||||||
|
defaultValue={schoolYear}
|
||||||
|
placeholder="e.g. 2025-2026"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-2">
|
||||||
|
<label className="form-label">Semester</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="semester"
|
||||||
|
className="form-control"
|
||||||
|
defaultValue={semester}
|
||||||
|
placeholder="Fall"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label">Date</label>
|
||||||
|
<input type="date" name="date" className="form-control" defaultValue={date} />
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<label className="form-label">Class Section</label>
|
||||||
|
<select
|
||||||
|
name="class_section_id"
|
||||||
|
className="form-select"
|
||||||
|
defaultValue={classSectionId || 0}
|
||||||
|
>
|
||||||
|
<option value="0">— Choose Section —</option>
|
||||||
|
{sectionEntries.map(([id, label]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{label} (ID: {id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-secondary">
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
{classSectionId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setSearchParams(new URLSearchParams())}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!classSectionId ? null : (
|
||||||
|
<>
|
||||||
|
{locked ? (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<strong>Note:</strong> Student/section attendance for this day appears to be <em>finalized</em>. Admin edits
|
||||||
|
to teacher attendance here are allowed and will not unlock the section.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<strong>Teachers on {dateLabel}</strong>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-success" onClick={markAllPresent}>
|
||||||
|
Mark All Present
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={clearAll}>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted mb-0">Loading…</p>
|
||||||
|
) : grid.length === 0 ? (
|
||||||
|
<p className="text-muted mb-0 text-center">No teachers found for this section/term.</p>
|
||||||
|
) : (
|
||||||
|
<table className="table table-bordered table-striped align-middle" id="teacherAttTable">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th className="text-center" style={{ width: 70 }}>
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th className="text-center" style={{ width: 160 }}>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="text-center" style={{ width: 200 }}>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grid.map((row, idx) => {
|
||||||
|
const tid = row.teacher_id ?? 0
|
||||||
|
const name =
|
||||||
|
`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `User#${tid}`
|
||||||
|
const pos = String(row.position ?? '').toLowerCase()
|
||||||
|
const stat = String(row.status ?? '').toLowerCase()
|
||||||
|
return (
|
||||||
|
<tr key={tid || idx}>
|
||||||
|
<td className="text-center">{idx + 1}</td>
|
||||||
|
<td>{name}</td>
|
||||||
|
<td className="text-center">{pos === 'main' ? 'Main Teacher' : 'Assistant'}</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={
|
||||||
|
stat === 'present' || stat === 'absent' || stat === 'late' ? stat : ''
|
||||||
|
}
|
||||||
|
onChange={(ev) => setRow(tid, { status: ev.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="present">Present</option>
|
||||||
|
<option value="absent">Absent</option>
|
||||||
|
<option value="late">Late</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Optional reason"
|
||||||
|
value={row.reason ?? ''}
|
||||||
|
onChange={(ev) => setRow(tid, { reason: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-footer d-flex justify-content-end">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || grid.length === 0}>
|
||||||
|
{saving ? 'Saving…' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/* From CI `teacher_attendance_month.php` embedded styles — monthly staff grids */
|
||||||
|
.att-month-wrap .month-grid-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .month-grid thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .sticky-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
background: #fff;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .sticky-col-2 {
|
||||||
|
position: sticky;
|
||||||
|
left: 180px;
|
||||||
|
z-index: 3;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .sticky-col-3 {
|
||||||
|
position: sticky;
|
||||||
|
left: 460px;
|
||||||
|
z-index: 3;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .no-school-day {
|
||||||
|
background-color: #cff4fc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .att-select.form-select.form-select-sm {
|
||||||
|
min-width: 54px;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 18px;
|
||||||
|
line-height: 1.1;
|
||||||
|
border: 1px solid #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .att-select.att-present {
|
||||||
|
background: #157347;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #0f5132;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .att-select.att-absent {
|
||||||
|
background: #bb2d3b;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #842029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .att-select.att-late {
|
||||||
|
background: #ffca2c;
|
||||||
|
color: #111;
|
||||||
|
border-color: #997404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .att-select.att-none {
|
||||||
|
background: #fff;
|
||||||
|
color: #212529;
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-month-wrap .badge-cell.bg-info {
|
||||||
|
min-width: 32px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { apiUrl } from '../../lib/apiOrigin'
|
||||||
|
import {
|
||||||
|
fetchStaffMonthlyAttendanceOverview,
|
||||||
|
saveStaffMonthlyAttendanceCell,
|
||||||
|
} from '../../api/session'
|
||||||
|
import type {
|
||||||
|
StaffMonthlyAdminRow,
|
||||||
|
StaffMonthlyOverviewPayload,
|
||||||
|
StaffMonthlySection,
|
||||||
|
StaffMonthlyTeacherRow,
|
||||||
|
} from '../../api/types'
|
||||||
|
import './TeacherAttendanceMonthPage.css'
|
||||||
|
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||||
|
|
||||||
|
function inferSemesterByDate(dateStr: string, schoolYear: string): string {
|
||||||
|
try {
|
||||||
|
if (!schoolYear.includes('-')) return ''
|
||||||
|
const parts = schoolYear.split('-')
|
||||||
|
const y1 = parseInt(parts[0]!, 10)
|
||||||
|
const y2 = parseInt(parts[1]!, 10)
|
||||||
|
if (!y1 || !y2) return ''
|
||||||
|
const fallStart = new Date(`${y1}-09-21T00:00:00Z`)
|
||||||
|
const fallEnd = new Date(`${y2}-01-18T00:00:00Z`)
|
||||||
|
const spStart = new Date(`${y2}-01-25T00:00:00Z`)
|
||||||
|
const spEnd = new Date(`${y2}-05-30T00:00:00Z`)
|
||||||
|
const d = new Date(`${dateStr}T00:00:00Z`)
|
||||||
|
if (d >= fallStart && d <= fallEnd) return 'Fall'
|
||||||
|
if (d >= spStart && d <= spEnd) return 'Spring'
|
||||||
|
const m = d.getUTCMonth() + 1
|
||||||
|
return m >= 9 || m <= 1 ? 'Fall' : 'Spring'
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectClass(
|
||||||
|
value: string,
|
||||||
|
): 'att-select form-select form-select-sm att-none' | 'att-select form-select form-select-sm att-present' | 'att-select form-select form-select-sm att-absent' | 'att-select form-select form-select-sm att-late' {
|
||||||
|
const v = value.toLowerCase()
|
||||||
|
if (v === 'present') return 'att-select form-select form-select-sm att-present'
|
||||||
|
if (v === 'absent') return 'att-select form-select form-select-sm att-absent'
|
||||||
|
if (v === 'late') return 'att-select form-select form-select-sm att-late'
|
||||||
|
return 'att-select form-select form-select-sm att-none'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/teacher_attendance_month.php` — interactive monthly grids (JWT; no CI CSRF). */
|
||||||
|
export function TeacherAttendanceMonthPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const semesterParam = searchParams.get('semester') ?? '---'
|
||||||
|
const schoolYearParam = searchParams.get('school_year') ?? ''
|
||||||
|
|
||||||
|
const [payload, setPayload] = useState<StaffMonthlyOverviewPayload | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [toast, setToast] = useState<{ ok: boolean; text: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchStaffMonthlyAttendanceOverview({
|
||||||
|
semester: semesterParam === '---' ? null : semesterParam,
|
||||||
|
schoolYear: schoolYearParam || null,
|
||||||
|
})
|
||||||
|
if (!cancelled) setPayload(data)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [semesterParam, schoolYearParam])
|
||||||
|
|
||||||
|
const filters = payload?.filters ?? {}
|
||||||
|
const sundays = payload?.sundays ?? []
|
||||||
|
const noSchoolSet = useMemo(
|
||||||
|
() => new Set(payload?.noSchoolDays ?? []),
|
||||||
|
[payload?.noSchoolDays],
|
||||||
|
)
|
||||||
|
const isCurrentYear = payload?.isCurrentYear !== false
|
||||||
|
const schoolYears = useMemo(() => {
|
||||||
|
const raw = payload?.schoolYears ?? []
|
||||||
|
const ys: string[] = []
|
||||||
|
for (const y of raw) {
|
||||||
|
if (typeof y === 'string') ys.push(y)
|
||||||
|
else if (y && typeof y === 'object' && 'school_year' in y && y.school_year)
|
||||||
|
ys.push(String(y.school_year))
|
||||||
|
}
|
||||||
|
if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam)
|
||||||
|
return ys
|
||||||
|
}, [payload?.schoolYears, schoolYearParam])
|
||||||
|
|
||||||
|
async function onCellChange(
|
||||||
|
kind: 'teacher' | 'admin',
|
||||||
|
patch: {
|
||||||
|
userId: string
|
||||||
|
date: string
|
||||||
|
status: string
|
||||||
|
roleName: string
|
||||||
|
sectionCode?: string | null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!isCurrentYear) {
|
||||||
|
setToast({ ok: false, text: 'Editing disabled for non-current year' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let semesterForSave = filters.semester ?? semesterParam
|
||||||
|
if (semesterForSave === '---') {
|
||||||
|
semesterForSave = inferSemesterByDate(patch.date, filters.school_year ?? schoolYearParam)
|
||||||
|
}
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
date: patch.date,
|
||||||
|
status: patch.status,
|
||||||
|
user_id: patch.userId,
|
||||||
|
semester: semesterForSave,
|
||||||
|
school_year: filters.school_year ?? schoolYearParam,
|
||||||
|
role_name: patch.roleName,
|
||||||
|
type: kind,
|
||||||
|
}
|
||||||
|
if (patch.sectionCode) body.section_id = patch.sectionCode
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveStaffMonthlyAttendanceCell(body)
|
||||||
|
setToast({ ok: true, text: 'Saved' })
|
||||||
|
const data = await fetchStaffMonthlyAttendanceOverview({
|
||||||
|
semester: semesterParam === '---' ? null : semesterParam,
|
||||||
|
schoolYear: schoolYearParam || null,
|
||||||
|
})
|
||||||
|
setPayload(data)
|
||||||
|
} catch {
|
||||||
|
setToast({ ok: false, text: 'Save failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '---')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
p.set('semester', sem)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString =
|
||||||
|
filters.semester && filters.school_year
|
||||||
|
? `?${new URLSearchParams({
|
||||||
|
semester: filters.semester === '---' ? '' : filters.semester,
|
||||||
|
school_year: filters.school_year,
|
||||||
|
}).toString()}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-4 att-month-wrap">
|
||||||
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="mb-0">Admin & Teacher — Monthly Attendance</h2>
|
||||||
|
<div className="legend small">
|
||||||
|
<span className="badge bg-success">P</span>
|
||||||
|
<span className="ms-1">Present</span>
|
||||||
|
<span className="badge bg-danger ms-3">A</span>
|
||||||
|
<span className="ms-1">Absent</span>
|
||||||
|
<span className="badge bg-warning text-dark ms-3">L</span>
|
||||||
|
<span className="ms-1">Late</span>
|
||||||
|
<span className="badge bg-info text-dark ms-3">NS</span>
|
||||||
|
<span className="ms-1">No School</span>
|
||||||
|
<span className="ms-3 text-muted">—</span>
|
||||||
|
<span className="ms-1">No entry</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast ? (
|
||||||
|
<div className={`alert alert-${toast.ok ? 'success' : 'danger'} py-2`}>{toast.text}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{payload?.missingYear ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
Current school year is not configured. This page is read-only until configured (key:{' '}
|
||||||
|
<code>school_year</code>).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="row gy-2 gx-3 align-items-end justify-content-center mb-4" onSubmit={applyFilter}>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">School Year</label>
|
||||||
|
<select name="school_year" className="form-select" defaultValue={schoolYearParam}>
|
||||||
|
{schoolYears.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<label className="form-label">Semester</label>
|
||||||
|
<select name="semester" className="form-select" defaultValue={semesterParam}>
|
||||||
|
{['---', 'Fall', 'Spring'].map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4 d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-secondary">
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<Link className="btn btn-outline-secondary" to={TEACHER_ATTENDANCE_MONTH_PATH}>
|
||||||
|
Reset
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
href={apiUrl(`/api/v1/attendance/staff/month-overview.csv${queryString}`)}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
Teachers & Assistants — Sundays in{' '}
|
||||||
|
<span>{filters.range_label ?? filters.month_label ?? schoolYearParam}</span>
|
||||||
|
</strong>
|
||||||
|
{!isCurrentYear ? <span className="badge bg-secondary ms-2">Read-only (Past Year)</span> : null}
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">Use the dropdowns to set attendance inline</small>
|
||||||
|
</div>
|
||||||
|
<div className="card-body month-grid-wrap">
|
||||||
|
<TeachersGrid
|
||||||
|
sections={payload?.sections ?? []}
|
||||||
|
sundays={sundays}
|
||||||
|
noSchoolSet={noSchoolSet}
|
||||||
|
isCurrentYear={isCurrentYear}
|
||||||
|
onCellChange={(p) => void onCellChange('teacher', p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
Admins — Sundays in{' '}
|
||||||
|
<span>{filters.range_label ?? filters.month_label ?? schoolYearParam}</span>
|
||||||
|
</strong>
|
||||||
|
<span className="text-muted ms-2">(All roles except Parent/Guest/Teacher/Teacher Assistant)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body month-grid-wrap">
|
||||||
|
<AdminsGrid
|
||||||
|
admins={payload?.admins ?? []}
|
||||||
|
sundays={sundays}
|
||||||
|
noSchoolSet={noSchoolSet}
|
||||||
|
isCurrentYear={isCurrentYear}
|
||||||
|
onCellChange={(p) => void onCellChange('admin', p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeachersGrid({
|
||||||
|
sections,
|
||||||
|
sundays,
|
||||||
|
noSchoolSet,
|
||||||
|
isCurrentYear,
|
||||||
|
onCellChange,
|
||||||
|
}: {
|
||||||
|
sections: StaffMonthlySection[]
|
||||||
|
sundays: string[]
|
||||||
|
noSchoolSet: Set<string>
|
||||||
|
isCurrentYear: boolean
|
||||||
|
onCellChange: (p: {
|
||||||
|
userId: string
|
||||||
|
date: string
|
||||||
|
status: string
|
||||||
|
roleName: string
|
||||||
|
sectionCode?: string | null
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
if (!sections.length) {
|
||||||
|
return <div className="alert alert-info mb-0">No sections with assigned teachers found for this term.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="table table-bordered table-striped month-grid align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky-col" style={{ minWidth: 180 }}>
|
||||||
|
Class / Section
|
||||||
|
</th>
|
||||||
|
<th className="sticky-col-2" style={{ minWidth: 280 }}>
|
||||||
|
Teacher
|
||||||
|
</th>
|
||||||
|
<th className="sticky-col-3 text-center" style={{ minWidth: 120 }}>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
{sundays.map((day) => (
|
||||||
|
<th
|
||||||
|
key={day}
|
||||||
|
className={`text-center${noSchoolSet.has(day) ? ' no-school-day' : ''}`}
|
||||||
|
style={{ minWidth: 56 }}
|
||||||
|
title={day}
|
||||||
|
>
|
||||||
|
{day.slice(5, 7)}-{day.slice(8, 10)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-center" style={{ minWidth: 70 }}>
|
||||||
|
P
|
||||||
|
</th>
|
||||||
|
<th className="text-center" style={{ minWidth: 70 }}>
|
||||||
|
A
|
||||||
|
</th>
|
||||||
|
<th className="text-center" style={{ minWidth: 70 }}>
|
||||||
|
L
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sections.flatMap((section) => {
|
||||||
|
const teachers = section.teachers ?? []
|
||||||
|
const rowSpan = teachers.length || 1
|
||||||
|
if (!teachers.length) return []
|
||||||
|
return teachers.map((teacher, t) => (
|
||||||
|
<TeacherRow
|
||||||
|
key={`${section.section_code}-${teacher.user_id ?? teacher.teacher_id}-${t}`}
|
||||||
|
section={section}
|
||||||
|
teacher={teacher}
|
||||||
|
tIndex={t}
|
||||||
|
rowSpan={rowSpan}
|
||||||
|
sundays={sundays}
|
||||||
|
noSchoolSet={noSchoolSet}
|
||||||
|
isCurrentYear={isCurrentYear}
|
||||||
|
onCellChange={onCellChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeacherRow({
|
||||||
|
section,
|
||||||
|
teacher,
|
||||||
|
tIndex,
|
||||||
|
rowSpan,
|
||||||
|
sundays,
|
||||||
|
noSchoolSet,
|
||||||
|
isCurrentYear,
|
||||||
|
onCellChange,
|
||||||
|
}: {
|
||||||
|
section: StaffMonthlySection
|
||||||
|
teacher: StaffMonthlyTeacherRow
|
||||||
|
tIndex: number
|
||||||
|
rowSpan: number
|
||||||
|
sundays: string[]
|
||||||
|
noSchoolSet: Set<string>
|
||||||
|
isCurrentYear: boolean
|
||||||
|
onCellChange: (p: {
|
||||||
|
userId: string
|
||||||
|
date: string
|
||||||
|
status: string
|
||||||
|
roleName: string
|
||||||
|
sectionCode?: string | null
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
const uid = String(teacher.user_id ?? teacher.teacher_id ?? '')
|
||||||
|
const roleText =
|
||||||
|
String(teacher.position ?? '').toLowerCase() === 'main' ? 'Main' : 'Assistant'
|
||||||
|
const totals = teacher.totals ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
{tIndex === 0 ? (
|
||||||
|
<td className="sticky-col" rowSpan={rowSpan} style={{ minWidth: 180 }}>
|
||||||
|
{section.label ?? section.section_code ?? '—'}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
<td className="sticky-col-2">{teacher.name ?? '—'}</td>
|
||||||
|
<td className="sticky-col-3 text-center">{roleText}</td>
|
||||||
|
{sundays.map((day) => {
|
||||||
|
if (noSchoolSet.has(day)) {
|
||||||
|
return (
|
||||||
|
<td key={day} className="text-center no-school-day">
|
||||||
|
<span className="badge-cell bg-info text-dark">NS</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const st = String(teacher.cells?.[day]?.status ?? '').toLowerCase()
|
||||||
|
const val = st === 'present' || st === 'absent' || st === 'late' ? st : ''
|
||||||
|
return (
|
||||||
|
<td key={day} className="text-center">
|
||||||
|
<select
|
||||||
|
disabled={!isCurrentYear}
|
||||||
|
className={selectClass(val)}
|
||||||
|
value={val}
|
||||||
|
onChange={(ev) =>
|
||||||
|
onCellChange({
|
||||||
|
userId: uid,
|
||||||
|
date: day,
|
||||||
|
status: ev.target.value,
|
||||||
|
roleName: roleText,
|
||||||
|
sectionCode: section.section_code ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="present">P</option>
|
||||||
|
<option value="absent">A</option>
|
||||||
|
<option value="late">L</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<td className="text-center">
|
||||||
|
<strong>{totals.p ?? 0}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<strong>{totals.a ?? 0}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<strong>{totals.l ?? 0}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminsGrid({
|
||||||
|
admins,
|
||||||
|
sundays,
|
||||||
|
noSchoolSet,
|
||||||
|
isCurrentYear,
|
||||||
|
onCellChange,
|
||||||
|
}: {
|
||||||
|
admins: StaffMonthlyAdminRow[]
|
||||||
|
sundays: string[]
|
||||||
|
noSchoolSet: Set<string>
|
||||||
|
isCurrentYear: boolean
|
||||||
|
onCellChange: (p: {
|
||||||
|
userId: string
|
||||||
|
date: string
|
||||||
|
status: string
|
||||||
|
roleName: string
|
||||||
|
sectionCode?: string | null
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
if (!admins.length) {
|
||||||
|
return <div className="alert alert-info mb-0">No admin users found for this term.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="table table-bordered table-striped month-grid align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky-col" style={{ minWidth: 180 }}>
|
||||||
|
Admin
|
||||||
|
</th>
|
||||||
|
<th className="sticky-col-2 text-center" style={{ minWidth: 240 }}>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
{sundays.map((day) => (
|
||||||
|
<th
|
||||||
|
key={day}
|
||||||
|
className={`text-center${noSchoolSet.has(day) ? ' no-school-day' : ''}`}
|
||||||
|
style={{ minWidth: 56 }}
|
||||||
|
title={day}
|
||||||
|
>
|
||||||
|
{day.slice(5, 7)}-{day.slice(8, 10)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-center">P</th>
|
||||||
|
<th className="text-center">A</th>
|
||||||
|
<th className="text-center">L</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{admins.map((admin) => {
|
||||||
|
const uid = String(admin.user_id ?? '')
|
||||||
|
const totals = admin.totals ?? {}
|
||||||
|
return (
|
||||||
|
<tr key={uid}>
|
||||||
|
<td className="sticky-col">{admin.name ?? '—'}</td>
|
||||||
|
<td className="sticky-col-2 text-center">{admin.role ?? '—'}</td>
|
||||||
|
{sundays.map((day) => {
|
||||||
|
if (noSchoolSet.has(day)) {
|
||||||
|
return (
|
||||||
|
<td key={day} className="text-center no-school-day">
|
||||||
|
<span className="badge-cell bg-info text-dark">NS</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const st = String(admin.cells?.[day]?.status ?? '').toLowerCase()
|
||||||
|
const val = st === 'present' || st === 'absent' || st === 'late' ? st : ''
|
||||||
|
return (
|
||||||
|
<td key={day} className="text-center">
|
||||||
|
<select
|
||||||
|
disabled={!isCurrentYear}
|
||||||
|
className={selectClass(val)}
|
||||||
|
value={val}
|
||||||
|
onChange={(ev) =>
|
||||||
|
onCellChange({
|
||||||
|
userId: uid,
|
||||||
|
date: day,
|
||||||
|
status: ev.target.value,
|
||||||
|
roleName: admin.role ?? 'Admin',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="present">P</option>
|
||||||
|
<option value="absent">A</option>
|
||||||
|
<option value="late">L</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<td className="text-center">
|
||||||
|
<strong>{totals.p ?? 0}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<strong>{totals.a ?? 0}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<strong>{totals.l ?? 0}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
fetchAttendanceViolationsNotified,
|
||||||
|
fetchAttendanceViolationsPending,
|
||||||
|
saveAttendanceViolationNote,
|
||||||
|
} from '../../api/session'
|
||||||
|
import type { AttendanceViolationStudentRow } from '../../api/types'
|
||||||
|
import {
|
||||||
|
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
|
||||||
|
ATTENDANCE_VIOLATIONS_PENDING_PATH,
|
||||||
|
} from './attendancePaths'
|
||||||
|
|
||||||
|
function studentIdOf(st: AttendanceViolationStudentRow): number {
|
||||||
|
return Number(st.student_id ?? st.id ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function studentNameOf(st: AttendanceViolationStudentRow): string {
|
||||||
|
const n =
|
||||||
|
(st.name as string | undefined) ||
|
||||||
|
(st.student_name as string | undefined) ||
|
||||||
|
`${String(st.firstname ?? st.first_name ?? '')} ${String(st.lastname ?? st.last_name ?? '')}`.trim()
|
||||||
|
const sid = studentIdOf(st)
|
||||||
|
return n || (sid ? `Student #${sid}` : '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/violations_pending.php` */
|
||||||
|
export function ViolationsPendingPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
|
||||||
|
const filterQuery = useMemo(() => {
|
||||||
|
const s = searchParams.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<AttendanceViolationStudentRow[]>([])
|
||||||
|
const [debug, setDebug] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceViolationsPending({
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
semester: semester || null,
|
||||||
|
})
|
||||||
|
if (!cancelled) {
|
||||||
|
setRows(data.students ?? [])
|
||||||
|
setDebug((data.debug as Record<string, unknown>) ?? null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load violations.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [schoolYear, semester])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const s = q.trim().toLowerCase()
|
||||||
|
if (!s) return rows
|
||||||
|
return rows.filter((r) => studentNameOf(r).toLowerCase().includes(s))
|
||||||
|
}, [rows, q])
|
||||||
|
|
||||||
|
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
if (sem) p.set('semester', sem)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid px-0">
|
||||||
|
<h2 className="text-center mt-4 mb-3">Pending Attendance Violations</h2>
|
||||||
|
<div className="d-flex justify-content-center mb-2">
|
||||||
|
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label small mb-0">School Year</label>
|
||||||
|
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label small mb-0">Semester</label>
|
||||||
|
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button type="submit" className="btn btn-sm btn-secondary">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="row g-0">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0 rounded-3 shadow-sm mx-2">
|
||||||
|
<div className="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<h5 className="mt-2 mb-0">Pending Attendance Violations</h5>
|
||||||
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
|
||||||
|
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
|
||||||
|
<Link
|
||||||
|
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
|
||||||
|
className="btn btn-outline-dark btn-sm"
|
||||||
|
>
|
||||||
|
View Notified
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Quick filter…"
|
||||||
|
value={q}
|
||||||
|
onChange={(ev) => setQ(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex flex-wrap gap-3 mb-3 small">
|
||||||
|
<Legend dot="#e6f7ff" label="1 Absence — Auto Email" dark />
|
||||||
|
<Legend dot="#fadb14" label="2 Absences — Team Notify" dark />
|
||||||
|
<Legend dot="#fa8c16" label="3 Absences — Team Notify" />
|
||||||
|
<Legend dot="#ff4d4f" label="4 Absences / Expel / 4 Lates" />
|
||||||
|
<Legend dot="#bae7ff" label="2 Lates — Auto Email" dark />
|
||||||
|
<Legend dot="#002766" label="3 Lates / mixed — Team Notify" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-info mb-2">No attendance violations found.</div>
|
||||||
|
{debug ? (
|
||||||
|
<div className="alert alert-secondary small">
|
||||||
|
<strong>Debug</strong>
|
||||||
|
<pre className="mb-0 small">{JSON.stringify(debug, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle w-100 small">
|
||||||
|
<thead className="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Grade</th>
|
||||||
|
<th>Violation</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th className="text-center">Abs</th>
|
||||||
|
<th className="text-center">Late</th>
|
||||||
|
<th>Last Incident</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Notified?</th>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Parent</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((st, idx) => (
|
||||||
|
<PendingRow key={studentIdOf(st) || idx} st={st} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Legend({ dot, label, dark }: { dot: string; label: string; dark?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
className="badge rounded-pill"
|
||||||
|
style={{ backgroundColor: dot, color: dark ? '#000' : '#fff' }}
|
||||||
|
>
|
||||||
|
|
||||||
|
</span>{' '}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingRow({ st }: { st: AttendanceViolationStudentRow }) {
|
||||||
|
const sid = studentIdOf(st)
|
||||||
|
const name = studentNameOf(st)
|
||||||
|
const grade = String(st.class_name ?? st.class_section_name ?? '')
|
||||||
|
const viTitle = String(st.violation ?? '')
|
||||||
|
const viCode = String(st.violation_code ?? '')
|
||||||
|
const viType = String(st.type ?? '')
|
||||||
|
const viColor = String(st.color ?? '#6c757d')
|
||||||
|
const action = String(st.action ?? '')
|
||||||
|
const absences = (st.absences as unknown[]) ?? []
|
||||||
|
const lates = (st.lates as unknown[]) ?? []
|
||||||
|
const lastDate = st.last_date as string | undefined
|
||||||
|
const incidentYmd = lastDate ? String(lastDate).slice(0, 10) : ''
|
||||||
|
const notified = Boolean(st.is_notified)
|
||||||
|
const notifCnt = Number(st.notif_counter ?? 0)
|
||||||
|
const parentEmail = String(st.parent_email ?? '')
|
||||||
|
const parentName = String(st.parent_name ?? '')
|
||||||
|
const parentLabel = parentName || parentEmail || 'View'
|
||||||
|
|
||||||
|
const isLight = ['#bae7ff', '#e6f7ff', '#fadb14'].includes(viColor.toLowerCase())
|
||||||
|
const badgeCls = `badge rounded-pill ${isLight ? 'text-dark' : 'text-light'}`
|
||||||
|
|
||||||
|
const composeBase = `/app/administrator/attendance/compose-email?student_id=${sid}&code=${encodeURIComponent(viCode)}`
|
||||||
|
const incidentParam = incidentYmd ? `&incident_date=${encodeURIComponent(incidentYmd)}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={{ borderLeft: `6px solid ${viColor}` }}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{grade}</td>
|
||||||
|
<td>
|
||||||
|
<span className={badgeCls} style={{ backgroundColor: viColor }}>
|
||||||
|
{viTitle}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-capitalize">{viType || '—'}</td>
|
||||||
|
<td className="text-center">{absences.length}</td>
|
||||||
|
<td className="text-center">{lates.length}</td>
|
||||||
|
<td>{incidentYmd ? formatMdY(incidentYmd) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{action === 'auto_email' ? (
|
||||||
|
<span className="badge bg-primary">Auto Email</span>
|
||||||
|
) : action === 'expel_notify' ? (
|
||||||
|
<span className="badge bg-danger">Expel Notify</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-warning text-dark">Team Notify</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${notified ? 'bg-success' : 'bg-warning text-dark'}`}>
|
||||||
|
{notified ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{notifCnt}</td>
|
||||||
|
<td>
|
||||||
|
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||||
|
{parentLabel}
|
||||||
|
</Link>
|
||||||
|
{parentEmail ? (
|
||||||
|
<a className="ms-2" href={`mailto:${parentEmail}`} title="Email parent">
|
||||||
|
<i className="bi bi-envelope" aria-hidden />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap">
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-info"
|
||||||
|
to={`/app/administrator/attendance/view/${sid}?code=${encodeURIComponent(viCode)}${incidentParam}`}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
{action === 'auto_email' ? (
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-primary ms-1"
|
||||||
|
to={`${composeBase}&variant=default${incidentParam}`}
|
||||||
|
>
|
||||||
|
Compose Auto Email
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="btn-group ms-1">
|
||||||
|
<Link className="btn btn-sm btn-success" to={`${composeBase}&variant=answered${incidentParam}`}>
|
||||||
|
Answered
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-sm btn-warning" to={`${composeBase}&variant=no_answer${incidentParam}`}>
|
||||||
|
No Answer
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of `Views/attendance/violations_notified.php` */
|
||||||
|
export function ViolationsNotifiedPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
|
||||||
|
const filterQuery = useMemo(() => {
|
||||||
|
const s = searchParams.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<AttendanceViolationStudentRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [noteModal, setNoteModal] = useState<AttendanceViolationStudentRow | null>(null)
|
||||||
|
const [noteText, setNoteText] = useState('')
|
||||||
|
const [savingNote, setSavingNote] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceViolationsNotified({
|
||||||
|
schoolYear: schoolYear || null,
|
||||||
|
semester: semester || null,
|
||||||
|
})
|
||||||
|
if (!cancelled) setRows(data.students ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [schoolYear, semester])
|
||||||
|
|
||||||
|
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
if (sy) p.set('school_year', sy)
|
||||||
|
if (sem) p.set('semester', sem)
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNote(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!noteModal) return
|
||||||
|
setSavingNote(true)
|
||||||
|
try {
|
||||||
|
const sid = studentIdOf(noteModal)
|
||||||
|
const code = String(noteModal.reason ?? noteModal.violation_code ?? '')
|
||||||
|
const lastDate = noteModal.last_date as string | undefined
|
||||||
|
const incidentYmd = lastDate ? String(lastDate).slice(0, 10) : ''
|
||||||
|
await saveAttendanceViolationNote({
|
||||||
|
student_id: String(sid),
|
||||||
|
code,
|
||||||
|
incident_date: incidentYmd,
|
||||||
|
note: noteText,
|
||||||
|
return_url: typeof window !== 'undefined' ? window.location.href : '',
|
||||||
|
})
|
||||||
|
setNoteModal(null)
|
||||||
|
setNoteText('')
|
||||||
|
} catch {
|
||||||
|
setError('Unable to save note.')
|
||||||
|
} finally {
|
||||||
|
setSavingNote(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid px-0">
|
||||||
|
<h2 className="text-center mt-4 mb-3">Notified Attendance Violations</h2>
|
||||||
|
<div className="d-flex justify-content-center mb-2">
|
||||||
|
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label small mb-0">School Year</label>
|
||||||
|
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label small mb-0">Semester</label>
|
||||||
|
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button type="submit" className="btn btn-sm btn-secondary">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="card border-0 rounded-3 shadow-sm mx-2">
|
||||||
|
<div className="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||||
|
<h5 className="mt-2 mb-0">Notified Attendance Violations</h5>
|
||||||
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
|
||||||
|
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
|
||||||
|
<Link to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`} className="btn btn-outline-dark btn-sm">
|
||||||
|
View Pending
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="alert alert-info mb-0">No notified violations found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-hover align-middle w-100 small">
|
||||||
|
<thead className="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Grade</th>
|
||||||
|
<th>Last Incident</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Parent</th>
|
||||||
|
<th>Notified On</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((st, idx) => (
|
||||||
|
<NotifiedRow
|
||||||
|
key={studentIdOf(st) || idx}
|
||||||
|
st={st}
|
||||||
|
onEditNote={() => {
|
||||||
|
setNoteModal(st)
|
||||||
|
setNoteText(String(st.note ?? ''))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noteModal ? (
|
||||||
|
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<form className="modal-content" onSubmit={submitNote}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Comment / Note</h5>
|
||||||
|
<button type="button" className="btn-close" aria-label="Close" onClick={() => setNoteModal(null)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
value={noteText}
|
||||||
|
onChange={(ev) => setNoteText(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={savingNote}>
|
||||||
|
Save Note
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={() => setNoteModal(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotifiedRow({
|
||||||
|
st,
|
||||||
|
onEditNote,
|
||||||
|
}: {
|
||||||
|
st: AttendanceViolationStudentRow
|
||||||
|
onEditNote: () => void
|
||||||
|
}) {
|
||||||
|
const sid = studentIdOf(st)
|
||||||
|
const name = studentNameOf(st)
|
||||||
|
const grade = String(st.class_section_name ?? st.class_name ?? '')
|
||||||
|
const lastDate = st.last_date ?? st.date
|
||||||
|
const incidentYmd = lastDate ? String(lastDate).slice(0, 10) : ''
|
||||||
|
const reason = String(st.reason ?? '')
|
||||||
|
const note = String(st.note ?? '')
|
||||||
|
const parentName = String(st.parent_name ?? '')
|
||||||
|
const notifiedAt = st.updated_at ?? st.date
|
||||||
|
const code = String(st.reason ?? st.violation_code ?? '')
|
||||||
|
const viColor = String(st.color ?? '#0d6efd')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={{ borderLeft: `6px solid ${viColor}` }}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{grade}</td>
|
||||||
|
<td>{incidentYmd ? formatMdY(incidentYmd) : '—'}</td>
|
||||||
|
<td>{reason || '—'}</td>
|
||||||
|
<td style={{ maxWidth: 280 }}>
|
||||||
|
{note ? (
|
||||||
|
<span className="small">{note.length > 120 ? `${note.slice(0, 120)}…` : note}</span>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-primary ms-1" onClick={onEditNote}>
|
||||||
|
{note ? 'Edit note' : 'Add note'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{parentName ? (
|
||||||
|
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||||
|
{parentName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{notifiedAt ? formatMdY(String(notifiedAt).slice(0, 10)) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-info"
|
||||||
|
to={`/app/administrator/attendance/view/${sid}?code=${encodeURIComponent(code)}&incident_date=${encodeURIComponent(incidentYmd)}&include_notified=1`}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMdY(ymd: string): string {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||||
|
const [y, m, d] = ymd.split('-')
|
||||||
|
return `${m}-${d}-${y}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Monthly teacher/staff attendance (JWT `GET /api/v1/attendance/staff/month-overview`, POST `month-save`). */
|
||||||
|
export const TEACHER_ATTENDANCE_MONTH_PATH = '/app/admin/teacher-attendance/month'
|
||||||
|
|
||||||
|
/** Attendance violation queues (JWT `GET /api/v1/attendance/violations/pending|notified`, POST `save-note`, etc.). */
|
||||||
|
export const ATTENDANCE_VIOLATIONS_PATH = '/app/attendance/violations'
|
||||||
|
export const ATTENDANCE_VIOLATIONS_PENDING_PATH = `${ATTENDANCE_VIOLATIONS_PATH}/pending`
|
||||||
|
export const ATTENDANCE_VIOLATIONS_NOTIFIED_PATH = `${ATTENDANCE_VIOLATIONS_PATH}/notified`
|
||||||
|
|
||||||
|
/** Early dismissals list + add form (JWT `GET/POST /api/v1/attendance/early-dismissals`, `student-options`, signature upload). */
|
||||||
|
export const EARLY_DISMISSALS_PATH = '/app/attendance/early-dismissals'
|
||||||
|
export const EARLY_DISMISSALS_NEW_PATH = `${EARLY_DISMISSALS_PATH}/new`
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export { AdministratorAbsencePage } from './AdministratorAbsencePage'
|
||||||
|
export { AdminsAttendanceFormPage } from './AdminsAttendanceFormPage'
|
||||||
|
export { AttendanceTrackingPage } from './AttendanceTrackingPage'
|
||||||
|
export { ComposeAttendanceEmailPage } from './ComposeAttendanceEmailPage'
|
||||||
|
export { EarlyDismissalsAddPage } from './EarlyDismissalsAddPage'
|
||||||
|
export { EarlyDismissalsPage } from './EarlyDismissalsPage'
|
||||||
|
export { AttendanceEditModalBody } from './AttendanceEditModal'
|
||||||
|
export { ParentAttendanceReportsAdminPage } from './ParentAttendanceReportsAdminPage'
|
||||||
|
export { TeacherAttendanceFormPage } from './TeacherAttendanceFormPage'
|
||||||
|
export { TeacherAttendanceMonthPage } from './TeacherAttendanceMonthPage'
|
||||||
|
export { AttendanceStudentViolationsViewPage } from './AttendanceStudentViolationsViewPage'
|
||||||
|
export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage'
|
||||||
|
export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages'
|
||||||
|
export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage'
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
|
||||||
|
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
|
||||||
|
|
||||||
|
function adjustQty(input: HTMLInputElement, delta: number) {
|
||||||
|
const curr = parseInt(input.value, 10) || 0
|
||||||
|
input.value = String(curr + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSchoolYear() {
|
||||||
|
const y = new Date().getFullYear()
|
||||||
|
return `${y}-${y + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassPrepListPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear()
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
|
||||||
|
const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null)
|
||||||
|
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const yearOptions = useMemo(() => {
|
||||||
|
const out: string[] = []
|
||||||
|
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
|
||||||
|
return out
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetchClassPrepIndex({
|
||||||
|
school_year: schoolYear,
|
||||||
|
semester: semester || undefined,
|
||||||
|
})
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setData(d)
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setError(e instanceof ApiHttpError ? e.message : 'Failed to load class preparation data.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [schoolYear, semester])
|
||||||
|
|
||||||
|
const onFilterSubmit = useCallback(
|
||||||
|
(e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const sy = String(fd.get('school_year') ?? '')
|
||||||
|
const sem = String(fd.get('semester') ?? '')
|
||||||
|
const next = new URLSearchParams()
|
||||||
|
if (sy) next.set('school_year', sy)
|
||||||
|
if (sem) next.set('semester', sem)
|
||||||
|
setSearchParams(next)
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function onSaveAdjustment(prep: ClassPrepSectionRow, form: HTMLFormElement) {
|
||||||
|
setSavingId(String(prep.class_section_id))
|
||||||
|
setSaveMsg(null)
|
||||||
|
const fd = new FormData(form)
|
||||||
|
const adjustments: Record<string, number> = {}
|
||||||
|
fd.forEach((v, k) => {
|
||||||
|
if (k.startsWith('adj__')) {
|
||||||
|
const name = k.slice(5)
|
||||||
|
adjustments[name] = parseInt(String(v), 10) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await saveClassPrepAdjustment({
|
||||||
|
class_section_id: prep.class_section_id,
|
||||||
|
school_year: schoolYear,
|
||||||
|
semester: semester || undefined,
|
||||||
|
adjustments,
|
||||||
|
})
|
||||||
|
setSaveMsg('Saved.')
|
||||||
|
const fresh = await fetchClassPrepIndex({
|
||||||
|
school_year: schoolYear,
|
||||||
|
semester: semester || undefined,
|
||||||
|
})
|
||||||
|
setData(fresh)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setSaveMsg(e instanceof ApiHttpError ? e.message : 'Save failed.')
|
||||||
|
} finally {
|
||||||
|
setSavingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const printBase = `/app/administrator/class-prep/print`
|
||||||
|
const semQuery = semester ? `?semester=${encodeURIComponent(semester)}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="wrapper">
|
||||||
|
<h2 className="text-center mt-4 mb-3">Class Preparation</h2>
|
||||||
|
|
||||||
|
<form onSubmit={onFilterSubmit} className="mb-4 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<label htmlFor="school_year" className="form-label mb-0">
|
||||||
|
School Year:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="school_year"
|
||||||
|
id="school_year"
|
||||||
|
className="form-select form-select-sm w-auto"
|
||||||
|
defaultValue={schoolYear}
|
||||||
|
>
|
||||||
|
{yearOptions.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label htmlFor="semester" className="form-label mb-0">
|
||||||
|
Semester:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="semester"
|
||||||
|
id="semester"
|
||||||
|
className="form-select form-select-sm w-auto"
|
||||||
|
defaultValue={semester}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="Fall">Fall</option>
|
||||||
|
<option value="Spring">Spring</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn btn-sm btn-primary">
|
||||||
|
Calculate Items
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<div className="alert alert-danger">{error}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{saveMsg ? (
|
||||||
|
<div className="alert alert-info py-2" role="status">
|
||||||
|
{saveMsg}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
{(data?.prep_results ?? []).map((prep) => {
|
||||||
|
const adjMap = prep.adjustments ?? {}
|
||||||
|
return (
|
||||||
|
<div key={String(prep.class_section_id)} className="col-12 col-lg-6">
|
||||||
|
<div className="card shadow-sm border-0 h-100 classprep-card">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center bg-light border-0">
|
||||||
|
<div>
|
||||||
|
<div className="fw-semibold mb-1">{prep.class_section}</div>
|
||||||
|
{prep.needs_print ? (
|
||||||
|
<span className="badge bg-danger">Updated since last print</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-success">Up-to-date</span>
|
||||||
|
)}
|
||||||
|
{prep.last_printed_at ? (
|
||||||
|
<small className="text-muted ms-2">Last printed: {prep.last_printed_at}</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="small text-muted text-end">
|
||||||
|
Students:
|
||||||
|
<br />
|
||||||
|
<span className="badge bg-secondary">{prep.student_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12 col-md-7">
|
||||||
|
<div className="fw-semibold mb-2">Prep Items</div>
|
||||||
|
<ul className="list-group list-group-flush small border rounded overflow-hidden">
|
||||||
|
{Object.entries(prep.prep_items ?? {}).map(([item, qty]) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className={`list-group-item d-flex justify-content-between align-items-center ${
|
||||||
|
Number(qty) === 0 ? 'text-muted classprep-muted-soft' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{item}</span>
|
||||||
|
<span
|
||||||
|
className={`badge rounded-pill ${
|
||||||
|
Number(qty) > 0 ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Number(qty)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-md-5">
|
||||||
|
<div className="fw-semibold mb-2">Adjustments (Tables)</div>
|
||||||
|
<form
|
||||||
|
className="d-grid gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void onSaveAdjustment(prep, e.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(adjMap).map(([item, value]) => (
|
||||||
|
<div key={item}>
|
||||||
|
<label className="form-label mb-1 small text-muted d-block">{item}</label>
|
||||||
|
<div className="input-group input-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={(ev) => {
|
||||||
|
const input = ev.currentTarget.parentElement?.querySelector(
|
||||||
|
'input[type="number"]',
|
||||||
|
) as HTMLInputElement | null
|
||||||
|
if (input) adjustQty(input, -1)
|
||||||
|
}}
|
||||||
|
aria-label={`Decrease ${item}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control text-center"
|
||||||
|
name={`adj__${item}`}
|
||||||
|
defaultValue={Number(value)}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={(ev) => {
|
||||||
|
const input = ev.currentTarget.parentElement?.querySelector(
|
||||||
|
'input[type="number"]',
|
||||||
|
) as HTMLInputElement | null
|
||||||
|
if (input) adjustQty(input, 1)
|
||||||
|
}}
|
||||||
|
aria-label={`Increase ${item}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="d-flex justify-content-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
disabled={savingId === String(prep.class_section_id)}
|
||||||
|
>
|
||||||
|
{savingId === String(prep.class_section_id) ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-footer bg-transparent border-0 d-flex justify-content-end">
|
||||||
|
<Link
|
||||||
|
to={`${printBase}/${encodeURIComponent(String(prep.class_section_id))}/${encodeURIComponent(schoolYear)}${semQuery}`}
|
||||||
|
className={`btn btn-lg ${prep.needs_print ? 'btn-danger' : 'btn-success'}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Print
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<h5 className="mb-3">Totals for All Used Items</h5>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-bordered align-middle">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th className="text-end">Total Needed</th>
|
||||||
|
<th className="text-end">In Inventory</th>
|
||||||
|
<th className="text-end">Short By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(data?.total_needed ?? {}).map(([item, needed]) => {
|
||||||
|
const have = Number((data?.available ?? {})[item] ?? 0)
|
||||||
|
const short = Math.max(0, Number(needed) - have)
|
||||||
|
return (
|
||||||
|
<tr key={item} className={short > 0 ? 'table-warning' : ''}>
|
||||||
|
<td>{item}</td>
|
||||||
|
<td className="text-end">{Number(needed)}</td>
|
||||||
|
<td className="text-end">{have}</td>
|
||||||
|
<td
|
||||||
|
className={`text-end ${short > 0 ? 'text-danger fw-bold' : 'text-success'}`}
|
||||||
|
>
|
||||||
|
{short}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.shortages && Object.keys(data.shortages).length > 0 ? (
|
||||||
|
<div className="alert alert-danger mt-3">
|
||||||
|
<strong>Shortages Detected</strong>
|
||||||
|
<ul className="mb-0">
|
||||||
|
{Object.entries(data.shortages).map(([item, short]) => (
|
||||||
|
<li key={item}>
|
||||||
|
{item}: short by {Number(short)} pcs
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : data && Object.keys(data.total_needed ?? {}).length > 0 ? (
|
||||||
|
<div className="alert alert-success mt-3">All items are available in stock.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.classprep-muted-soft { opacity: 0.55; }
|
||||||
|
.classprep-card { border-radius: 12px; }
|
||||||
|
.classprep-card .card-header {
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-top-right-radius: 12px;
|
||||||
|
}
|
||||||
|
.classprep-card .card-footer {
|
||||||
|
border-bottom-left-radius: 12px;
|
||||||
|
border-bottom-right-radius: 12px;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||