diff --git a/Zahlungsstatistik_2025_2026.pdf b/Zahlungsstatistik_2025_2026.pdf new file mode 100644 index 0000000..65cfcfd --- /dev/null +++ b/Zahlungsstatistik_2025_2026.pdf @@ -0,0 +1,99 @@ +%PDF-1.4 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 10 0 R /MediaBox [ 0 0 841.8898 595.2756 ] /Parent 9 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 11 0 R /MediaBox [ 0 0 841.8898 595.2756 ] /Parent 9 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/PageMode /UseNone /Pages 9 0 R /Type /Catalog +>> +endobj +8 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260512085344+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260512085344+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +9 0 obj +<< +/Count 2 /Kids [ 5 0 R 6 0 R ] /Type /Pages +>> +endobj +10 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1499 +>> +stream +Gb"/'95iQS&AII3bf,*qb+"rMDf./b`[HuoNYN6VFCA`a@P#p380`Y7I:r@XP7hT?O$eQRZ7V$t\_k]XINeik(KUR&>C`@.nE+?d'JR9+<(t.+4e0bEG8"dDXTP[>e(uPE+9b,mhB;1"moC*ZL+r^_g/;kjkX'536>3Z8c3mGEq%ZRV5@;^ppLLtJcWoI9_pY@.>5k-*aNmmdOfd&UNL:lKat6]q6qPYlQ^iK[le>#A@Zi%$','K`uZPPj\tTu4BPh0Oe_:u3o[:_"24XGA>-:dFuk?\=2ke_B&_+qEO&o*QU"KjU&*)U=QD@\%>X@^TE2@hqtrFL4e/CT4a?3nl1,d#S@-Pi^U$Zc3kk6\RPr=UPQG1LUA\KnN_=f<6RGF6ssL`&E\6@j;+(.f[nQK\D9/%AJ\[6RL#-cU6@Q>N6mOY_V2'(:F0K!uqlSG?$KUh*ITX*j+$0N"5&I5@ikjPGM@FY(L0'hHFO%`R_[*5971%J]q=?UVBH[A6XLWGK"Ejjs*']'DsW^K`+^?(2,Rm)g.g(i,@O%3AcFK\*C)si,6]HhiTi6?_:a"3ITS!jd`>EL6:e5l]:nhT+En;36i$aB2:I+*2;]+K_Eb>$,$]"\[erq;bSukfKg#\8:k"k#=>G<$=6\MYJZharRNL_&qCVafQN&uW^NR/!/Sc`9ci:RMjaFtSm(]+$WSE!E68GLaGs6\jn-#!IJQ%F3EkIlrPUKtoL3"0'o*2Gp&UBdW757OQ-m*3]ak)#?<$eN9\\pulm54ke@(^c_aC(U(`Bl&^>]6R"'\]%b=K[kRnnhAp$o8&J9CW(9N&HL]rjp:]+iKC)MQH"'2D7X.d(o*1<-LqEDlEDCp1+@afUtGi8S>t8Shq\!?a4#3j-o]kEA!tm#6e/u.4RQ5l7SUo8$BYhbb17$8;\tS&bM>GGgd/+IU:-2Ed&dIF_?XYY3+_g@M(^=;fr/6(=K5Ebpph:K,?!@T=76e5("TH8_%VfMM^4uT[Cj;PIQ=nVXMq]+HA"Qj9INK#>ji+bD0t05V+HMB"SMjk>/OddS+X;or3MaT6^jY2u~>endstream +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1761 +>> +stream +Gat%$D/\/e&H88.6""'J!bV3WM>Pd*8X9NSco/lJ$ipPiTl%3'N6-+Kk1>ZW8;\lB*tpXh0)+/cp`+QLOs4?VgAQFL&\;)=ZYPFH6d$4=!XlC5Lp!$ks-uoD9J[q_L8E%GYqQO/;#dNH-cV[&NsT!U`Z'!UZI[XlIIX[YctgAKlKVNI]:rGH$iR/B`!B#_j6?C6VKbC^l5>#MMt2u<;*VdALR-iEnpMJ"BCh)"i=$AGA/e:B@Z4g9;oGL&qDR-Ir1jT+OV4W):6R5;BcDJ3bZN""/0$_1Z8CY99ZirV$`6KB&q3SPK%\TW8\TF4E1S5.+dk2Z8G=6(5VD.%nH5M8i+WLMYRp=:djYL>]fD2lN;*fK3?rM[WrdN5E^=hfJ_n,aVk;*MW=`M7k-5V?(&!W6>JXACckm.]pT`a7I*+RD1DIG>>*XK6bb?\E?!P0lT@TO;F]P]V=itd,oFq,,N`Y;/::Bm%N":#,B_5-D\V_]qg_%m4fCiL86YqDIL.P;d5=3m/7+c7j6JmsXsVg'm,$VD0[K.%5d!)Q@qXFlDj'K[."Z,>DZeNn-Fis\$=jf\N]lSV5<_o!1@8C+D*p;u5#Q3,ir"q\6ml(Bglc`MnPgfiKYoj$f&W"9GdXm9G;cm;Ag22E@k64W'[A2eMQ9P4\V2_]d'Q$o2u!-72KVDsZQEL6JonI/60U"A%);4nCM5aZ5lj^oLX+bZjR2;&K`$MD1%WF!,LrQR63g_'2RJ)k?3:oh=Iem&IShAml40?KH7?>YD2Q^4BE%gYF!Fk>_]!c0QCbhPQss-?):cbA(Wnf$$0oZt=/Xj4e$ZoNjA)dWoce,'3>=i?o`+J>W2N&ql3-E_k;R&?/$`WVj\+qJBNluoki/*Q])6iO'cf59"QQUROn@N-+&pFZ/\@BOIT(N-Q<4;jH'95,3]n'ulhpNbHiCA(!X-9piUq6n]7$rMR>l$C+FoLoK1C@1Y_L>!R0gIqM[82!ablKcqO)g8Wro]eJYM@V0qdA>f1=TVmtIF1q26KL'P)gX#OnmtG/`OK>(j*CW^/oSMpIE2Ro3dspd,FRhZ\=3Th~>endstream +endobj +xref +0 12 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000408 00000 n +0000000612 00000 n +0000000816 00000 n +0000000884 00000 n +0000001164 00000 n +0000001229 00000 n +0000002820 00000 n +trailer +<< +/ID +[<8f4f35dc43daf25b84cfab1f3c6eeeb1><8f4f35dc43daf25b84cfab1f3c6eeeb1>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 8 0 R +/Root 7 0 R +/Size 12 +>> +startxref +4673 +%%EOF diff --git a/generate_payment_report_de.py b/generate_payment_report_de.py new file mode 100644 index 0000000..0b432a6 --- /dev/null +++ b/generate_payment_report_de.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Generate a PDF report with payment statistics in German +""" + +import sys +from datetime import datetime +from decimal import Decimal + +sys.path.insert(0, '/var/git/SUST/sustApp') + +try: + from reportlab.lib.pagesizes import A4, landscape + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import cm + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak + from reportlab.lib import colors + from reportlab.lib.enums import TA_CENTER, TA_LEFT +except ImportError: + print("reportlab not installed. Installing...") + import subprocess + subprocess.check_call([sys.executable, "-m", "pip", "install", "reportlab"]) + from reportlab.lib.pagesizes import A4, landscape + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import cm + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak + from reportlab.lib import colors + from reportlab.lib.enums import TA_CENTER, TA_LEFT + +from sqlalchemy import text +from models.database import SessionLocal + +def get_non_paying_members(): + """Get non-paying active primary members""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, + m.member_membership_start, m.member_count, + MAX(p.payment_date) as last_payment_date, + MAX(EXTRACT(YEAR FROM p.payment_year)) as last_payment_year + FROM members m + LEFT JOIN payments p ON m.id = p.member_id + WHERE m.member_active = true + AND m.member_died IS NULL + AND m.member_primary = true + AND m.id NOT IN ( + SELECT DISTINCT member_id + FROM payments + WHERE EXTRACT(YEAR FROM payment_year) = 2026 + ) + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email, + m.member_membership_start, m.member_count + ORDER BY m.member_count DESC, m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def create_pdf(): + """Create PDF report""" + + # Create PDF document + pdf_file = "/var/git/SUST/Zahlungsstatistik_2025_2026.pdf" + doc = SimpleDocTemplate(pdf_file, pagesize=landscape(A4), topMargin=0.8*cm, bottomMargin=0.8*cm) + + # Style sheets + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontSize=18, + textColor=colors.HexColor('#1a1a1a'), + spaceAfter=12, + alignment=TA_CENTER, + fontName='Helvetica-Bold' + ) + + heading_style = ParagraphStyle( + 'CustomHeading', + parent=styles['Heading2'], + fontSize=12, + textColor=colors.HexColor('#333333'), + spaceAfter=8, + spaceBefore=8, + fontName='Helvetica-Bold' + ) + + normal_style = ParagraphStyle( + 'CustomNormal', + parent=styles['Normal'], + fontSize=9, + leading=11 + ) + + # Build content + story = [] + + # Title + story.append(Paragraph("SUST Zahlungsstatistik 2025-2026", title_style)) + story.append(Spacer(1, 0.3*cm)) + story.append(Paragraph(f"Stand: {datetime.now().strftime('%d.%m.%Y')}", normal_style)) + story.append(Spacer(1, 0.5*cm)) + + # Section 1: 2025 Statistics + story.append(Paragraph("Zahlungsstatistiken 2025 - Nach Haushaltsgröße", heading_style)) + + table_data_2025 = [ + ["Mitglieder", "Haushalte", "Gesamtpersonen", "Zahlende HH", "Zahlende Personen", "Einnahmen", "Quote"], + ["2 (Paare)", "33", "66", "33", "66", "€900,00", "100,0%"], + ["1 (Einzelpersonen)", "109", "109", "102", "102", "€1.575,00", "93,6%"], + ["GESAMT", "142", "175", "135", "168", "€2.475,00", "95,1%"] + ] + + table = Table(table_data_2025, colWidths=[2.5*cm, 2*cm, 2.2*cm, 2*cm, 2.2*cm, 2*cm, 1.8*cm]) + table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4472C4')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('BOTTOMPADDING', (0, 0), (-1, 0), 8), + ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#E7E6E6')), + ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F2F2F2')]) + ])) + story.append(table) + story.append(Spacer(1, 0.5*cm)) + + # Section 2: 2026 Statistics + story.append(Paragraph("Zahlungsstatistiken 2026 - Nach Haushaltsgröße", heading_style)) + + table_data_2026 = [ + ["Mitglieder", "Haushalte", "Gesamtpersonen", "Zahlende HH", "Zahlende Personen", "Einnahmen", "Quote"], + ["2 (Paare)", "33", "66", "32", "64", "€1.095,00", "97,0%"], + ["1 (Einzelpersonen)", "109", "109", "97", "97", "€1.778,00", "89,0%"], + ["GESAMT", "142", "175", "129", "161", "€2.873,00", "90,8%"] + ] + + table = Table(table_data_2026, colWidths=[2.5*cm, 2*cm, 2.2*cm, 2*cm, 2.2*cm, 2*cm, 1.8*cm]) + table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4472C4')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('BOTTOMPADDING', (0, 0), (-1, 0), 8), + ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#E7E6E6')), + ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F2F2F2')]) + ])) + story.append(table) + story.append(Spacer(1, 0.5*cm)) + + # Section 3: Key insights + story.append(Paragraph("Wichtigste Erkenntnisse", heading_style)) + + insights_text = """ +
1. Tatsächliche Mitgliedschaft: 175 Personen in 142 primären Haushaltskonten (66 Personen in Paaren / 37,7%, 109 Einzelpersonen / 62,3%) +
2. 2025 Leistung: Paare 100% Quote, Einzelpersonen 93,6%, personenbasierte Quote 96,0% +
3. 2026 Leistung: Paare 97,0% Quote, Einzelpersonen 89,0%, personenbasierte Quote 92,0% (4% Rückgang) +
4. Umsatzwachstum: €2.475 → €2.873 (+16%) trotz Rückgang der Zahlungsquoten + """ + + story.append(Paragraph(insights_text, normal_style)) + story.append(Spacer(1, 0.5*cm)) + + # Page break + story.append(PageBreak()) + + # Section 4: Non-paying members + story.append(Paragraph("Nicht zahlende Mitglieder 2026 (Aktiv und Primär)", heading_style)) + story.append(Spacer(1, 0.3*cm)) + + non_paying = get_non_paying_members() + + table_data = [ + ["#", "HH-Sz.", "Vorname", "Nachname", "E-Mail", "Mitglied seit", "Letzte Zahlung", "Jahr"] + ] + + total_people = 0 + for idx, row in enumerate(non_paying, 1): + firstname = row[1] or "" + surname = row[2] or "" + email = row[3] or "N/A" + member_since = row[4].strftime("%Y-%m-%d") if row[4] else "N/A" + member_count = row[5] or 1 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "Nie" + last_year = int(row[7]) if row[7] else 0 + + total_people += member_count + + table_data.append([ + str(idx), + str(member_count), + firstname, + surname, + email, + member_since, + last_payment, + str(last_year) if last_year > 0 else "-" + ]) + + # Add summary + table_data.append([ + "", + "13", + "", + "", + "", + f"Insgesamt: {len(non_paying)} Haushalte / {total_people} Personen", + "", + "" + ]) + + table = Table(table_data, colWidths=[0.6*cm, 1*cm, 1.8*cm, 2*cm, 3.5*cm, 2*cm, 2*cm, 1*cm]) + table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4472C4')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('ALIGN', (0, 0), (1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 8), + ('BOTTOMPADDING', (0, 0), (-1, 0), 6), + ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#E7E6E6')), + ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTSIZE', (0, 1), (-1, -1), 7), + ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F2F2F2')]) + ])) + story.append(table) + + # Build PDF + doc.build(story) + print(f"PDF-Report erstellt: {pdf_file}") + +if __name__ == "__main__": + create_pdf() diff --git a/member_payment_list_head.html b/member_payment_list_head.html new file mode 100644 index 0000000..09182f0 --- /dev/null +++ b/member_payment_list_head.html @@ -0,0 +1,63 @@ + + + + + + \ No newline at end of file diff --git a/migrations/member_state_migration_v4.sql b/migrations/member_state_migration_v4.sql index c5a6a16..fda2628 100644 --- a/migrations/member_state_migration_v4.sql +++ b/migrations/member_state_migration_v4.sql @@ -95,7 +95,7 @@ SELECT msl.changed_by FROM member_state_log msl JOIN member_states ms ON msl.state_code = ms.state_code -WHERE msl.member_id = 123 -- Replace with actual member_id +WHERE msl.member_id = 27 ORDER BY msl.changed_at DESC; -- Count members by state @@ -109,7 +109,7 @@ ORDER BY ms.state_code; -- Step 7: Example usage of the change_member_state function -- Change member 123 to inactive -SELECT change_member_state(123, 'I', 1, 'Member requested temporary suspension'); +SELECT change_member_state(185, 'I', 1, 'Member requested temporary suspension'); -- Reactivate member 123 SELECT change_member_state(123, 'A', 1, 'Member reactivation approved'); @@ -252,4 +252,20 @@ GROUP BY ms.state_code, ms.state_name ORDER BY ms.state_code; -- Step 9: After testing, remove the old column (CAREFUL!) --- ALTER TABLE members DROP COLUMN member_active; \ No newline at end of file +-- ALTER TABLE members DROP COLUMN member_active; + + +-- 1) Add boolean column with default true +ALTER TABLE sust.members +ADD COLUMN member_primary boolean DEFAULT true; + +-- Optional: ensure all existing rows are true before special handling +UPDATE sust.members +SET member_primary = true +WHERE member_primary IS NULL; + +-- 2) Migration: partner-address holders are not primary +UPDATE sust.members +SET member_primary = false +WHERE member_email LIKE '%_partner%'; + diff --git a/migrations/member_street_migration.sql b/migrations/member_street_migration.sql new file mode 100644 index 0000000..1e85d6a --- /dev/null +++ b/migrations/member_street_migration.sql @@ -0,0 +1,210 @@ +-- Erstellung der Tabelle für PostgreSQL +CREATE TABLE streets ( + id serial4 PRIMARY KEY, + street_name TEXT NOT NULL UNIQUE +); + +-- Einfügen der extrahierten Straßennamen (alphabetisch sortiert) +INSERT INTO streets (street_name) VALUES +('Asperlweg'), +('Bachweg'), +('Dorfstraße'), +('Erlenstraße'), +('Gartenweg'), +('Lilienweg'), +('Prambachkirchner Straße'), +('Rosenweg'), +('Schmiedstraße'), +('Sonnenfeld'), +('Wimmstraße'); + +ALTER TABLE members ADD COLUMN IF NOT EXISTS street_id INTEGER REFERENCES streets(id); + +-- 1. Neue Spalte für die alte Hausnummer erstellen +ALTER TABLE members ADD COLUMN members_old_streetnumber TEXT; + +-- 2. Bestehende Hausnummern in die Backup-Spalte kopieren +UPDATE members SET members_old_streetnumber = member_streetnumber; + +CREATE TEMP TABLE address_mapping ( + old_address TEXT, + new_street TEXT, + new_number TEXT +); + +CREATE TABLE address_mapping ( + old_address TEXT, + new_street TEXT, + new_number TEXT, + target_member_id INTEGER DEFAULT NULL -- NEU: Für gezielte Zuweisungen +); + +-- Daten-Import aus den PDF-Quellen [cite: 7, 8, 10, 11] +INSERT INTO address_mapping (old_address, new_street, new_number) VALUES +('Sankt Thomas 1', 'Gartenweg', '1'), +('Sankt Thomas 2', 'Gartenweg', '2'), +('Sankt Thomas 3', 'Dorfstraße', '7'), +('Sankt Thomas 4', 'Bachweg', '5'), +('Sankt Thomas 5', 'Bachweg', '2'), +('Sankt Thomas 6', 'Bachweg', '9'), +('Sankt Thomas 8', 'Bachweg', '13'), +('Sankt Thomas 9', 'Bachweg', '10'), +('Sankt Thomas 10', 'Schmiedstraße', '11'), +('Sankt Thomas 11', 'Schmiedstraße', '24'), +('Sankt Thomas 12', 'Wimmstraße', '1a'), +('Sankt Thomas 13', 'Gartenweg', '3a'), +('Sankt Thomas 14', 'Schmiedstraße', '2a'), +('Sankt Thomas 15', 'Prambachkirchner Straße', '7'), +('Sankt Thomas 16', 'Dorfstraße', '1'), +('Sankt Thomas 17', 'Dorfstraße', '4'), +('Sankt Thomas 18', 'Prambachkirchner Straße', '9'), +('Sankt Thomas 19', 'Dorfstraße', '9'), +('Sankt Thomas 20', 'Dorfstraße', '5'), +('Sankt Thomas 21', 'Wimmstraße', '3'), +('Sankt Thomas 22', 'Lilienweg', '2'), +('Sankt Thomas 23', 'Prambachkirchner Straße', '6'), +('Sankt Thomas 24', 'Schmiedstraße', '14'), +('Sankt Thomas 26', 'Gartenweg', '5'), +('Sankt Thomas 27', 'Schmiedstraße', '16'), +('Sankt Thomas 28', 'Erlenstraße', '5'), +('Sankt Thomas 29', 'Dorfstraße', '11'), +('Sankt Thomas 30', 'Dorfstraße', '6'), +('Sankt Thomas 31', 'Schmiedstraße', '18'), +('Sankt Thomas 32', 'Gartenweg', '12'), +('Sankt Thomas 33', 'Gartenweg', '14'), +('Sankt Thomas 34', 'Prambachkirchner Straße', '3'), +('Sankt Thomas 35', 'Bachweg', '1'), +('Sankt Thomas 36', 'Erlenstraße', '7'), +('Sankt Thomas 37', 'Erlenstraße', '10'), +('Sankt Thomas 38', 'Prambachkirchner Straße', '10'), +('Sankt Thomas 39', 'Wimmstraße', '2'), +('Sankt Thomas 40', 'Wimmstraße', '9'), +('Sankt Thomas 40a', 'Wimmstraße', '11'), +('Sankt Thomas 41', 'Lilienweg', '14'), +('Sankt Thomas 42', 'Lilienweg', '12'), +('Sankt Thomas 43', 'Lilienweg', '10'), +('Sankt Thomas 44', 'Lilienweg', '8'), +('Sankt Thomas 45', 'Lilienweg', '6'), +('Sankt Thomas 46', 'Lilienweg', '4'), +('Sankt Thomas 47', 'Prambachkirchner Straße', '2'), +('Sankt Thomas 48', 'Gartenweg', '7'), +('Sankt Thomas 49', 'Gartenweg', '9'), +('Sankt Thomas 50', 'Erlenstraße', '1'), +('Sankt Thomas 51', 'Erlenstraße', '13'), +('Sankt Thomas 52', 'Bachweg', '19'), +('Sankt Thomas 53', 'Gartenweg', '21'), +('Sankt Thomas 54', 'Wimmstraße', '7'), +('Sankt Thomas 55', 'Erlenstraße', '12'), +('Sankt Thomas 56', 'Gartenweg', '16'), +('Sankt Thomas 57', 'Gartenweg', '18'), +('Sankt Thomas 58', 'Gartenweg', '23'), +('Sankt Thomas 60', 'Lilienweg', '5'), +('Sankt Thomas 61', 'Lilienweg', '7'), +('Sankt Thomas 62', 'Asperlweg', '1'), +('Sankt Thomas 63', 'Asperlweg', '2'), +('Sankt Thomas 64', 'Asperlweg', '4'), +('Sankt Thomas 65', 'Asperlweg', '3'), +('Sankt Thomas 66', 'Sonnenfeld', '3'), +('Sankt Thomas 70', 'Wimmstraße', '4'), +('Sankt Thomas 72a', 'Wimmstraße', '10a'), +('Sankt Thomas 73a', 'Wimmstraße', '12a'), +('Sankt Thomas 75', 'Lilienweg', '11'), +('Sankt Thomas 76', 'Lilienweg', '13'), +('Sankt Thomas 77', 'Lilienweg', '16'), +('Sankt Thomas 78', 'Schmiedstraße', '34'), +('Sankt Thomas 80', 'Schmiedstraße', '36'), +('Sankt Thomas 81', 'Erlenstraße', '9'), +('Sankt Thomas 82', 'Erlenstraße', '11'), +('Sankt Thomas 83', 'Gartenweg', '11'), +('Sankt Thomas 85', 'Lilienweg', '20'), +('Sankt Thomas 86', 'Lilienweg', '15'), +('Sankt Thomas 87', 'Lilienweg', '17'), +('Sankt Thomas 88', 'Wimmstraße', '14'), +('Sankt Thomas 89', 'Wimmstraße', '16'), +('Sankt Thomas 91', 'Lilienweg', '34'), +('Sankt Thomas 92', 'Lilienweg', '32'), +('Sankt Thomas 94', 'Lilienweg', '22'), +('Sankt Thomas 95', 'Lilienweg', '24'), +('Sankt Thomas 96', 'Lilienweg', '28'), +('Sankt Thomas 98', 'Erlenstraße', '15'), +('Sankt Thomas 100', 'Dorfstraße', '14'), +('Sankt Thomas 101', 'Dorfstraße', '16'), +('Sankt Thomas 102', 'Dorfstraße', '20'), +('Sankt Thomas 103', 'Rosenweg', '1'), +('Sankt Thomas 104', 'Rosenweg', '2'), +('Sankt Thomas 105', 'Rosenweg', '3'), +('Sankt Thomas 106', 'Rosenweg', '4'), +('Sankt Thomas 107', 'Rosenweg', '5'), +('Sankt Thomas 109', 'Rosenweg', '7'), +('Sankt Thomas 110', 'Rosenweg', '6'), +('Sankt Thomas 111', 'Rosenweg', '9'), +('Sankt Thomas 112', 'Rosenweg', '8'), +('Sankt Thomas 113', 'Rosenweg', '11'), +('Sankt Thomas 114', 'Rosenweg', '10'), +('Sankt Thomas 115', 'Rosenweg', '13'), +('Sankt Thomas 116', 'Rosenweg', '15'), +('Sankt Thomas 117', 'Rosenweg', '17'), +('Sankt Thomas 118', 'Sonnenfeld', '2'), +('Sankt Thomas 119', 'Sonnenfeld', '12'), +('Sankt Thomas 120', 'Sonnenfeld', '4'), +('Sankt Thomas 121', 'Sonnenfeld', '10'), +('Sankt Thomas 122', 'Sonnenfeld', '6'), +('Sankt Thomas 123', 'Sonnenfeld', '8'), +('Sankt Thomas 150', 'Erlenstraße', '6'); + +SELECT + m.member_street AS "Alte Gemeinde", + m.members_old_streetnumber AS "Alte Nummer (Backup)", + (m.member_street || ' ' || m.members_old_streetnumber) AS "Suchschlüssel", + s.street_name AS "Neue Straße", + map.new_number AS "Geplante neue Nummer" +FROM members m +JOIN address_mapping map ON (m.member_street || ' ' || m.members_old_streetnumber) = map.old_address +JOIN streets s ON s.street_name = map.new_street; + +/*UPDATE members m +SET + street_id = s.id, + member_streetnumber = map.new_number + -- member_street = 'St. Thomas' -- Vereinheitlichung +FROM address_mapping map +JOIN streets s ON s.street_name = map.new_street +WHERE (m.member_street || ' ' || m.members_old_streetnumber) = map.old_address; + +UPDATE members m +SET + street_id = s.id, + member_streetnumber = map.new_number, + member_street = 'St. Thomas' -- Optional: Den Gemeindenamen vereinheitlichen +FROM address_mapping map +JOIN streets s ON s.street_name = map.new_street +WHERE (m.member_street || ' ' || m.member_streetnumber) = map.old_address;*/ + +-- SCHRITT A: Spezifische Zuweisungen (ID-basiert) +UPDATE members m +SET + street_id = s.id, + member_streetnumber = map.new_number, + member_street = map.new_street +FROM address_mapping map +JOIN streets s ON s.street_name = map.new_street +WHERE (m.member_street || ' ' || m.members_old_streetnumber) = map.old_address; +--AND m.id = map.target_member_id; -- Matcht nur, wenn die ID explizit hinterlegt wurde + +-- SCHRITT B: Allgemeine Zuweisungen (nur für die, die noch keine street_id haben) +-- Hier nehmen wir nur die Mappings, die KEINE target_member_id haben +UPDATE members m +SET + street_id = s.id, + member_streetnumber = map.new_number, + member_street = 'Sankt Thomas' +FROM address_mapping map +JOIN streets s ON s.street_name = map.new_street +WHERE (m.member_street || ' ' || m.members_old_streetnumber) = map.old_address +AND m.street_id IS NULL -- Nur Mitglieder, die noch nicht durch Schritt A versorgt wurden +AND map.target_member_id IS NULL; -- Nur allgemeine Mapping-Regeln nutzen + +SELECT old_address, count(*) +FROM address_mapping +GROUP BY old_address +HAVING count(*) > 1; \ No newline at end of file diff --git a/migrations/members_by_section_.ddl b/migrations/members_by_section_.ddl new file mode 100644 index 0000000..9fa90fe --- /dev/null +++ b/migrations/members_by_section_.ddl @@ -0,0 +1,74 @@ +drop view sust.members_by_section; +drop view sust.members_active; + +CREATE VIEW sust.members_active +AS SELECT id, + member_surname, + member_firstname, + member_firstname_spouse, + member_count, + member_active, + current_state_code, + member_email + FROM members m + WHERE TRIM(BOTH FROM current_state_code) = 'A'::text AND member_count > 0 AND member_primary = true + ORDER BY member_surname; + +CREATE OR REPLACE VIEW sust.members_by_section +AS SELECT m.id, + m.member_surname, + m.member_firstname, + m.member_firstname_spouse, + m.member_active, + m.member_email, + s.section_name + FROM members_active m + JOIN member_sections ms ON ms.member_id = m.id + JOIN sections s ON s.id = ms.section_id; + +SELECT sum(member_count) FROM members_active + +drop view sust.members_paid_years; +CREATE OR REPLACE VIEW sust.members_paid_years +AS SELECT id, + member_surname, + member_firstname, + member_suffix, + member_firstname_spouse, + CONCAT_WS(' ', member_street, member_streetnumber, member_city) as member_address, + member_active, + current_state_code, + member_email, + member_membership_start, + CASE + WHEN member_membership_start > '2023-12-31'::date THEN 'N'::text + WHEN (EXISTS ( SELECT 1 + FROM payments p + WHERE p.member_id = m.id AND EXTRACT(year FROM p.payment_year) = 2023::numeric)) THEN 'X'::text + ELSE NULL::text + END AS paid_2023, + CASE + WHEN member_membership_start > '2024-12-31'::date THEN 'N'::text + WHEN (EXISTS ( SELECT 1 + FROM payments p + WHERE p.member_id = m.id AND EXTRACT(year FROM p.payment_year) = 2024::numeric)) THEN 'X'::text + ELSE NULL::text + END AS paid_2024, + CASE + WHEN member_membership_start > '2025-12-31'::date THEN 'N'::text + WHEN (EXISTS ( SELECT 1 + FROM payments p + WHERE p.member_id = m.id AND EXTRACT(year FROM p.payment_year) = 2025::numeric)) THEN 'X'::text + ELSE NULL::text + END AS paid_2025, + CASE + WHEN member_membership_start > '2026-12-31'::date THEN 'N'::text + WHEN (EXISTS ( SELECT 1 + FROM payments p + WHERE p.member_id = m.id AND EXTRACT(year FROM p.payment_year) = 2026::numeric)) THEN 'X'::text + ELSE NULL::text + END AS paid_2026 + FROM members m + WHERE TRIM(BOTH FROM current_state_code) = 'A'::text AND member_count > 0 AND member_primary = true + ORDER BY member_surname; + diff --git a/package-lock.json b/package-lock.json index d022013..c472c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,8 +4,10 @@ "requires": true, "packages": { "": { + "name": "SUST", "dependencies": { "@types/react": "^19.1.8", + "bootstrap": "^5.3.8", "lucide-react": "^0.525.0", "react": "^19.1.0" }, @@ -14,9 +16,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", + "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", "cpu": [ "ppc64" ], @@ -31,9 +33,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", + "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", "cpu": [ "arm" ], @@ -48,9 +50,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", + "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", "cpu": [ "arm64" ], @@ -65,9 +67,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", + "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", "cpu": [ "x64" ], @@ -82,9 +84,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", + "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", "cpu": [ "arm64" ], @@ -99,9 +101,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", + "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", "cpu": [ "x64" ], @@ -116,9 +118,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", + "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", "cpu": [ "arm64" ], @@ -133,9 +135,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", + "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", "cpu": [ "x64" ], @@ -150,9 +152,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", + "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", "cpu": [ "arm" ], @@ -167,9 +169,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", + "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", "cpu": [ "arm64" ], @@ -184,9 +186,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", + "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", "cpu": [ "ia32" ], @@ -201,9 +203,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", + "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", "cpu": [ "loong64" ], @@ -218,9 +220,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", + "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", "cpu": [ "mips64el" ], @@ -235,9 +237,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", + "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", "cpu": [ "ppc64" ], @@ -252,9 +254,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", + "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", "cpu": [ "riscv64" ], @@ -269,9 +271,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", + "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", "cpu": [ "s390x" ], @@ -286,9 +288,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", + "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", "cpu": [ "x64" ], @@ -303,9 +305,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", + "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", "cpu": [ "arm64" ], @@ -320,9 +322,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", + "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", "cpu": [ "x64" ], @@ -337,9 +339,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", + "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", "cpu": [ "arm64" ], @@ -354,9 +356,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", + "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", "cpu": [ "x64" ], @@ -371,9 +373,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", + "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", "cpu": [ "arm64" ], @@ -388,9 +390,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", + "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", "cpu": [ "x64" ], @@ -405,9 +407,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", + "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", "cpu": [ "arm64" ], @@ -422,9 +424,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", + "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", "cpu": [ "ia32" ], @@ -439,9 +441,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", + "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", "cpu": [ "x64" ], @@ -455,6 +457,17 @@ "node": ">=18" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.45.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.3.tgz", @@ -751,6 +764,25 @@ "csstype": "^3.0.2" } }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -758,9 +790,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -771,40 +803,43 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" } }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -966,14 +1001,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -983,18 +1018,18 @@ } }, "node_modules/vite": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", - "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.6", + "esbuild": "^0.27.0", + "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index d802780..e746744 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@types/react": "^19.1.8", + "bootstrap": "^5.3.8", "lucide-react": "^0.525.0", "react": "^19.1.0" }, diff --git a/query_membership_info.py b/query_membership_info.py new file mode 100644 index 0000000..7d4f190 --- /dev/null +++ b/query_membership_info.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Query script to get member information: +- Current active members +- Members who became inactive in 2025 +- Members who died in 2025 +""" + +import sys +from datetime import datetime +sys.path.insert(0, '/var/git/SUST/sustApp') + +from sqlalchemy import text +from models.database import SessionLocal, engine +from models.models import Members, MemberStateLog + +def get_active_members(): + """Get all currently active members""" + session = SessionLocal() + try: + query = session.query(Members).filter( + Members.member_active == True, # use current active state instead + Members.member_died == None + ).order_by(Members.member_surname, Members.member_firstname) + + members = query.all() + return members + finally: + session.close() + +def get_inactive_2025(): + """Get members who became inactive in 2025""" + session = SessionLocal() + try: + # Query for members who have a state change to inactive in 2025 + query = text(""" + SELECT DISTINCT m.id, m.member_firstname, m.member_surname, m.member_email, + msl.changed_at, msl.reason + FROM members m + JOIN member_state_log msl ON m.id = msl.member_id + WHERE msl.state_code = 'I' + AND EXTRACT(YEAR FROM msl.changed_at) = 2025 + ORDER BY m.member_surname, m.member_firstname + """) + + result = session.execute(query).fetchall() + return result + except Exception as e: + print(f"Error querying inactive members: {e}") + # Fallback to alternative query + return [] + finally: + session.close() + +def get_died_2025(): + """Get members who died in 2025""" + session = SessionLocal() + try: + query = session.query(Members).filter( + Members.member_died != None, + Members.member_died >= datetime(2025, 1, 1), + Members.member_died < datetime(2026, 1, 1) + ).order_by(Members.member_surname, Members.member_firstname) + + members = query.all() + return members + finally: + session.close() + +def print_active_members(members): + """Print active members in formatted table""" + if not members: + print("No active members found.") + return + + print("\n" + "="*100) + print(f"CURRENT ACTIVE MEMBERS ({len(members)} total)") + print("="*100) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Member Since':<12}") + print("-"*100) + + for idx, member in enumerate(members, 1): + email = member.member_email or "N/A" + member_since = member.member_membership_start.strftime("%Y-%m-%d") if member.member_membership_start else "N/A" + print(f"{idx:<4} {(member.member_firstname or ''):<20} {(member.member_surname or ''):<25} {email:<35} {member_since:<12}") + +def print_inactive_2025(members): + """Print members who became inactive in 2025""" + if not members: + print("No members became inactive in 2025.") + return + + print("\n" + "="*100) + print(f"MEMBERS WHO BECAME INACTIVE IN 2025 ({len(members)} total)") + print("="*100) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Inactive Date':<15} {'Reason':<20}") + print("-"*100) + + for idx, row in enumerate(members, 1): + name = row[1] or "" + surname = row[2] or "" + email = row[3] or "N/A" + changed_at = row[4].strftime("%Y-%m-%d") if row[4] else "N/A" + reason = row[5] or "N/A" + print(f"{idx:<4} {name:<20} {surname:<25} {email:<35} {changed_at:<15} {reason:<20}") + +def print_died_2025(members): + """Print members who died in 2025""" + if not members: + print("No members died in 2025.") + return + + print("\n" + "="*100) + print(f"MEMBERS WHO DIED IN 2025 ({len(members)} total)") + print("="*100) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Date of Death':<15}") + print("-"*100) + + for idx, member in enumerate(members, 1): + email = member.member_email or "N/A" + died_date = member.member_died.strftime("%Y-%m-%d") if member.member_died else "N/A" + print(f"{idx:<4} {(member.member_firstname or ''):<20} {(member.member_surname or ''):<25} {email:<35} {died_date:<15}") + +if __name__ == "__main__": + print("Querying SUST member database...") + + # Get active members + active_members = get_active_members() + print_active_members(active_members) + + # Get members who became inactive in 2025 + inactive_2025 = get_inactive_2025() + print_inactive_2025(inactive_2025) + + # Get members who died in 2025 + died_2025 = get_died_2025() + print_died_2025(died_2025) + + print("\n" + "="*100 + "\n") diff --git a/query_non_paying_2026.py b/query_non_paying_2026.py new file mode 100644 index 0000000..d40f978 --- /dev/null +++ b/query_non_paying_2026.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Query script to get active primary members who did NOT pay in 2026 +""" + +import sys +from datetime import datetime +sys.path.insert(0, '/var/git/SUST/sustApp') + +from sqlalchemy import text +from models.database import SessionLocal + +def get_non_paying_active_primary_2026(): + """Get active primary members who did not pay in 2026""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, + m.member_membership_start, m.member_count, + MAX(p.payment_date) as last_payment_date, + MAX(EXTRACT(YEAR FROM p.payment_year)) as last_payment_year + FROM members m + LEFT JOIN payments p ON m.id = p.member_id + WHERE m.member_active = true + AND m.member_died IS NULL + AND m.member_primary = true + AND m.id NOT IN ( + SELECT DISTINCT member_id + FROM payments + WHERE EXTRACT(YEAR FROM payment_year) = 2026 + ) + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email, + m.member_membership_start, m.member_count + ORDER BY m.member_count DESC, m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def print_non_paying_members(data): + """Print active primary members who did not pay in 2026""" + if not data: + print("No non-paying members found.") + return + + print("\n" + "="*150) + print(f"ACTIVE PRIMARY MEMBERS WHO DID NOT PAY IN 2026") + print(f"Total: {len(data)} members") + print("="*150) + print(f"{'#':<4} {'Member Cnt':<12} {'First Name':<20} {'Surname':<25} {'Email':<40} {'Member Since':<15} {'Last Payment':<15} {'Last Yr':<10}") + print("-"*150) + + total_people = 0 + count_by_member_count = {} + + for idx, row in enumerate(data, 1): + firstname = row[1] or "" + surname = row[2] or "" + email = row[3] or "N/A" + member_since = row[4].strftime("%Y-%m-%d") if row[4] else "N/A" + member_count = row[5] or 1 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "Never" + last_year = int(row[7]) if row[7] else 0 + + total_people += member_count + count_by_member_count[member_count] = count_by_member_count.get(member_count, 0) + 1 + + print(f"{idx:<4} {member_count:<12} {firstname:<20} {surname:<25} {email:<40} {member_since:<15} {last_payment:<15} {last_year:<10}") + + print("-"*150) + print(f"Total non-paying active primary members: {len(data)}") + print(f"Total people represented: {total_people}") + print(f"\nBreakdown by member count:") + for count in sorted(count_by_member_count.keys(), reverse=True): + num_households = count_by_member_count[count] + num_people = count * num_households + print(f" - Member count {count}: {num_households} household(s) representing {num_people} people") + +if __name__ == "__main__": + print("Querying SUST database for active primary members who did not pay in 2026...") + + non_paying = get_non_paying_active_primary_2026() + print_non_paying_members(non_paying) + + print("\n" + "="*140 + "\n") diff --git a/query_payment_info.py b/query_payment_info.py new file mode 100644 index 0000000..fc2d303 --- /dev/null +++ b/query_payment_info.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Query script to get payment information for members: +- Payments by active members +- Payments by members who became inactive in 2025 +- Payments by members who died in 2025 +""" + +import sys +from datetime import datetime +sys.path.insert(0, '/var/git/SUST/sustApp') + +from sqlalchemy import text +from models.database import SessionLocal + +def get_active_members_payments(): + """Get payment data for active members""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date, + STRING_AGG(DISTINCT EXTRACT(YEAR FROM p.payment_year)::text, ', ' ORDER BY EXTRACT(YEAR FROM p.payment_year)::text) as payment_years + FROM members m + LEFT JOIN payments p ON m.id = p.member_id + WHERE m.member_active = true AND m.member_died IS NULL + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def get_inactive_2025_payments(): + """Get payment data for members who became inactive in 2025""" + session = SessionLocal() + try: + query = text(""" + SELECT DISTINCT m.id, m.member_firstname, m.member_surname, m.member_email, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date, + STRING_AGG(DISTINCT EXTRACT(YEAR FROM p.payment_year)::text, ', ' ORDER BY EXTRACT(YEAR FROM p.payment_year)::text) as payment_years + FROM members m + LEFT JOIN payments p ON m.id = p.member_id + JOIN member_state_log msl ON m.id = msl.member_id + WHERE msl.state_code = 'I' + AND EXTRACT(YEAR FROM msl.changed_at) = 2025 + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def get_died_2025_payments(): + """Get payment data for members who died in 2025""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, m.member_died, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date, + STRING_AGG(DISTINCT EXTRACT(YEAR FROM p.payment_year)::text, ', ' ORDER BY EXTRACT(YEAR FROM p.payment_year)::text) as payment_years + FROM members m + LEFT JOIN payments p ON m.id = p.member_id + WHERE m.member_died >= '2025-01-01' AND m.member_died < '2026-01-01' + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email, m.member_died + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def print_active_payments(data): + """Print payment data for active members""" + if not data: + print("No payment data found for active members.") + return + + print("\n" + "="*130) + print(f"PAYMENTS BY ACTIVE MEMBERS ({len(data)} total)") + print("="*130) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid':<15} {'Last Payment':<15} {'Payment Years':<20}") + print("-"*130) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(data, 1): + payment_count = row[4] or 0 + total_paid = float(row[5]) if row[5] else 0 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "N/A" + payment_years = row[7] or "None" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15} {payment_years:<20}") + + print("-"*130) + print(f"{'TOTAL':<84} {count_all:<8} {total_all:<15.2f}") + +def print_inactive_2025_payments(data): + """Print payment data for members who became inactive in 2025""" + if not data: + print("No payment data found for members who became inactive in 2025.") + return + + print("\n" + "="*130) + print(f"PAYMENTS BY MEMBERS WHO BECAME INACTIVE IN 2025 ({len(data)} total)") + print("="*130) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid':<15} {'Last Payment':<15} {'Payment Years':<20}") + print("-"*130) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(data, 1): + payment_count = row[4] or 0 + total_paid = float(row[5]) if row[5] else 0 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "N/A" + payment_years = row[7] or "None" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15} {payment_years:<20}") + + print("-"*130) + print(f"{'TOTAL':<84} {count_all:<8} {total_all:<15.2f}") + +def print_died_2025_payments(data): + """Print payment data for members who died in 2025""" + if not data: + print("No payment data found for members who died in 2025.") + return + + print("\n" + "="*130) + print(f"PAYMENTS BY MEMBERS WHO DIED IN 2025 ({len(data)} total)") + print("="*130) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid':<15} {'Last Payment':<15} {'Payment Years':<20}") + print("-"*130) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(data, 1): + payment_count = row[5] or 0 + total_paid = float(row[6]) if row[6] else 0 + last_payment = row[7].strftime("%Y-%m-%d") if row[7] else "N/A" + payment_years = row[8] or "None" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15} {payment_years:<20}") + + print("-"*130) + print(f"{'TOTAL':<84} {count_all:<8} {total_all:<15.2f}") + +if __name__ == "__main__": + print("Querying SUST member payment data...") + + # Get payment data for active members + active_payments = get_active_members_payments() + print_active_payments(active_payments) + + # Get payment data for members who became inactive in 2025 + inactive_2025_payments = get_inactive_2025_payments() + print_inactive_2025_payments(inactive_2025_payments) + + # Get payment data for members who died in 2025 + died_2025_payments = get_died_2025_payments() + print_died_2025_payments(died_2025_payments) + + print("\n" + "="*130 + "\n") diff --git a/query_payment_info_2025.py b/query_payment_info_2025.py new file mode 100644 index 0000000..be77bbd --- /dev/null +++ b/query_payment_info_2025.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Query script to get payment information for members in 2025 only: +- Payments by active members in 2025 +- Payments by members who became inactive in 2025 +- Payments by members who died in 2025 +""" + +import sys +from datetime import datetime +sys.path.insert(0, '/var/git/SUST/sustApp') + +from sqlalchemy import text +from models.database import SessionLocal + +def get_active_members_payments_2025(): + """Get 2025 payment data for active members""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = 2025 + WHERE m.member_active = true AND m.member_died IS NULL AND m.member_primary = true + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email + HAVING COUNT(p.id) > 0 OR (COUNT(p.id) = 0 AND m.member_active = true) + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def get_inactive_2025_payments(): + """Get 2025 payment data for members who became inactive in 2025""" + session = SessionLocal() + try: + query = text(""" + SELECT DISTINCT m.id, m.member_firstname, m.member_surname, m.member_email, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = 2025 + JOIN member_state_log msl ON m.id = msl.member_id + WHERE msl.state_code = 'I' + AND EXTRACT(YEAR FROM msl.changed_at) = 2025 + AND m.member_primary = true + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def get_died_2025_payments(): + """Get 2025 payment data for members who died in 2025""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, m.member_died, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = 2025 + WHERE m.member_died >= '2025-01-01' AND m.member_died < '2026-01-01' + AND m.member_primary = true + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email, m.member_died + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def print_active_payments_2025(data): + """Print 2025 payment data for active members""" + if not data: + print("No payment data found for active members in 2025.") + return + + # Filter to only those with payments + paying_members = [row for row in data if row[4] and row[4] > 0] + all_members = data + + print("\n" + "="*120) + print(f"2025 PAYMENTS BY ACTIVE MEMBERS") + print(f"Total Members with payments: {len(paying_members)} | Total Active Members: {len(all_members)}") + print("="*120) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid (€)':<15} {'Last Payment':<15}") + print("-"*120) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(paying_members, 1): + payment_count = row[4] or 0 + total_paid = float(row[5]) if row[5] else 0 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "N/A" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15}") + + print("-"*120) + print(f"{'TOTAL':<79} {count_all:<8} {total_all:<15.2f}") + +def print_inactive_2025_payments(data): + """Print 2025 payment data for members who became inactive in 2025""" + if not data: + print("No payment data found for members who became inactive in 2025.") + return + + # Filter to only those with payments + paying_members = [row for row in data if row[4] and row[4] > 0] + all_members = data + + print("\n" + "="*120) + print(f"2025 PAYMENTS BY MEMBERS WHO BECAME INACTIVE IN 2025") + print(f"Total Members with payments: {len(paying_members)} | Total Members Inactive in 2025: {len(all_members)}") + print("="*120) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid (€)':<15} {'Last Payment':<15}") + print("-"*120) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(paying_members, 1): + payment_count = row[4] or 0 + total_paid = float(row[5]) if row[5] else 0 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "N/A" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15}") + + if paying_members: + print("-"*120) + print(f"{'TOTAL':<79} {count_all:<8} {total_all:<15.2f}") + +def print_died_2025_payments(data): + """Print 2025 payment data for members who died in 2025""" + if not data: + print("No payment data found for members who died in 2025.") + return + + # Filter to only those with payments + paying_members = [row for row in data if row[5] and row[5] > 0] + all_members = data + + print("\n" + "="*120) + print(f"2025 PAYMENTS BY MEMBERS WHO DIED IN 2025") + print(f"Total Members with payments: {len(paying_members)} | Total Members Died in 2025: {len(all_members)}") + print("="*120) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid (€)':<15} {'Last Payment':<15}") + print("-"*120) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(paying_members, 1): + payment_count = row[5] or 0 + total_paid = float(row[6]) if row[6] else 0 + last_payment = row[7].strftime("%Y-%m-%d") if row[7] else "N/A" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15}") + + if paying_members: + print("-"*120) + print(f"{'TOTAL':<79} {count_all:<8} {total_all:<15.2f}") + +if __name__ == "__main__": + print("Querying SUST member payment data for 2025...") + + # Get payment data for active members + active_payments = get_active_members_payments_2025() + print_active_payments_2025(active_payments) + + # Get payment data for members who became inactive in 2025 + inactive_2025_payments = get_inactive_2025_payments() + print_inactive_2025_payments(inactive_2025_payments) + + # Get payment data for members who died in 2025 + died_2025_payments = get_died_2025_payments() + print_died_2025_payments(died_2025_payments) + + print("\n" + "="*120 + "\n") diff --git a/query_payment_info_2026.py b/query_payment_info_2026.py new file mode 100644 index 0000000..e37652b --- /dev/null +++ b/query_payment_info_2026.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Query script to get payment information for members in 2026 only: +- Payments by active members in 2026 +- Payments by members who became inactive in 2025 in 2026 +- Payments by members who died in 2025 in 2026 +""" + +import sys +from datetime import datetime +sys.path.insert(0, '/var/git/SUST/sustApp') + +from sqlalchemy import text +from models.database import SessionLocal + +def get_active_members_payments_2026(): + """Get 2026 payment data for active members""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = 2026 + WHERE m.member_active = true AND m.member_died IS NULL AND m.member_primary = true + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email + HAVING COUNT(p.id) > 0 OR (COUNT(p.id) = 0 AND m.member_active = true) + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def get_inactive_2025_payments_2026(): + """Get 2026 payment data for members who became inactive in 2025""" + session = SessionLocal() + try: + query = text(""" + SELECT DISTINCT m.id, m.member_firstname, m.member_surname, m.member_email, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = 2026 + JOIN member_state_log msl ON m.id = msl.member_id + WHERE msl.state_code = 'I' + AND EXTRACT(YEAR FROM msl.changed_at) = 2025 + AND m.member_primary = true + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def get_died_2025_payments_2026(): + """Get 2026 payment data for members who died in 2025""" + session = SessionLocal() + try: + query = text(""" + SELECT m.id, m.member_firstname, m.member_surname, m.member_email, m.member_died, + COUNT(p.id) as payment_count, + SUM(p.payment_amount) as total_paid, + MAX(p.payment_date) as last_payment_date + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = 2026 + WHERE m.member_died >= '2025-01-01' AND m.member_died < '2026-01-01' + AND m.member_primary = true + GROUP BY m.id, m.member_firstname, m.member_surname, m.member_email, m.member_died + ORDER BY m.member_surname, m.member_firstname + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def print_active_payments_2026(data): + """Print 2026 payment data for active members""" + if not data: + print("No payment data found for active members in 2026.") + return + + # Filter to only those with payments + paying_members = [row for row in data if row[4] and row[4] > 0] + all_members = data + + print("\n" + "="*120) + print(f"2026 PAYMENTS BY ACTIVE MEMBERS") + print(f"Total Members with payments: {len(paying_members)} | Total Active Members: {len(all_members)}") + print("="*120) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid (€)':<15} {'Last Payment':<15}") + print("-"*120) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(paying_members, 1): + payment_count = row[4] or 0 + total_paid = float(row[5]) if row[5] else 0 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "N/A" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15}") + + print("-"*120) + print(f"{'TOTAL':<79} {count_all:<8} {total_all:<15.2f}") + +def print_inactive_2025_payments_2026(data): + """Print 2026 payment data for members who became inactive in 2025""" + if not data: + print("No payment data found for members who became inactive in 2025.") + return + + # Filter to only those with payments + paying_members = [row for row in data if row[4] and row[4] > 0] + all_members = data + + print("\n" + "="*120) + print(f"2026 PAYMENTS BY MEMBERS WHO BECAME INACTIVE IN 2025") + print(f"Total Members with payments: {len(paying_members)} | Total Members Inactive in 2025: {len(all_members)}") + print("="*120) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid (€)':<15} {'Last Payment':<15}") + print("-"*120) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(paying_members, 1): + payment_count = row[4] or 0 + total_paid = float(row[5]) if row[5] else 0 + last_payment = row[6].strftime("%Y-%m-%d") if row[6] else "N/A" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15}") + + if paying_members: + print("-"*120) + print(f"{'TOTAL':<79} {count_all:<8} {total_all:<15.2f}") + +def print_died_2025_payments_2026(data): + """Print 2026 payment data for members who died in 2025""" + if not data: + print("No payment data found for members who died in 2025.") + return + + # Filter to only those with payments + paying_members = [row for row in data if row[5] and row[5] > 0] + all_members = data + + print("\n" + "="*120) + print(f"2026 PAYMENTS BY MEMBERS WHO DIED IN 2025") + print(f"Total Members with payments: {len(paying_members)} | Total Members Died in 2025: {len(all_members)}") + print("="*120) + print(f"{'#':<4} {'First Name':<20} {'Surname':<25} {'Email':<35} {'Count':<8} {'Total Paid (€)':<15} {'Last Payment':<15}") + print("-"*120) + + total_all = 0 + count_all = 0 + + for idx, row in enumerate(paying_members, 1): + payment_count = row[5] or 0 + total_paid = float(row[6]) if row[6] else 0 + last_payment = row[7].strftime("%Y-%m-%d") if row[7] else "N/A" + + total_all += total_paid + count_all += payment_count + + print(f"{idx:<4} {(row[1] or ''):<20} {(row[2] or ''):<25} {(row[3] or 'N/A'):<35} {payment_count:<8} {total_paid:<15.2f} {last_payment:<15}") + + if paying_members: + print("-"*120) + print(f"{'TOTAL':<79} {count_all:<8} {total_all:<15.2f}") + +if __name__ == "__main__": + print("Querying SUST member payment data for 2026...") + + # Get payment data for active members + active_payments = get_active_members_payments_2026() + print_active_payments_2026(active_payments) + + # Get payment data for members who became inactive in 2025 + inactive_2025_payments = get_inactive_2025_payments_2026() + print_inactive_2025_payments_2026(inactive_2025_payments) + + # Get payment data for members who died in 2025 + died_2025_payments = get_died_2025_payments_2026() + print_died_2025_payments_2026(died_2025_payments) + + print("\n" + "="*120 + "\n") diff --git a/query_payment_stats_by_count.py b/query_payment_stats_by_count.py new file mode 100644 index 0000000..acace3f --- /dev/null +++ b/query_payment_stats_by_count.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +Query script to get payment information with member_count breakdown for 2025 and 2026 +""" + +import sys +from datetime import datetime +sys.path.insert(0, '/var/git/SUST/sustApp') + +from sqlalchemy import text +from models.database import SessionLocal + +def get_payment_stats_by_year(year): + """Get payment statistics for active primary members by member_count""" + session = SessionLocal() + try: + query = text(f""" + SELECT COALESCE(m.member_count, 1) as member_count, + COUNT(DISTINCT m.id) as num_households, + SUM(m.member_count) as total_people, + SUM(CASE WHEN p.id IS NOT NULL THEN 1 ELSE 0 END) as households_paid, + SUM(CASE WHEN p.id IS NOT NULL THEN m.member_count ELSE 0 END) as people_paid, + SUM(COALESCE(p.payment_amount, 0)) as total_revenue + FROM members m + LEFT JOIN payments p ON m.id = p.member_id AND EXTRACT(YEAR FROM p.payment_year) = {year} + WHERE m.member_active = true + AND m.member_died IS NULL + AND m.member_primary = true + GROUP BY COALESCE(m.member_count, 1) + ORDER BY COALESCE(m.member_count, 1) DESC + """) + result = session.execute(query).fetchall() + return result + finally: + session.close() + +def print_statistics(year, data): + """Print payment statistics""" + if not data: + print(f"No data found for {year}.") + return + + print("\n" + "="*110) + print(f"PAYMENT STATISTICS FOR ACTIVE PRIMARY MEMBERS - {year}") + print("="*110) + print(f"{'Member Cnt':<12} {'Households':<15} {'Total People':<15} {'Paid (HH)':<15} {'Paid (People)':<15} {'Revenue (€)':<15}") + print("-"*110) + + total_households = 0 + total_people = 0 + total_paid_households = 0 + total_paid_people = 0 + total_revenue = 0 + + for row in data: + member_count = int(row[0]) + num_households = row[1] + total_people_count = row[2] + households_paid = row[3] + people_paid = row[4] + revenue = float(row[5]) if row[5] else 0 + + total_households += num_households + total_people += total_people_count + total_paid_households += households_paid + total_paid_people += people_paid + total_revenue += revenue + + pct_households = (households_paid / num_households * 100) if num_households > 0 else 0 + pct_people = (people_paid / total_people_count * 100) if total_people_count > 0 else 0 + + print(f"{member_count:<12} {num_households:<15} {total_people_count:<15} {households_paid:<15} {people_paid:<15} {revenue:<15.2f}") + print(f"{'':12} {f'{pct_households:.1f}%':<15} {f'{pct_people:.1f}%':<15}") + + print("-"*110) + pct_households_total = (total_paid_households / total_households * 100) if total_households > 0 else 0 + pct_people_total = (total_paid_people / total_people * 100) if total_people > 0 else 0 + print(f"{'TOTAL':<12} {total_households:<15} {total_people:<15} {total_paid_households:<15} {total_paid_people:<15} {total_revenue:<15.2f}") + print(f"{'':12} {f'{pct_households_total:.1f}%':<15} {f'{pct_people_total:.1f}%':<15}") + print("="*110) + +if __name__ == "__main__": + print("Querying SUST payment statistics by member count...") + + # Get 2025 statistics + print_statistics(2025, get_payment_stats_by_year(2025)) + + # Get 2026 statistics + print_statistics(2026, get_payment_stats_by_year(2026)) + + print("\n") diff --git a/requirements.txt b/requirements.txt index 7854f65..5cb14d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,15 @@ annotated-doc==0.0.4 annotated-types==0.7.0 -anyio==4.12.0 +anyio==4.12.1 branca==0.8.2 -certifi==2025.11.12 +certifi==2026.1.4 chardet==5.2.0 charset-normalizer==3.4.4 click==8.3.1 dnspython==2.8.0 email-validator==2.3.0 -fastapi==0.124.0 -fastapi-cli==0.0.16 +fastapi==0.128.0 +fastapi-cli==0.0.20 folium==0.20.0 geographiclib==2.1 geopy==2.4.1 @@ -23,36 +23,36 @@ Jinja2==3.1.6 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 -numpy==2.3.5 +numpy==2.4.1 pandas==2.3.3 -pillow==12.0.0 +pillow==12.1.0 psycopg2-binary==2.9.11 pydantic==2.12.5 pydantic_core==2.41.5 Pygments==2.19.2 python-dateutil==2.9.0.post0 python-dotenv==1.2.1 -python-multipart==0.0.20 +python-multipart==0.0.21 pytz==2025.2 PyYAML==6.0.3 -reportlab==4.4.5 +reportlab==4.4.9 requests==2.32.5 rich==14.2.0 -rich-toolkit==0.17.0 +rich-toolkit==0.17.1 segno==1.6.6 shellingham==1.5.4 six==1.17.0 sniffio==1.3.1 -SQLAlchemy==2.0.44 -SQLAlchemy-Utils==0.42.0 +SQLAlchemy==2.0.45 +SQLAlchemy-Utils==0.42.1 starlette==0.50.0 -typer==0.20.0 +typer==0.21.1 typing-inspection==0.4.2 typing_extensions==4.15.0 -tzdata==2025.2 -urllib3==2.6.0 -uvicorn==0.38.0 +tzdata==2025.3 +urllib3==2.6.3 +uvicorn==0.40.0 uvloop==0.22.1 watchfiles==1.1.1 -websockets==15.0.1 +websockets==16.0 xyzservices==2025.11.0 diff --git a/sustApp/QR_Code advertisement.jpeg b/sustApp/QR_Code advertisement.jpeg new file mode 100644 index 0000000..562e2bf Binary files /dev/null and b/sustApp/QR_Code advertisement.jpeg differ diff --git a/sustApp/admin-frontend/README.md b/sustApp/admin-frontend/README.md new file mode 100644 index 0000000..962ae8f --- /dev/null +++ b/sustApp/admin-frontend/README.md @@ -0,0 +1,206 @@ +# SUST Admin Frontend + +A modern, responsive Bootstrap-based admin dashboard for managing SUST members and payments. + +## Features + +### 📊 Dashboard Overview +- Quick statistics on active, inactive, and deactivated members +- Payment and member count summary +- Quick navigation to management sections + +### 👥 Member Management +- View all members in a searchable, sortable table +- Filter by name or email +- View detailed member information with modal +- Add new members with validation +- See member activity and payment history + +### 💳 Payment Management +- View all payment records +- Associate payments with members +- Search and filter payments by member name +- Track payment dates and amounts + +## Project Structure + +``` +admin-frontend/ +├── src/ +│ ├── components/ # React components +│ │ ├── Navbar.tsx # Navigation bar +│ │ ├── StatsCard.tsx # Statistics dashboard cards +│ │ ├── MembersTable.tsx # Members list table +│ │ ├── MemberModal.tsx # Member details modal +│ │ ├── PaymentsTable.tsx # Payments list +│ │ └── AddMemberForm.tsx # New member form +│ ├── api.ts # API client helper functions +│ ├── types.ts # TypeScript type definitions +│ ├── App.tsx # Main application component +│ ├── App.css # Custom styles +│ └── main.tsx # Entry point +├── index.html # HTML template +├── vite.config.ts # Vite configuration +├── tsconfig.json # TypeScript configuration +└── package.json # Dependencies +``` + +## Installation + +1. Navigate to the admin-frontend directory: +```bash +cd sustApp/admin-frontend +``` + +2. Install dependencies: +```bash +npm install +``` + +## Development + +Start the development server: +```bash +npm run dev +``` + +The frontend will be available at `http://localhost:5174` + +**Important:** Make sure your FastAPI backend is running on `http://localhost:8000` + +## Build for Production + +```bash +npm run build +``` + +Output files will be in the `dist/` directory. + +## API Integration + +The frontend connects to these FastAPI endpoints: + +### Members +- `GET /members/` - List members +- `GET /member/{member_id}` - Get member details +- `POST /members/` - Create single member +- `POST /fullmembers/` - Create multiple members + +### Payments +- `GET /payments/` - List all payments +- `GET /payments/{member_id}` - Get member payments + +### Stats +- `GET /activememberscount/` - Count active members +- `GET /inactivememberscount/` - Count inactive members +- `GET /deactivatedmemberscount/` - Count deactivated members + +## Configuration + +To change the API base URL, edit `src/api.ts`: + +```typescript +const API_BASE = 'http://localhost:8000' +``` + +To change the development server port, edit `vite.config.ts`: + +```typescript +server: { + port: 5174, + ... +} +``` + +## Technologies Used + +- **React 19** - UI framework +- **TypeScript** - Type safety +- **Bootstrap 5** - CSS framework +- **Axios** - HTTP client +- **Vite** - Build tool + +## Features Overview + +### Dashboard Tab +- Real-time statistics cards +- Active/inactive/deactivated member counts +- Quick links to management sections + +### Members Tab +- **Search/Filter**: Find members by name or email +- **View Details**: Click "View" to see full member information + - Contact details + - Address information + - Status and active/inactive status + - Recent payment history +- **Add Member**: Create new members with comprehensive form + - Required fields: First name, surname, street, city + - Optional: Email, spouse name, ZIP code, etc. + +### Payments Tab +- **Complete Payment History**: View all payments with member associations +- **Search**: Filter by member name +- **Details**: Payment date, year, and amount + +## Responsive Design + +The interface is fully responsive and works on: +- Desktop (1024px+) +- Tablet (768px-1024px) +- Mobile (< 768px) + +## Styling + +Custom Bootstrap styles are in `src/App.css` with: +- Dark navbar +- Custom tab navigation +- Hover effects on buttons +- Modal styling +- Mobile-responsive adjustments + +## Error Handling + +The frontend includes error handling for: +- API connection failures +- Missing data +- Form validation errors +- Loading states + +## Future Enhancements + +- Edit member functionality +- Delete member confirmation +- Payment record management +- Member state transitions +- Bulk member import +- Export reports +- Authentication/authorization +- Member notes/comments +- Email notifications +- Advanced filtering and sorting + +## Troubleshooting + +### API Connection Issues +If you see "Failed to load data" errors: +1. Verify FastAPI backend is running on port 8000 +2. Check CORS configuration in `sustApp/main.py` +3. Check browser console for specific errors + +### Build Issues +```bash +# Clear node_modules and reinstall +rm -rf node_modules package-lock.json +npm install +``` + +### Port Already in Use +```bash +# Change port in vite.config.ts or +npm run dev -- --port 5175 +``` + +## Support + +For issues or questions, check the main SUST project documentation. diff --git a/sustApp/admin-frontend/index.html b/sustApp/admin-frontend/index.html new file mode 100644 index 0000000..211ede1 --- /dev/null +++ b/sustApp/admin-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SUST Admin - Member & Payment Management + + +
+ + + diff --git a/sustApp/admin-frontend/package-lock.json b/sustApp/admin-frontend/package-lock.json new file mode 100644 index 0000000..69f3f77 --- /dev/null +++ b/sustApp/admin-frontend/package-lock.json @@ -0,0 +1,2158 @@ +{ + "name": "sust-admin-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sust-admin-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "bootstrap": "^5.3.2", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "^5.8.3", + "vite": "^7.0.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.355", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz", + "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/sustApp/admin-frontend/package.json b/sustApp/admin-frontend/package.json new file mode 100644 index 0000000..7bed737 --- /dev/null +++ b/sustApp/admin-frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "sust-admin-frontend", + "version": "1.0.0", + "description": "Bootstrap admin frontend for SUST member and payment management", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "bootstrap": "^5.3.2", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "^5.8.3", + "vite": "^7.0.6" + } +} diff --git a/sustApp/admin-frontend/src/App.css b/sustApp/admin-frontend/src/App.css new file mode 100644 index 0000000..2db4f1b --- /dev/null +++ b/sustApp/admin-frontend/src/App.css @@ -0,0 +1,338 @@ +/* Modern Admin Dashboard Styles */ + +:root { + --primary-color: #27ae60; + --secondary-color: #f39c12; + --danger-color: #e74c3c; + --info-color: #3498db; + --text-dark: #2c3e50; + --text-muted: #7f8c8d; + --bg-light: #f8f9fa; + --border-color: #ecf0f1; +} + +* { + box-sizing: border-box; +} + +body { + background-color: var(--bg-light); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--text-dark); +} + +/* Navbar */ +.navbar { + background-color: #ffffff !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + border-bottom: 1px solid var(--border-color); +} + +.navbar-brand { + font-weight: 700 !important; + font-size: 1.4rem !important; + color: var(--text-dark) !important; +} + +.nav-link { + color: var(--text-muted) !important; + font-weight: 500; + transition: color 0.3s ease; + font-size: 0.95rem; +} + +.nav-link:hover { + color: var(--primary-color) !important; +} + +/* Cards */ +.card { + border: none !important; + border-radius: 8px !important; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important; + transition: all 0.3s ease; +} + +.card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important; +} + +.card-body { + padding: 1.5rem; +} + +.card-title { + color: var(--text-dark); + font-weight: 600; + margin-bottom: 1rem; +} + +/* Badges */ +.badge { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + font-weight: 500; + border-radius: 4px; +} + +/* Tables */ +.table { + margin-bottom: 0; +} + +.table thead { + background-color: var(--bg-light); + border-bottom: 2px solid var(--border-color); +} + +.table thead th { + color: var(--text-muted); + font-weight: 600; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; + border: none; + padding: 1rem 0.75rem; +} + +.table tbody tr { + transition: background-color 0.2s ease; + border-bottom: 1px solid var(--border-color); +} + +.table tbody tr:hover { + background-color: rgba(39, 174, 96, 0.03); +} + +.table tbody td { + padding: 1rem 0.75rem; + color: var(--text-dark); + vertical-align: middle; +} + +/* Buttons */ +.btn { + border-radius: 6px; + font-weight: 500; + transition: all 0.3s ease; + border: none; +} + +.btn-primary { + background-color: var(--primary-color) !important; + color: white !important; +} + +.btn-primary:hover { + background-color: #229954 !important; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3) !important; +} + +.btn-secondary { + background-color: var(--text-muted) !important; + color: white !important; +} + +.btn-secondary:hover { + background-color: #6c757d !important; +} + +.btn-sm { + padding: 0.4rem 0.75rem; + font-size: 0.85rem; +} + +/* Form Controls */ +.form-control, +.form-select { + border: 1px solid var(--border-color) !important; + border-radius: 6px !important; + padding: 0.75rem 1rem !important; + font-size: 0.95rem; + transition: all 0.3s ease; + background-color: #ffffff; +} + +.form-control:focus, +.form-select:focus { + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px rgba(39, 174, 96, 0.1) !important; +} + +.form-label { + color: var(--text-dark); + font-weight: 500; + margin-bottom: 0.5rem; +} + +/* Modals */ +.modal { + display: block; + position: fixed; + z-index: 1050; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + animation: fadeIn 0.3s ease; +} + +.modal-dialog { + animation: slideUp 0.3s ease; +} + +.modal-content { + border: none !important; + border-radius: 12px !important; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2) !important; +} + +.modal-header { + border-bottom: 1px solid var(--border-color) !important; + padding: 1.5rem !important; + background-color: var(--bg-light); +} + +.modal-title { + color: var(--text-dark); + font-weight: 600; + font-size: 1.25rem; +} + +.modal-body { + padding: 1.5rem !important; + color: var(--text-dark); +} + +.modal-footer { + border-top: 1px solid var(--border-color) !important; + padding: 1.5rem !important; + background-color: var(--bg-light); +} + +/* Spinner */ +.spinner-border { + color: var(--primary-color) !important; +} + +/* Alerts */ +.alert { + border: none !important; + border-radius: 8px !important; + border-left: 4px solid; +} + +.alert-danger { + background-color: #fee !important; + border-left-color: #e74c3c !important; + color: #c0392b !important; +} + +.alert-info { + background-color: #eff !important; + border-left-color: #3498db !important; + color: #2980b9 !important; +} + +.alert-success { + background-color: #efe !important; + border-left-color: #27ae60 !important; + color: #229954 !important; +} + +.alert-warning { + background-color: #ffeaa7 !important; + border-left-color: #f39c12 !important; + color: #d68910 !important; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Responsive */ +@media (max-width: 768px) { + .container-fluid { + padding-left: 1rem; + padding-right: 1rem; + } + + .card-body { + padding: 1rem; + } + + .table { + font-size: 0.9rem; + } + + .table thead th { + padding: 0.75rem 0.5rem; + } + + .table tbody td { + padding: 0.75rem 0.5rem; + } + + h2 { + font-size: 1.5rem; + } + + .btn { + font-size: 0.9rem; + padding: 0.5rem 1rem; + } + + .d-flex { + flex-direction: column; + gap: 0.5rem !important; + } + + .d-flex.gap-2 { + gap: 0.5rem !important; + } +} + +@media (max-width: 480px) { + .navbar-brand { + font-size: 1.1rem !important; + } + + h2 { + font-size: 1.25rem; + } + + .card-body { + padding: 0.75rem; + } + + .modal-body { + padding: 1rem !important; + } + + .btn-sm { + padding: 0.35rem 0.5rem; + font-size: 0.8rem; + } +} diff --git a/sustApp/admin-frontend/src/App.tsx b/sustApp/admin-frontend/src/App.tsx new file mode 100644 index 0000000..b9a12f7 --- /dev/null +++ b/sustApp/admin-frontend/src/App.tsx @@ -0,0 +1,262 @@ +import React, { useState, useEffect } from 'react' +import { Navbar } from './components/Navbar' +import { StatsCard } from './components/StatsCard' +import { MembersTable } from './components/MembersTable' +import { MemberModal } from './components/MemberModal' +import { PaymentsTable } from './components/PaymentsTable' +import { AddMemberForm } from './components/AddMemberForm' +import { getMembers, getAllPayments, getStats } from './api' +import { Member, Payment, Stats } from './types' +import './App.css' + +function App() { + const [members, setMembers] = useState([]) + const [payments, setPayments] = useState([]) + const [stats, setStats] = useState({ + activeCount: 0, + inactiveCount: 0, + deactivatedCount: 0, + }) + const [loading, setLoading] = useState(true) + const [selectedTab, setSelectedTab] = useState<'overview' | 'members' | 'payments'>('overview') + const [selectedMember, setSelectedMember] = useState(null) + const [showAddMember, setShowAddMember] = useState(false) + + const loadData = async () => { + try { + setLoading(true) + const [membersData, paymentsData, statsData] = await Promise.all([ + getMembers(0, 500), + getAllPayments(), + getStats(), + ]) + setMembers(membersData) + setPayments(paymentsData) + setStats(statsData) + } catch (error) { + console.error('Failed to load data:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) + + const handleAddMemberSuccess = () => { + setShowAddMember(false) + loadData() + } + + const tabContent = [ + { id: 'overview', label: 'Dashboard', icon: '📊' }, + { id: 'members', label: 'Member', icon: '👥' }, + { id: 'payments', label: 'Payments', icon: '💳' }, + ] + + return ( +
+ + +
+
+ {/* Tab Navigation */} +
+
+ {tabContent.map((tab) => ( + + ))} +
+ {selectedTab === 'members' && ( + + )} +
+ + {/* Overview Tab */} + {selectedTab === 'overview' && ( +
+
+

+ Dashboard Overview +

+
+ + {loading ? ( +
+
+ Loading... +
+
+ ) : ( + <> +
+ + + +
+ +
+
+
+
+
+ Quick Stats +
+
+

+ Total Members: + + {members.length} + +

+

+ Total Payments: + + {payments.length} + +

+

+ Member Distribution: + + {members.reduce((sum, m) => sum + m.member_count, 0)} + +

+
+
+
+
+ +
+
+
+
+ Quick Actions +
+
+ + +
+
+
+
+
+ + )} +
+ )} + + {/* Members Tab */} + {selectedTab === 'members' && ( +
+
+

+ Member Management +

+
+
+
+ +
+
+
+ )} + + {/* Payments Tab */} + {selectedTab === 'payments' && ( +
+
+

+ Payment Records +

+
+
+
+ +
+
+
+ )} +
+
+ + {/* Modals */} + {selectedMember && ( + setSelectedMember(null)} /> + )} + + {showAddMember && ( + setShowAddMember(false)} + /> + )} +
+ ) +} + +export default App diff --git a/sustApp/admin-frontend/src/api.ts b/sustApp/admin-frontend/src/api.ts new file mode 100644 index 0000000..9dcb502 --- /dev/null +++ b/sustApp/admin-frontend/src/api.ts @@ -0,0 +1,59 @@ +import axios from 'axios' +import { Member, Payment, Stats } from './types' + +const API_BASE = 'http://localhost:8000' + +const api = axios.create({ + baseURL: API_BASE, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Members +export const getMembers = async (skip = 0, limit = 100) => { + const response = await api.get('/members/', { params: { skip, limit } }) + return response.data +} + +export const getMember = async (memberId: number) => { + const response = await api.get(`/member/${memberId}`) + return response.data +} + +export const createMember = async (member: Partial) => { + const response = await api.post('/members/', member) + return response.data +} + +export const createMembers = async (members: Partial[]) => { + const response = await api.post('/fullmembers/', members) + return response.data +} + +// Payments +export const getAllPayments = async () => { + const response = await api.get('/payments/') + return response.data +} + +export const getMemberPayments = async (memberId: number) => { + const response = await api.get(`/payments/${memberId}`) + return response.data +} + +// Stats +export const getStats = async (): Promise => { + const [activeCount, inactiveCount, deactivatedCount] = await Promise.all([ + api.get('/activememberscount/').then((r) => r.data || 0), + api.get('/inactivememberscount/').then((r) => r.data || 0), + api.get('/deactivatedmemberscount/').then((r) => r.data || 0), + ]) + return { + activeCount: activeCount || 0, + inactiveCount: inactiveCount || 0, + deactivatedCount: deactivatedCount || 0, + } +} + +export default api diff --git a/sustApp/admin-frontend/src/components/AddMemberForm.tsx b/sustApp/admin-frontend/src/components/AddMemberForm.tsx new file mode 100644 index 0000000..4ad1bea --- /dev/null +++ b/sustApp/admin-frontend/src/components/AddMemberForm.tsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react' +import { Member } from '../types' +import { createMember } from '../api' + +interface AddMemberFormProps { + onSuccess: () => void + onCancel: () => void +} + +export const AddMemberForm: React.FC = ({ onSuccess, onCancel }) => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [formData, setFormData] = useState>({ + member_firstname: '', + member_surname: '', + member_street: '', + member_city: '', + member_count: 1, + member_active: true, + }) + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target + setFormData((prev) => ({ + ...prev, + [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value, + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + try { + if (!formData.member_firstname || !formData.member_surname || !formData.member_street || !formData.member_city) { + throw new Error('Please fill in all required fields') + } + + await createMember(formData) + onSuccess() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create member') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+
Add New Member
+ +
+
+
+ {error && ( +
+ {error} +
+ )} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/sustApp/admin-frontend/src/components/MemberModal.tsx b/sustApp/admin-frontend/src/components/MemberModal.tsx new file mode 100644 index 0000000..c7b0590 --- /dev/null +++ b/sustApp/admin-frontend/src/components/MemberModal.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import { Member } from '../types' + +interface MemberModalProps { + member: Member | null + onClose: () => void +} + +export const MemberModal: React.FC = ({ member, onClose }) => { + if (!member) return null + + return ( +
+
+
+
+
+ {member.member_firstname} {member.member_surname} +
+ +
+
+
+
+
+ +

{member.id}

+
+
+ +

{member.member_firstname}

+
+
+ +

{member.member_surname}

+
+
+ +

{member.member_email || 'N/A'}

+
+
+
+
+ +

{member.member_street}

+
+
+ +

{member.member_zip || 'N/A'}

+
+
+ +

{member.member_city}

+
+
+ +

+ + {member.member_active ? '✓ Active' : '✗ Inactive'} + +

+
+
+
+ + {member.member_firstname_spouse && ( +
+ Spouse: {member.member_firstname_spouse} +
+ )} + + {member.member_payments && member.member_payments.length > 0 && ( +
+
Recent Payments
+
+ + + + + + + + + + {member.member_payments.slice(0, 5).map((payment) => ( + + + + + + ))} + +
DateYearAmount
{new Date(payment.payment_date).toLocaleDateString()}{payment.payment_year} + €{payment.payment_amount ? payment.payment_amount.toFixed(2) : 'N/A'} +
+
+
+ )} +
+
+ + +
+
+
+
+ ) +} diff --git a/sustApp/admin-frontend/src/components/MembersTable.tsx b/sustApp/admin-frontend/src/components/MembersTable.tsx new file mode 100644 index 0000000..911984a --- /dev/null +++ b/sustApp/admin-frontend/src/components/MembersTable.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react' +import { Member } from '../types' + +interface MembersTableProps { + members: Member[] + onViewDetails: (member: Member) => void + loading: boolean +} + +export const MembersTable: React.FC = ({ members, onViewDetails, loading }) => { + const [filter, setFilter] = useState('') + + const filteredMembers = members.filter( + (m) => + m.member_firstname.toLowerCase().includes(filter.toLowerCase()) || + m.member_surname.toLowerCase().includes(filter.toLowerCase()) || + m.member_email?.toLowerCase().includes(filter.toLowerCase()) + ) + + if (loading) { + return ( +
+
+ Loading... +
+
+ ) + } + + return ( +
+
+ setFilter(e.target.value)} + style={{ fontSize: '0.95rem' }} + /> +
+ + {filteredMembers.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + {filteredMembers.map((member) => ( + + + + + + + + + + ))} + +
IDFirst NameSurnameEmailCityStatusActions
{member.id}{member.member_firstname}{member.member_surname}{member.member_email}{member.member_city} + + {member.member_active ? '✓ Active' : '✗ Inactive'} + + + +
+
+
+ Showing {filteredMembers.length} of {members.length} members +
+ + ) : ( +
+

No members found matching your search

+
+ )} +
+ ) +} diff --git a/sustApp/admin-frontend/src/components/Navbar.tsx b/sustApp/admin-frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..2733156 --- /dev/null +++ b/sustApp/admin-frontend/src/components/Navbar.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +export const Navbar: React.FC = () => { + return ( + <> + +
+
+ Last updated: {new Date().toLocaleTimeString()} +
+
+ + ) +} diff --git a/sustApp/admin-frontend/src/components/PaymentsTable.tsx b/sustApp/admin-frontend/src/components/PaymentsTable.tsx new file mode 100644 index 0000000..99fcc0e --- /dev/null +++ b/sustApp/admin-frontend/src/components/PaymentsTable.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react' +import { Member, Payment } from '../types' + +interface PaymentsTableProps { + members: Member[] + payments: Payment[] + loading: boolean +} + +export const PaymentsTable: React.FC = ({ members, payments, loading }) => { + const [filter, setFilter] = useState('') + + const getMemberName = (memberId: number) => { + const member = members.find((m) => m.id === memberId) + return member ? `${member.member_firstname} ${member.member_surname}` : 'Unknown' + } + + const filteredPayments = payments.filter((p) => { + const memberName = getMemberName(p.member_id).toLowerCase() + return memberName.includes(filter.toLowerCase()) + }) + + if (loading) { + return ( +
+
+ Loading... +
+
+ ) + } + + return ( +
+
+ setFilter(e.target.value)} + style={{ fontSize: '0.95rem' }} + /> +
+ + {filteredPayments.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {filteredPayments.map((payment) => ( + + + + + + + + + ))} + +
Payment IDMember NameMember IDPayment DateYearAmount
{payment.id}{getMemberName(payment.member_id)}{payment.member_id} + {new Date(payment.payment_date).toLocaleDateString()} + {payment.payment_year} + €{payment.payment_amount ? payment.payment_amount.toFixed(2) : '0.00'} +
+
+
+ Showing {filteredPayments.length} of {payments.length} payments + {filteredPayments.length > 0 && ( + + • Total: €{filteredPayments.reduce((sum, p) => sum + (p.payment_amount || 0), 0).toFixed(2)} + + )} +
+ + ) : ( +
+

No payments found matching your search

+
+ )} +
+ ) +} diff --git a/sustApp/admin-frontend/src/components/StatsCard.tsx b/sustApp/admin-frontend/src/components/StatsCard.tsx new file mode 100644 index 0000000..79a2799 --- /dev/null +++ b/sustApp/admin-frontend/src/components/StatsCard.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +interface StatsCardProps { + title: string + count: number + icon: string + color: string +} + +export const StatsCard: React.FC = ({ title, count, icon, color }) => { + const colorMap: { [key: string]: string } = { + success: '#27ae60', + warning: '#f39c12', + danger: '#e74c3c', + primary: '#3498db', + } + + const bgColor = colorMap[color] || '#3498db' + + return ( +
+
+
+
+
+

+ {title} +

+

+ {count} +

+
+
{icon}
+
+
+
+
+ ) +} diff --git a/sustApp/admin-frontend/src/main.tsx b/sustApp/admin-frontend/src/main.tsx new file mode 100644 index 0000000..80c8c8c --- /dev/null +++ b/sustApp/admin-frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import 'bootstrap/dist/css/bootstrap.min.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/sustApp/admin-frontend/src/types.ts b/sustApp/admin-frontend/src/types.ts new file mode 100644 index 0000000..26e00e0 --- /dev/null +++ b/sustApp/admin-frontend/src/types.ts @@ -0,0 +1,41 @@ +export interface Member { + id: number + member_count: number + member_firstname: string + member_firstname_spouse?: string + member_surname: string + member_suffix?: string + member_street: string + member_streetnumber?: string + member_email?: string + member_zip?: number + member_city: string + member_active: boolean + member_gender?: string + member_connected?: number + member_printed?: boolean + current_state_code?: string + current_state_name?: string + member_deliveries?: Delivery[] + member_payments?: Payment[] +} + +export interface Payment { + id: number + payment_date: string + payment_year: string + member_id: number + payment_amount?: number +} + +export interface Delivery { + id: number + delivery_date: string + delivered_by_id?: number +} + +export interface Stats { + activeCount: number + inactiveCount: number + deactivatedCount: number +} diff --git a/sustApp/admin-frontend/tsconfig.json b/sustApp/admin-frontend/tsconfig.json new file mode 100644 index 0000000..e9338c2 --- /dev/null +++ b/sustApp/admin-frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "noEmit": true, + "strict": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/sustApp/admin-frontend/tsconfig.node.json b/sustApp/admin-frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/sustApp/admin-frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/sustApp/admin-frontend/vite.config.ts b/sustApp/admin-frontend/vite.config.ts new file mode 100644 index 0000000..2127acf --- /dev/null +++ b/sustApp/admin-frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}) diff --git a/sustApp/be_logging.py b/sustApp/be_logging.py new file mode 100644 index 0000000..04d3429 --- /dev/null +++ b/sustApp/be_logging.py @@ -0,0 +1,47 @@ +from fastapi import FastAPI, WebSocket +import asyncio +import logging + +app = FastAPI() + +# Store active WebSocket connections +connected_clients = [] + +@app.websocket("/ws/logs") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + connected_clients.append(websocket) + try: + while True: + await websocket.receive_text() + except: + connected_clients.remove(websocket) + +# Custom logging handler to send logs via WebSocket +class WebSocketHandler(logging.Handler): + def emit(self, record): + log_message = self.format(record) + # Send to all connected clients + asyncio.create_task(broadcast_log(log_message)) + +async def broadcast_log(message): + for client in connected_clients[:]: # Copy list to avoid modification during iteration + try: + await client.send_json({ + "type": "log", + "message": message, + "timestamp": asyncio.get_event_loop().time() + }) + except: + connected_clients.remove(client) + +# Set up logging +logger = logging.getLogger("app") +logger.setLevel(logging.INFO) +ws_handler = WebSocketHandler() +logger.addHandler(ws_handler) + +@app.get("/test") +async def test_endpoint(): + logger.info("This is a test log message from backend!") + return {"message": "Check your browser console"} \ No newline at end of file diff --git a/sustApp/config.yaml b/sustApp/config.yaml index 4912fb1..8894427 100644 --- a/sustApp/config.yaml +++ b/sustApp/config.yaml @@ -1,5 +1,6 @@ {'maintainer': 'Markus Zimmerberger', - 'year': 2025, + 'year': 2026, + 'yearly_fee': 18, 'map_params': {'metrics': ['euclidean', 'l1', 'manhattan'], 'min_dist': [0.01, 0.1, 0.5], 'spread': 1.0}, diff --git a/sustApp/config_loader.py b/sustApp/config_loader.py index 0cb6ba9..af4b56b 100644 --- a/sustApp/config_loader.py +++ b/sustApp/config_loader.py @@ -1,5 +1,9 @@ import yaml +import os -def load_config(config_path="./sustApp/config.yaml"): +def load_config(config_path=None): + if config_path is None: + # Resolve path relative to this module's location + config_path = os.path.join(os.path.dirname(__file__), "config.yaml") with open(config_path, encoding='utf8') as f: return yaml.load(f, Loader=yaml.FullLoader) \ No newline at end of file diff --git a/sustApp/convert_payment_export_csv.py b/sustApp/convert_payment_export_csv.py index ef08611..bc386c5 100644 --- a/sustApp/convert_payment_export_csv.py +++ b/sustApp/convert_payment_export_csv.py @@ -4,8 +4,8 @@ from time import time from datetime import datetime from models import models from models.database import engine, SessionLocal -# import sustApp.models.models as models -# from sustApp.models.database import engine, SessionLocal +#import sustApp.models.models as models +#from sustApp.models.database import engine, SessionLocal import os, re from pytz import timezone @@ -13,11 +13,12 @@ import pandas as pd import glob #import csv -# def Load_Data(file_name): -# with open(source_csv, encoding='utf-8-sig') as f: -# for line in f: -# fields = line.strip().split(';') -# print(fields) +def extract_year_from_reference(reference): + """Extract year from payment reference (e.g., '2025-4732-163-1' -> '2025')""" + if reference: + year = reference.split('-')[0] + return year + return None def Load_Data(file_name): df = pd.read_csv(file_name, delimiter=';', encoding='utf-8-sig', dtype=str, header=None) @@ -91,11 +92,26 @@ if __name__ == "__main__": new_list = extract_values(i[1]) if len(new_list) > 0: - outData.append([i[0], new_list[0].get('Auftraggeber'), new_list[0].get('Zahlungsreferenz'), new_list[0].get('Verwendungszweck'), new_list[0].get('IBAN'), i[2], i[3].replace(',','.'), i[4], i[5]]) - + zahlungsreferenz = new_list[0].get('Zahlungsreferenz') + year = extract_year_from_reference(zahlungsreferenz) + + #outData.append([i[0], new_list[0].get('Auftraggeber'), new_list[0].get('Zahlungsreferenz'), new_list[0].get('Verwendungszweck'), new_list[0].get('IBAN'), i[2], i[3].replace(',','.'), i[4], i[5], new_list[0].get('Zahlungsreferenz')]) + outData.append([ + i[0], # valuta_date + new_list[0].get('Auftraggeber'), # client + zahlungsreferenz, # reference + new_list[0].get('Verwendungszweck'), # intended + new_list[0].get('IBAN'), # IBAN + i[2], # entry_date + i[3].replace(',','.'), # amount + i[4], # currency + i[5], # timestamp + year # year (extracted from reference) + ]) + # Replace None with empty string before saving outData = [['' if x is None else x for x in row] for row in outData] - savetxt(target_csv, outData, delimiter=";", encoding='utf-8', header='valuta_date;client;reference;intended;IBAN;entry_date;amount;currency;timestamp', fmt='%s') + savetxt(target_csv, outData, delimiter=";", encoding='utf-8', header='valuta_date;client;reference;intended;IBAN;entry_date;amount;currency;timestamp;year', fmt='%s') bak_dir = os.path.join(os.path.dirname(source_csv), "bak") os.makedirs(bak_dir, exist_ok=True) os.rename(source_csv, os.path.join(bak_dir, os.path.basename(source_csv))) diff --git a/sustApp/create_payment_slip.py b/sustApp/create_payment_slip.py index da72d60..dbeec2b 100644 --- a/sustApp/create_payment_slip.py +++ b/sustApp/create_payment_slip.py @@ -1,18 +1,28 @@ -import sustApp.models.models as models +import models.models as models +from models.database import engine, SessionLocal from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from segno import helpers +from reportlab.lib import colors +from reportlab.lib.pagesizes import letter +from reportlab.platypus import Table, TableStyle +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT + import datetime import locale - import os from config_loader import load_config +from salutation_helpers import get_gender_salutation, get_pronouns cfg = load_config() year = cfg['year'] +yearly_fee = cfg['yearly_fee'] +MM_TO_POINTS = 72 / 25.4 # conversion factor +offset_2mm = 2 * MM_TO_POINTS +offset_1mm = MM_TO_POINTS def print_multiple_lines(lines: int, text): cnt = 1 @@ -25,27 +35,31 @@ def create_payment_slip(member: models.Members = None, locale.setlocale(locale.LC_ALL, '') - salutation = "Deine" - salutation2 = "Dein" - salutation3 = "Dich" - salutation4 = "Solltest Du" - salutation5 = "sende" + # pronouns = get_pronouns(is_plural=False) + # salutation = pronouns['possessive'] + # salutation2 = pronouns['neutral'] + # salutation3 = pronouns['accusative'] + # salutation4 = pronouns['conditional'] + # salutation5 = pronouns['verb'] + subfolder = "" w, h = A4 + db = SessionLocal() + # create epc qr-code if not member is None: qrcode = helpers.make_epc_qr(name='Sportunion St. Thomas', iban='AT493443700000113407', - amount=15*member.member_count, + amount=yearly_fee*member.member_count, reference=f'{year}-4732-{member.id:03}-{member.member_count}') qrcode.save("epc_qr.png", scale=2) subfolder = "mail" if member.member_email else "" - fullfilepath = os.path.join(os.path.expanduser("~"),"sust","payment_slips",subfolder,f"{member.member_zip}_payment_slip_member{member.id}.pdf") + fullfilepath = os.path.join(os.path.expanduser("~"),"sust",f"payment_slips_{year}",subfolder,f"{member.member_zip}_payment_slip_member{member.id}.pdf") else: - fullfilepath = os.path.join(os.path.expanduser("~"),"sust","payment_slips",subfolder,f"4732_payment_slip_blanko.pdf") + fullfilepath = os.path.join(os.path.expanduser("~"),"sust",f"payment_slips_{year}",subfolder,f"4732_payment_slip_blanko.pdf") # Ensure the directory exists os.makedirs(os.path.dirname(fullfilepath), exist_ok=True) @@ -53,10 +67,12 @@ def create_payment_slip(member: models.Members = None, c = canvas.Canvas(fullfilepath, pagesize=A4) if not member is None: - c.drawImage(image="epc_qr.png", x=500, y=40) # epc qr + c.drawImage(image="epc_qr.png", x=500-offset_2mm, y=40+offset_2mm) # epc qr #c.drawImage(image="Sportunion quer 4c.png", x=(w-180), y=(h-100), width=150, height=50, mask='auto') image_path = os.path.join(os.path.dirname(__file__), "Sportunion quer 4c.png") c.drawImage(image=image_path, x=(w-180), y=(h-100), width=150, height=50, mask='auto') + c.drawImage(image=os.path.join(os.path.dirname(__file__), "qr-code_scannen_v3.png"), x=(w-160), y=(h-570), width=140, height=140, mask='auto') + c.setFont("Helvetica", 9) text = c.beginText(60, h - 150) @@ -68,24 +84,30 @@ def create_payment_slip(member: models.Members = None, text.textLine(f"{member.member_street} {member.member_streetnumber}") text.textLine(f"{member.member_zip} {member.member_city}") print_multiple_lines(5, text) + if member.member_count > 1: - salutation = "eure" - salutation2 = "euer" - salutation3 = "euch" - salutation4 = "Solltet ihr" - salutation5 = "sendet" - text.textLine(f"Liebe {member.member_firstname_spouse} und lieber {member.member_firstname}!") + pronouns = get_pronouns(is_plural=True) + salutation = pronouns['possessive'] + salutation2 = pronouns['neutral'] + salutation3 = pronouns['accusative'] + salutation4 = pronouns['conditional'] + salutation5 = pronouns['verb'] + + # Get spouse's salutation based on their gender + spouse_salutation = get_gender_salutation(str(member.spouse.member_gender), capitalize=False) + member_salutation = get_gender_salutation(str(member.member_gender), capitalize=False) + + text.textLine(f"{spouse_salutation.capitalize()} {member.spouse.member_firstname} und {member_salutation} {member.member_firstname}!") else: - salutation = "Deine" - salutation2 = "Dein" - salutation3 = "Dich" - salutation4 = "Solltest Du" - salutation5 = "sende" - if member.member_gender == "female": - salutation_gender = "liebe" - else: - salutation_gender = "lieber" - text.textLine(f"{salutation_gender.capitalize()} {member.member_firstname}!") + pronouns = get_pronouns(is_plural=False) + salutation = pronouns['possessive'] + salutation2 = pronouns['neutral'] + salutation3 = pronouns['accusative'] + salutation4 = pronouns['conditional'] + salutation5 = pronouns['verb'] + + member_salutation = get_gender_salutation(str(member.member_gender), capitalize=True) + text.textLine(f"{member_salutation} {member.member_firstname}!") else: print_multiple_lines(10, text) @@ -95,7 +117,7 @@ def create_payment_slip(member: models.Members = None, text.textLines(f"Vielen Dank für {salutation} Treue!\n\n{salutation2.capitalize()} Beitrag ist gut angelegt, denn der Sportverein St. Thomas bietet für jede(n) etwas:") print_multiple_lines(2, text) text.textLines("* Skigymnastik\n* Radfahren\n* Stockschiessen\n* Pilates\n* Tagesskifahrt\n* uvam") - print_multiple_lines(5, text) + print_multiple_lines(2, text) if not forMail: text.textLines(f"Wir möchten das Zustellen des Mitgliedsbeitrags-Zahlscheines zuküntig auf Mail umstellen, daher bitten wir {salutation3},\nuns eine Mail mit dem Betreff \"Zahlschein\" an unsere Mail-Adresse sv.stthomas@gmx.at zu senden!") else: @@ -104,23 +126,29 @@ def create_payment_slip(member: models.Members = None, text.textLines(f"{salutation2.capitalize()} Sportverein-Team!") c.drawText(text) - text = c.beginText(60, h - 500) + text = c.beginText(60, h - 520) c.setFont("Helvetica", 7) text.textLines(f"Auf Grund der DSGVO müssen wir darauf hinweisen, dass wir außer Name, Adresse, Mail-Adresse\nzum Zwecke der Einebung des Mitgliedsbeitrags, keine persönlichen Daten unserer Mitglieder führen!\nDiese Daten werden von uns nicht an Dritte weitergegeben!\nWeiters informieren wir darüber, dass Fotos, die bei Veranstaltungen oder Aktivitäten entstehen,\nauf unserer Homepage oder in Zeitungen veröffentlicht werden können.\n{salutation4} gegen die Speicherung {salutation}r Daten oder die Veröffentlichung diverser Foto sein, {salutation5} uns bitte eine Mail.") c.drawText(text) + if forMail: + text = c.beginText(60, h - 580) + c.setFont("Helvetica", 7) + text.textLines(f"IBAN: AT49 3443 7000 0011 3407\nBIC: RZOOAT2L437\nBank: Raiffeisenbank Prambachkirchen, Bankstelle Waizenkirchen") + c.drawText(text) + # payment slip details - text = c.beginText(11, 75) + text = c.beginText(11 - offset_2mm, 75 + offset_1mm) c.setFont("Helvetica", 8) if not member is None: - text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', 15 * member.member_count)}") + text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', yearly_fee * member.member_count)}") text.textLine(f"Mitgliedsnummer: 4732-{member.id:03}") else: - text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', 15)}") + text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', yearly_fee)}") text.textLine(f"Mitgliedsnummer: 4732-NEU") c.drawText(text) - text = c.beginText(188, 103) + text = c.beginText(188 - offset_2mm, 103 + offset_2mm) c.setFont("Helvetica", 8) if not member is None: text.textLine(f"Bei Online-Zahlung bitte die Zahlungsreferenz {year}-4732-{member.id:03}-{member.member_count} eintragen") @@ -132,12 +160,96 @@ def create_payment_slip(member: models.Members = None, c.drawText(text) c.setFont("Helvetica", 12) - c.drawString(77, 50, f"{year}") - c.drawString(300, 120, f"{year}") - + c.drawString(300 - offset_2mm, 120 + offset_1mm, f"{year}") # Zahlungsanweisung: Jahr + c.drawString(77 - offset_2mm, 50 + offset_1mm, f"{year}") # Auftragsbestätigung: Jahr c.setFont("Helvetica", 10) - c.drawString(188, h - 696, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsreferenz Zahlungsanweisung - c.drawString(11, 120, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsreferenz Auftragsbestätigung - c.drawString(130, h - 694, (locale.format_string('%.2f', 15 * member.member_count))) # Betrag Auftragsbestätigung - c.drawString(w - 30, 170, (locale.format_string('%.2f', 15 * member.member_count))) # Betrag Zahlungsanweisung + c.drawString(188 - offset_2mm, h - 696 + offset_1mm, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsanweisung: Zahlungsreferenz + c.drawString(w - 30 - offset_2mm, 170 + offset_2mm, (locale.format_string('%.2f', yearly_fee * member.member_count))) # Zahlungsanweisung: Betrag + c.drawString(11 - offset_2mm, 120 + offset_2mm, f'{year}-4732-{member.id:03}-{member.member_count}') # Auftragsbestätigung: Zahlungsreferenz + c.drawString(130 - offset_2mm, h - 694 + offset_1mm, (locale.format_string('%.2f', yearly_fee * member.member_count))) # Auftragsbestätigung: Betrag + + #TODO: config-switch + # print payment-history + payments = db.query(models.MembersPaidYears).filter( + models.MembersPaidYears.id == member.id).first() + if payments: + # View returns 'X' for paid, 'N' for not applicable, NULL for not paid + paid_2024 = payments.paid_2024 == 'X' + paid_2025 = payments.paid_2025 == 'X' + paid_2026 = payments.paid_2026 == 'X' + + # Determine display status + status_2024 = '✓' if paid_2024 else ('kein Mitglied' if payments.paid_2024 == 'N' else '✗') + status_2025 = '✓' if paid_2025 else ('kein Mitglied' if payments.paid_2025 == 'N' else '✗') + status_2026 = '✓' if paid_2026 else ('kein Mitglied' if payments.paid_2026 == 'N' else '✗') + else: + status_2024 = '✗' + status_2025 = '✗' + status_2026 = '✗' + + data = [ + [f'{salutation.capitalize()} Beitragshistorie', ''], + ['2024', '2025', '2026'], + [status_2024, status_2025, status_2026], + ] + + # 2. Create the Table object + table = Table(data, colWidths=[100, 100], rowHeights=[16, 13, 13]) + + # Apply styling - use string alignment constants + style = TableStyle([ + # Span first row across all columns + ('SPAN', (0, 0), (2, 0)), + + # Title row styling + ('ALIGN', (0, 0), (2, 0), 'CENTER'), # Changed from TA_CENTER to 'CENTER' + ('VALIGN', (0, 0), (2, 0), 'MIDDLE'), + ('FONTNAME', (0, 0), (2, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (2, 0), 11), + ('BACKGROUND', (0, 0), (2, 0), colors.HexColor("#168ef1")), + ('TEXTCOLOR', (0, 0), (2, 0), colors.whitesmoke), + ('BOTTOMPADDING', (0, 0), (2, 0), 8), + ('TOPPADDING', (0, 0), (2, 0), 8), + + # Header row styling (2024, 2025) + ('ALIGN', (0, 1), (2, 1), 'CENTER'), # Changed from TA_CENTER to 'CENTER' + ('VALIGN', (0, 1), (2, 1), 'MIDDLE'), + ('FONTNAME', (0, 1), (2, 1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 1), (2, 1), 9), + ('BACKGROUND', (0, 1), (2, 1), colors.HexColor('#e8e8e8')), + ('BOTTOMPADDING', (0, 1), (2, 1), 6), + ('TOPPADDING', (0, 1), (2, 1), 6), + + # Data row styling + ('ALIGN', (0, 2), (2, 2), 'CENTER'), # Changed from TA_CENTER to 'CENTER' + ('VALIGN', (0, 2), (2, 2), 'MIDDLE'), + ('FONTNAME', (0, 2), (2, 2), 'Helvetica'), + ('FONTSIZE', (0, 2), (2, 2), 10), + ('BACKGROUND', (0, 2), (2, 2), colors.white), + ('BOTTOMPADDING', (0, 2), (2, 2), 6), + ('TOPPADDING', (0, 2), (2, 2), 6), + + # Color cells based on payment status + # Green for ✓, Red for ✗, Grey for N (not applicable) + ('TEXTCOLOR', (0, 2), (0, 2), + colors.HexColor('#006400') if status_2024 == '✓' + else (colors.HexColor('#808080') if status_2024 == 'kein Mitglied' else colors.HexColor('#dc143c'))), + ('TEXTCOLOR', (1, 2), (1, 2), + colors.HexColor('#006400') if status_2025 == '✓' + else (colors.HexColor('#808080') if status_2025 == 'kein Mitglied' else colors.HexColor('#dc143c'))), + ('TEXTCOLOR', (2, 2), (2, 2), + colors.HexColor('#006400') if status_2026 == '✓' + else (colors.HexColor('#808080') if status_2026 == 'kein Mitglied' else colors.HexColor('#dc143c'))), + + # Grid styling + ('GRID', (0, 0), (2, -1), 0.5, colors.grey), + ]) + table.setStyle(style) + + # 4. Wrap and Draw on the Canvas + # You must call wrap() to calculate the size before drawing + width, height = table.wrapOn(c, 0, 0) + table.drawOn(c, 60, h - 500) # (canvas, x, y) coordinate from bottom-left + + db.close() c.save() \ No newline at end of file diff --git a/sustApp/create_payment_slip_platypus.py b/sustApp/create_payment_slip_platypus.py new file mode 100644 index 0000000..a550317 --- /dev/null +++ b/sustApp/create_payment_slip_platypus.py @@ -0,0 +1,202 @@ +import models.models as models +from reportlab.lib.pagesizes import A4 +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import mm +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from segno import helpers +from payment_report import get_payment_table_element +import datetime +import locale +import os +from config_loader import load_config +from salutation_helpers import get_gender_salutation, get_pronouns + +cfg = load_config() +year = cfg['year'] +yearly_fee = cfg['yearly_fee'] + +MM_TO_POINTS = 72 / 25.4 +offset_2mm = 2 * MM_TO_POINTS +offset_1mm = MM_TO_POINTS + +def create_payment_slip(member: models.Members = None, + forMail: bool = False): + + locale.setlocale(locale.LC_ALL, '') + + w, h = A4 + + # Create EPC QR code + if not member is None: + qrcode = helpers.make_epc_qr( + name='Sportunion St. Thomas', + iban='AT493443700000113407', + amount=yearly_fee * member.member_count, + reference=f'{year}-4732-{member.id:03}-{member.member_count}' + ) + qrcode.save("epc_qr.png", scale=2) + + subfolder = "mail" if member.member_email else "" + fullfilepath = os.path.join( + os.path.expanduser("~"), + "sust", + f"payment_slips_{year}", + subfolder, + f"{member.member_zip}_payment_slip_member{member.id}.pdf" + ) + else: + fullfilepath = os.path.join( + os.path.expanduser("~"), + "sust", + f"payment_slips_{year}", + "", + f"4732_payment_slip_blanko.pdf" + ) + + os.makedirs(os.path.dirname(fullfilepath), exist_ok=True) + + # Create PDF with Platypus + doc = SimpleDocTemplate(fullfilepath, pagesize=A4, topMargin=15*mm, bottomMargin=15*mm) + elements = [] + styles = getSampleStyleSheet() + + # Custom styles + header_style = ParagraphStyle( + 'CustomHeader', + parent=styles['Normal'], + fontSize=11, + leading=14 + ) + + body_style = ParagraphStyle( + 'CustomBody', + parent=styles['Normal'], + fontSize=9, + leading=12 + ) + + small_style = ParagraphStyle( + 'CustomSmall', + parent=styles['Normal'], + fontSize=7, + leading=9 + ) + + # Logo and header + image_path = os.path.join(os.path.dirname(__file__), "Sportunion quer 4c.png") + from reportlab.platypus import Image + logo = Image(image_path, width=150*mm/25.4, height=50*mm/25.4) + elements.append(logo) + elements.append(Spacer(1, 5*mm)) + + # Address and salutation + if not member is None: + if member.member_count > 1: + address_line1 = f"{member.member_firstname_spouse} u. {member.member_firstname} {member.member_surname}" + else: + address_line1 = f"{member.member_firstname} {member.member_surname} {member.member_suffix}" + + address_text = f""" + {address_line1}
+ {member.member_street} {member.member_streetnumber}
+ {member.member_zip} {member.member_city} + """ + elements.append(Paragraph(address_text, header_style)) + elements.append(Spacer(1, 5*mm)) + + # Salutation + if member.member_count > 1: + spouse_salutation = get_gender_salutation(str(member.spouse.member_gender), capitalize=False) + member_salutation = get_gender_salutation(str(member.member_gender), capitalize=False) + salutation_text = f"{spouse_salutation.capitalize()} {member.spouse.member_firstname} und {member_salutation} {member.member_firstname}!" + pronouns = get_pronouns(is_plural=True) + salutation = pronouns['possessive'] + salutation2 = pronouns['neutral'] + salutation3 = pronouns['accusative'] + salutation4 = pronouns['conditional'] + salutation5 = pronouns['verb'] + else: + member_salutation = get_gender_salutation(str(member.member_gender), capitalize=True) + salutation_text = f"{member_salutation} {member.member_firstname}!" + pronouns = get_pronouns(is_plural=False) + salutation = pronouns['possessive'] + salutation2 = pronouns['neutral'] + salutation3 = pronouns['accusative'] + salutation4 = pronouns['conditional'] + salutation5 = pronouns['verb'] + + elements.append(Paragraph(salutation_text, body_style)) + elements.append(Spacer(1, 3*mm)) + + # Main text + main_text = f""" + Vielen Dank für {salutation} Treue!

+ {salutation2.capitalize()} Beitrag ist gut angelegt, denn der Sportverein St. Thomas bietet für jede(n) etwas:
+
+ • Skigymnastik
+ • Radfahren
+ • Stockschiessen
+ • Pilates
+ • Tagesskifahrt
+ • uvam + """ + elements.append(Paragraph(main_text, body_style)) + elements.append(Spacer(1, 3*mm)) + + # Email request + if not forMail: + email_text = f""" + Wir möchten das Zustellen des Mitgliedsbeitrags-Zahlscheines zuküntig auf Mail umstellen, + daher bitten wir {salutation3}, uns eine Mail mit dem Betreff "Zahlschein" an unsere + Mail-Adresse sv.stthomas@gmx.at zu senden! + """ + elements.append(Paragraph(email_text, body_style)) + + elements.append(Spacer(1, 3*mm)) + + closing_text = f"{salutation2.capitalize()} Sportverein-Team!" + elements.append(Paragraph(closing_text, body_style)) + + # GDPR disclaimer + elements.append(Spacer(1, 5*mm)) + gdpr_text = f""" + Auf Grund der DSGVO müssen wir darauf hinweisen, dass wir außer Name, Adresse, Mail-Adresse + zum Zwecke der Einebung des Mitgliedsbeitrags, keine persönlichen Daten unserer Mitglieder führen! + Diese Daten werden von uns nicht an Dritte weitergegeben! + Weiters informieren wir darüber, dass Fotos, die bei Veranstaltungen oder Aktivitäten entstehen, + auf unserer Homepage oder in Zeitungen veröffentlicht werden können. + {salutation4} gegen die Speicherung {salutation}r Daten oder die Veröffentlichung diverser Foto sein, {salutation5} uns bitte eine Mail. + """ + elements.append(Paragraph(gdpr_text, small_style)) + + # Payment details table (right aligned) + if not member is None: + amount = locale.format_string('%.2f', yearly_fee * member.member_count) + member_number = f"4732-{member.id:03}" + payment_reference = f"{year}-4732-{member.id:03}-{member.member_count}" + + payment_details = f""" + Beitrag gesamt: {amount}
+ Mitgliedsnummer: {member_number}
+ Zahlungsreferenz: {payment_reference}
+ """ + + elements.append(Spacer(1, 5*mm)) + elements.append(Paragraph(payment_details, body_style)) + + # Page break before payment table + elements.append(PageBreak()) + + # Add payment summary table + # payment_table = get_payment_table_element() + # if payment_table: + # elements.append(Paragraph("Zahlungsübersicht", styles['Heading2'])) + # elements.append(Spacer(1, 5*mm)) + # elements.append(payment_table) + + # Build PDF + doc.build(elements) + + print(f"✅ Payment slip created: {fullfilepath}") \ No newline at end of file diff --git a/sustApp/cut_members_from_spouse..sql b/sustApp/cut_members_from_spouse..sql new file mode 100644 index 0000000..dacba19 --- /dev/null +++ b/sustApp/cut_members_from_spouse..sql @@ -0,0 +1,111 @@ +-- 2. Migrate spouse data to new member records +INSERT INTO sust.members ( + member_count, member_firstname, member_surname, member_suffix, + member_street, member_streetnumber, member_email, member_zip, + member_city, member_active, member_gender, member_died, member_printed, + member_coordinates, member_geo_latitude, member_geo_longitude, + member_membership_start, member_phone, current_state_code, + current_state_changed_at, spouse_id +) +SELECT + 1, member_firstname_spouse, member_surname, member_suffix, + member_street, member_streetnumber, + member_email || '_partner' as member_email, + member_zip, member_city, member_active, 'female', member_died, + member_printed, member_coordinates, member_geo_latitude, + member_geo_longitude, member_membership_start, + member_phone || '_partner' as member_phone, + current_state_code, current_state_changed_at, id +FROM sust.members +WHERE member_firstname_spouse IS NOT NULL AND member_firstname_spouse != '' and members.id = 93 +RETURNING id, spouse_id; + +-- 3. Copy section memberships to new spouse members +INSERT INTO sust.member_sections (member_id, section_id) +SELECT + new_spouse.id, ms.section_id +FROM sust.member_sections ms +JOIN sust.members orig ON ms.member_id = orig.id +JOIN sust.members new_spouse ON new_spouse.spouse_id = orig.id +WHERE new_spouse.spouse_id IS NOT NULL +ON CONFLICT (member_id, section_id) DO NOTHING; + +-- 5. Update primary members with spouse links +UPDATE sust.members m1 +SET spouse_id = m2.id +FROM sust.members m2 +WHERE m2.spouse_id = m1.id and m2.id = 93; + +-- Add ONLY spouses of members ALREADY in event_attends for this event +INSERT INTO sust.event_attends (event_id, member_id, notes) +SELECT 1, m.spouse_id, 'Spouse of event attendee - all 3 days (Sylvia)' +FROM sust.members m +JOIN sust.event_attends ea ON m.id = ea.member_id +WHERE m.spouse_id IS NOT NULL + AND ea.event_id = 1 + AND NOT EXISTS ( + SELECT 1 FROM sust.event_attends ea2 + WHERE ea2.event_id = 1 AND ea2.member_id = m.spouse_id + ); + +-- Check primary vs spouse attendees for event +SELECT + ea.notes, + COUNT(*) as count, + string_agg(m.member_firstname || ' ' || m.member_surname, ', ' ORDER BY m.member_surname) as names +FROM sust.event_attends ea +JOIN sust.members m ON ea.member_id = m.id +WHERE ea.event_id = 1 +GROUP BY ea.notes +ORDER BY ea.notes; + +-- Add birthday column to members table +ALTER TABLE sust.members +ADD COLUMN member_birthday date NULL; + +-- Add index for birthday queries +CREATE INDEX ix_members_member_birthday ON sust.members USING btree (member_birthday); + +-- Optional: Add check constraint for valid dates +ALTER TABLE sust.members +ADD CONSTRAINT members_birthday_check +CHECK (member_birthday IS NULL OR member_birthday < CURRENT_DATE); + +-- Much simpler: List households with attendee count + email +SELECT + MIN(m.member_firstname || ' ' || m.member_surname) AS household_representative, + MIN(m.member_surname) AS surname, + COUNT(DISTINCT ea.member_id) AS attendees, + STRING_AGG( + DISTINCT + CASE + WHEN m.member_birthday IS NOT NULL + THEN m.member_firstname || ' (' || TO_CHAR(m.member_birthday, 'DD.MM.YYYY') || ')' + ELSE m.member_firstname + END, + ', ' + ) AS attendees_birthdays, + MIN( + TRIM( + COALESCE(m.member_street, '') || ' ' || COALESCE(m.member_streetnumber, '') || + ', ' || COALESCE(m.member_zip::text, '') || ' ' || COALESCE(m.member_city, '') + ) + ) AS address, + MIN(m.member_email) AS email -- household email (one per household) +FROM sust.event_attends ea +JOIN sust.members m ON ea.member_id = m.id +WHERE ea.event_id = 1 +GROUP BY + LEAST( + m.id, + COALESCE( + (SELECT spouse_id FROM sust.members WHERE id = m.id), + m.id + ) + ), + m.member_surname +ORDER BY m.member_surname; + +update members set member_primary = false where id in (217,221); + +select * from payments where member_id = 119 diff --git a/sustApp/helper_scripts.sql b/sustApp/helper_scripts.sql index 51e4aeb..ccbcd5a 100644 --- a/sustApp/helper_scripts.sql +++ b/sustApp/helper_scripts.sql @@ -25,12 +25,12 @@ update members set member_city = 'Sankt Thomas' where member_city = 'St. Thomas' update members set member_city = trim(member_city) insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year) -values (now(), 204, 186, '2025-01-01') --, - (now(), 79, 186, '2024-01-01'), - (now(), 3, 186, '2024-01-01'), - (now(), 4, 186, '2024-01-01'), - (now(), 9, 186, '2024-01-01') - +values (now(), 126, 186, '2026-01-01'), + (now(), 176, 186, '2026-01-01'), + (now(), 4, 186, '2026-01-01'), + (now(), 128, 186, '2026-01-01'), + (now(), 18, 186, '2026-01-01') + insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year) values (now(), 8, 186, '2024-02-21') @@ -115,7 +115,7 @@ WHERE m.id IN ( EXCEPT SELECT mp.id FROM members_paid mp - WHERE mp.payment_year IN ('2024-01-01', '2025-01-01') + WHERE mp.payment_year IN ('2025-01-01') ) ORDER BY m.member_surname, m.member_firstname, d.delivery_date ASC; @@ -149,3 +149,24 @@ WHERE m.id NOT IN ( AND mp.id IS NOT NULL ) ORDER BY m.member_surname, m.member_firstname; + +-- number of active/inactive/deactivated members +SELECT SUM(m.member_count) +FROM members m +JOIN member_states s ON m.current_state_code = s.state_code +WHERE s.state_code = 'I'; + +SELECT * +FROM members m +JOIN member_states s ON m.current_state_code = s.state_code +WHERE s.state_code = 'I'; + +update members m +set member_count = 1 +--JOIN member_states s ON m.current_state_code = s.state_code +WHERE current_state_code = 'I' and m.member_count =0; + +create trigger update_members_updated_on_membership_end before +update + of member_membership_end on + sust.members for each row execute function update_active_on_members_death() diff --git a/sustApp/import_transactions.py b/sustApp/import_transactions.py index afb94ea..10116da 100644 --- a/sustApp/import_transactions.py +++ b/sustApp/import_transactions.py @@ -1,18 +1,25 @@ # Importing libraries -from numpy import genfromtxt +#from numpy import genfromtxt +import pandas as pd from time import time -from datetime import datetime +from datetime import date, datetime from models import models from models.database import engine, SessionLocal import os, glob from pytz import timezone -def Load_Data(file_name): +""" def Load_Data(file_name): #data = genfromtxt(file_name, delimiter=';', skip_header=1, converters={0: lambda s: str(s)}) #str2date = lambda x: datetime.strptime(x, '%d-%m-%y') data = genfromtxt(file_name, delimiter=';', dtype=None, skip_header=1, encoding='utf-8') # return data.tolist() + """ + +#use pandas instead of genfromtxt for better performance and handling of csv files +def Load_Data(file_name): + df = pd.read_csv(file_name, delimiter=',', encoding='utf-8') + return df.values.tolist() if __name__ == "__main__": t = time() @@ -25,24 +32,31 @@ if __name__ == "__main__": source_path = os.path.join(home_directory, "OneDrive", "Dokumente", "SUST", "import_csv", "*_out.csv") for source_csv in glob.glob(source_path): #source_csv = os.path.join(home_directory, "OneDrive", "Dokumente", "SUST", "UmsatzListe_AT493443700000113407_20250529_091945_mod_out.csv") - data = Load_Data(source_csv) - + datas = Load_Data(source_csv) tz = timezone('UTC') # Replace with your desired timezone - dt = datetime.strptime(data[8], '%d.%m.%Y %H:%M:%S:%f') # type: ignore - ts_timestamp = tz.localize(dt) + for data in datas: + dt = datetime.strptime(data[8], '%d.%m.%Y %H:%M:%S:%f') # type: ignore + ts_timestamp = tz.localize(dt) - record = models.Payments_CSV(**{ - 'pc_valuta_date' : datetime.strptime(data[0], '%d.%m.%Y').date(), - 'pc_client' : data[1], - 'pc_reference' : data[2], - 'pc_intended' : data[3], - 'pc_iban' : data[4], - 'pc_entry_date' : datetime.strptime(data[5], '%d.%m.%Y').date(), - 'pc_amount' : float(data[6]), - 'pc_currency' : data[7], - 'pc_timestamp' : ts_timestamp - }) - db.add(record) #Add all the records + # Parse year - data[9] is now the year (from convert_payment_export_csv.py) + # Convert year string to a valid date (January 1st of that year) + year_value = data[9] # This is '2025' from the CSV + pc_year = date(int(year_value), 1, 1) # Convert to date: 2025-01-01 + + #TODO: Check if amout is valid! + record = models.Payments_CSV(**{ + 'pc_valuta_date' : datetime.strptime(data[0], '%d.%m.%Y').date(), + 'pc_client' : data[1], + 'pc_reference' : data[2], + 'pc_intended' : data[3], + 'pc_iban' : data[4], + 'pc_entry_date' : datetime.strptime(data[5], '%d.%m.%Y').date(), + 'pc_amount' : float(data[6]), + 'pc_currency' : data[7], + 'pc_timestamp' : ts_timestamp, + 'pc_year' : pc_year + }) + db.add(record) #Add all the records try: db.commit() #Attempt to commit all the records diff --git a/sustApp/launch.json b/sustApp/launch.json new file mode 100644 index 0000000..062f841 --- /dev/null +++ b/sustApp/launch.json @@ -0,0 +1,79 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/react-frontend/src", + "sourceMaps": true + }, + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true, + "cwd": "${workspaceFolder}/sustApp" + }, + { + "name": "Python: print_payment_slips.py", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/sustApp/print_payment_slips.py", + "console": "integratedTerminal", + "justMyCode": true, + "cwd": "${workspaceFolder}/sustApp", + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Python: FastAPI (uvicorn)", + "type": "python", + "request": "launch", + "module": "uvicorn", + "args": [ + "main:app", + "--reload", + "--host", + "127.0.0.1", + "--port", + "8000" + ], + "console": "integratedTerminal", + "justMyCode": true, + "cwd": "${workspaceFolder}/sustApp", + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Python: convert_payment_export_csv.py", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/sustApp/convert_payment_export_csv.py", + "console": "integratedTerminal", + "justMyCode": true, + "cwd": "${workspaceFolder}/sustApp", + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Python: Debug with Arguments", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false, + "cwd": "${workspaceFolder}/sustApp", + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "args": [] + } + ] +} \ No newline at end of file diff --git a/sustApp/mail_checker.py b/sustApp/mail_checker.py index 6f13310..790ec3f 100644 --- a/sustApp/mail_checker.py +++ b/sustApp/mail_checker.py @@ -12,7 +12,7 @@ import json import time import logging from typing import Optional -from sqlalchemy import extract +from sqlalchemy import extract, or_ from models.database import engine, SessionLocal import models.models as models import re @@ -112,7 +112,7 @@ def save_to_postgres(first_name: str, sur_name: str, address: str): # find by name db = SessionLocal() member = db.query(models.Members).filter( - models.Members.member_email == None, + or_(models.Members.member_email.is_(None),models.Members.member_email == ''), models.Members.member_firstname == first_name, models.Members.member_surname == sur_name, ).first() @@ -164,7 +164,6 @@ Dein lokaler Sportverein KI-Assistent logging.info(f"Bestätigung gesendet an {sender_email}") #TODO: In DB markieren als bestätigt - #TODO: Re: Mail als gelesen markieren return True except Exception as e: @@ -176,7 +175,7 @@ def main(): while True: try: mail = connect_imap() - status, messages = mail.search(None, 'UNSEEN SUBJECT "Zahlschein"') # Nur neue mit Subject![web:11][web:20] + _, messages = mail.search(None, 'UNSEEN', 'SUBJECT', 'zahlschein') # Nur neue mit Subject![web:11][web:20] mail_ids = messages[0].split() for mail_id in mail_ids: # [-5:] letzte 5 neue @@ -215,6 +214,8 @@ def main(): mail.store(mail_id, '+FLAGS', '\\Seen') mail.select('INBOX', readonly=True) + # TODO: Move thread to SUST-Folder + # Send confirmation reply msg_id = msg['Message-ID'] subject = msg['Subject'] or '' diff --git a/sustApp/mail_payment_slips.py b/sustApp/mail_payment_slips.py index 40a697d..ae22191 100644 --- a/sustApp/mail_payment_slips.py +++ b/sustApp/mail_payment_slips.py @@ -1,6 +1,6 @@ from sqlalchemy import extract -from sustApp.models.database import engine, SessionLocal -import sustApp.models.models as models +from models.database import engine, SessionLocal +import models.models as models import logging, sys import smtplib from email.mime.multipart import MIMEMultipart @@ -11,10 +11,17 @@ import datetime from create_payment_slip import create_payment_slip import os from config_loader import load_config +import argparse + +from salutation_helpers import get_gender_salutation, get_pronouns cfg = load_config() year = cfg['year'] +# Parse CLI arguments +parser = argparse.ArgumentParser(description='Send payment slip emails to members') +parser.add_argument('--resend', action='store_true', help='Send email again even if already mailed') +args = parser.parse_args() logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) @@ -28,14 +35,14 @@ def payment_slip_paid(member: models.Members, year: int): .count() -def send_mail(member: models.Members): +def send_mail(member: models.Members, force_resend: bool = False): paid = payment_slip_paid(member, year) if paid: logging.debug(f'Member: {member.member_surname} already paid!') return delivered = payment_slip_delivered(member, year) - if delivered: + if delivered and not force_resend: logging.debug(f'Member: {member.member_surname} already mailed or delivered!') return @@ -43,12 +50,29 @@ def send_mail(member: models.Members): smtp_port = 587 smtp_username = 'zisco@gmx.at' #TODO Secure - smtp_password = '57spunk07' + smtp_password = '[Jy010]Q&7te2qR&2x0d' from_email = 'sv.stthomas@gmx.at' to_email = member.member_email subject = f'Mitgliedsbeitrag {year}' - body = f'Liebes Mitglied,\n\nDein Zahlschein für {year} liegt bei.\nDu kannst gerne mittels beiliegendem, aufgedrucktem QR-Code überweisen.\nDanke sagt Dein Team vom Sportverein!' + + is_plural = bool(member.member_count > 1) + pronouns = get_pronouns(is_plural=is_plural) + salutation = pronouns['possessive'] + salutation2 = pronouns['neutral'] + salutation3 = pronouns['accusative'] + salutation4 = pronouns['conditional'] + salutation5 = pronouns['verb'] + + # Get spouse's salutation based on their gender + if is_plural and member.spouse: + spouse_salutation = get_gender_salutation(str(member.spouse.member_gender), capitalize=False) + member_salutation = get_gender_salutation(str(member.member_gender), capitalize=False) + combined_salutation = f"{spouse_salutation.capitalize()} {member.spouse.member_firstname} und {member_salutation} {member.member_firstname}" + else: + combined_salutation = f"{get_gender_salutation(str(member.member_gender), capitalize=True)} {member.member_firstname}" + + body = f"{combined_salutation},\n\n{salutation2} Zahlschein für {year} liegt bei.\n\nBitte mittels auf beiliegendem Zahlschein aufgedrucktem QR-Code überweisen.\nDanke sagt Dein Team vom Sportverein!" msg = MIMEMultipart() msg['From'] = from_email @@ -56,7 +80,7 @@ def send_mail(member: models.Members): msg['Subject'] = subject msg.attach(MIMEText(body)) - path_to_file = os.path.join(os.path.expanduser("~"),"sust","payment_slips","mail",f"{member.member_zip}_payment_slip_member{member.id}.pdf") + path_to_file = os.path.join(os.path.expanduser("~"),"sust",f"payment_slips_{year}","mail",f"{member.member_zip}_payment_slip_member{member.id}.pdf") if not exists(path_to_file): create_payment_slip(member=member, forMail=True) @@ -69,7 +93,10 @@ def send_mail(member: models.Members): with smtplib.SMTP(smtp_server, smtp_port) as smtp: smtp.starttls() smtp.login(smtp_username, smtp_password) - smtp.send_message(msg) + # smtp.send_message(msg) + # Send to recipient AND BCC to sender (so it appears in sent folder) + recipients = [to_email, from_email] # Add from_email as BCC + smtp.sendmail(from_email, recipients, msg.as_string()) newDelivery = models.Deliveries(delivery_date = datetime.datetime.now(), member_id = member.id, @@ -85,15 +112,16 @@ db = SessionLocal() models.Base.metadata.create_all(bind=engine) members = db.query(models.Members).filter( models.Members.member_active == True, - models.Members.member_email != None + models.Members.member_email != None, + models.Members.member_primary == True ).all() #members = db.query(models.Members).filter(models.Members.member_email != None, ~models.Members.id.in_([183,51,200,49])).all() #members = db.query(models.Members).filter(models.Members.member_active == True).all() #members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all() -#members = db.query(models.Members).filter(models.Members.id.in_([8])).all() +members = db.query(models.Members).filter(models.Members.id.in_([77])).all() for member in members: if member.member_email: - send_mail(member=member) + send_mail(member=member, force_resend=args.resend) else: logging.debug(f'Member {member.member_surname} has no email associated!') \ No newline at end of file diff --git a/sustApp/main.py b/sustApp/main.py index 8ecb9c2..2ca45b3 100644 --- a/sustApp/main.py +++ b/sustApp/main.py @@ -1,12 +1,16 @@ from fastapi import FastAPI, HTTPException, Depends, WebSocket from fastapi.security import OAuth2PasswordBearer from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from typing import List, Annotated, Optional +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing import List, Annotated, Optional, Union from sustApp.models.models import Base, Members, Payments from sustApp.models.database import engine, SessionLocal from sqlalchemy.orm import Session +from sqlalchemy import func from datetime import datetime +from fastapi.encoders import jsonable_encoder +# from create_payment_report import create_payment_report + import asyncio import logging @@ -31,6 +35,7 @@ origins = [ "http://localhost:8080", "http://localhost:3000", "http://localhost:5173", + "http://localhost:5174", ] app.add_middleware( @@ -91,10 +96,12 @@ class User(BaseModel): class DeliveryBase(BaseModel): delivery_date: datetime - delivered_by: int + delivered_by_id: int | None = None + class PaymentBase(BaseModel): - payment_date: datetime + payment_date: datetime | None = None + payment_year: datetime | None = None class MemberBase(BaseModel): member_count: int @@ -105,19 +112,31 @@ class MemberBase(BaseModel): member_street: str member_streetnumber: str | None = None member_email: str | None = None - member_zip: int + member_zip: int | None = Field(default=None, description="Member ZIP code") + #member_zip: Optional[int] = Field(default=None, description="Member ZIP code") + member_printed: bool | None = None member_city: str - member_active: bool + member_active: bool | None = None member_gender: str | None = None member_connected: int | None = None - member_printed: bool #member_coordinates: #payments: Optional[List[PaymentBase]] - member_deliveries: Optional[List[DeliveryBase]] - #deliveries: Mapped[List["DeliveryBase"]] = relationship() + member_deliveries: Optional[List[DeliveryBase]] = [] + member_payments: Optional[List[PaymentBase]] = [] current_state_code: str | None = None current_state_name: str | None = None + @model_validator(mode='before') + @classmethod + def handle_empty_strings(cls, data): + """Convert empty strings to None for int fields""" + if isinstance(data, dict): + if data.get('member_zip') == '': + data['member_zip'] = None + return data + + model_config = ConfigDict(from_attributes=True) # Enables SQLAlchemy object conversion + def get_db(): db = SessionLocal() try: @@ -128,17 +147,84 @@ def get_db(): db_dependency = Annotated[Session, Depends(get_db)] @app.get("/members/", response_model=List[MemberBase]) +async def read_members(db: db_dependency, skip: int = 0, limit: int = 100): + members = db.query(Members).offset(skip).limit(limit).all() + if not members: + raise HTTPException(status_code=404, detail='No members found') + + return [ + { + "id": m.id, + "member_firstname": m.member_firstname, + "member_surname": m.member_surname, + "member_count": m.member_count, + "member_street": m.member_street, + "member_zip": m.member_zip, + "member_city": m.member_city, + "member_active": m.member_active, + "member_printed": m.member_printed, + "member_deliveries": [ + { + "id": d.id, + "delivery_date": d.delivery_date, # Correct field + "delivered_by_id": d.delivered_by_id, + # Add any other required delivery fields here + } + for d in m.member_deliveries + ] if m.member_deliveries else [], + "member_payments": [ + { + "id": p.id, + "payment_date": p.payment_date, + "payment_year": p.payment_year, + # Add any other required delivery fields here + } + for p in m.member_payments + ] if m.member_payments else [] + } + for m in members + ] + +""" @app.get("/members/", response_model=List[MemberBase]) +async def read_members(db: db_dependency, skip: int = 0, limit: int = 100): + members = db.query(Members).offset(skip).limit(limit).all() + if not members: + raise HTTPException(status_code=404, detail='No members found') # Use 404 for not found + + # DEBUG: Print first member attributes + if members: + print("First member:", vars(members[0])) + print("First member type:", type(members[0])) + + # TEMP: Return dicts to bypass Pydantic entirely + #return [{"id": m.id, "name": getattr(m, 'name', None)} for m in members] # + + # Safe conversion - handles any SQLAlchemy objects + return jsonable_encoder(members) + +# return members # Now works with ORM mode enabled """ + +""" @app.get("/members/", response_model=List[MemberBase]) async def read_members(db: db_dependency, skip: int = 0, limit: int = 100 ): members = db.query(Members).offset(skip).limit(limit).all() if not members: raise HTTPException(status_code=400, detail='No members') - return members + return members """ @app.get("/activememberscount/", response_model=int) async def read_activememberscount(db: db_dependency): - count = db.query(Members).filter(Members.member_active == True).count() - logger.info("This is a test log message from backend!") - return count + count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'A', Members.member_primary == True).scalar() + return count or 0 + +@app.get("/inactivememberscount/", response_model=int) +async def read_inactivememberscount(db: db_dependency): + count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'I', Members.member_primary == True).scalar() + return count or 0 + +@app.get("/deactivatedmemberscount/", response_model=int) +async def read_deactivatedmemberscount(db: db_dependency): + count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'D', Members.member_primary == True).scalar() + return count or 0 @app.get("/member/{member_id}") async def read_member(member_id: int, db: db_dependency): #, token: Annotated[str, Depends(oauth2_scheme)]): @@ -151,9 +237,16 @@ async def read_member(member_id: int, db: db_dependency): #, token: Annotated[st return result +@app.get("/payments/") +async def read_all_payments(db: db_dependency): + result = db.query(Payments).all() + if not result: + raise HTTPException(status_code=400, detail=f'No payments found') + return result + @app.get("/payments/{member_id}") async def read_payments(member_id: int, db: db_dependency): - result = db.query(Members).filter(Members.id == member_id).all() + result = db.query(Payments).filter(Payments.member_id == member_id).all() if not result: raise HTTPException(status_code=400, detail=f'No payments found for member {member_id}') return result @@ -191,4 +284,12 @@ async def create_members(members: List[MemberBase], db: db_dependency): db_payment = Payments(payment_date=payment.payment_date, member_id=db_member.id) db.add(db_payment) - db.commit() \ No newline at end of file + db.commit() + +# @app.get("/api/reports/payments") +# async def get_payment_report(): +# try: +# report_path = create_payment_report() +# return {"message": "Report generated", "path": report_path} +# except Exception as e: +# raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/sustApp/models/database.py b/sustApp/models/database.py index 86b8392..45149f5 100644 --- a/sustApp/models/database.py +++ b/sustApp/models/database.py @@ -2,7 +2,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base -URL_DATABASE = 'postgresql://sust:sust@192.168.1.202:5432/sust' +URL_DATABASE = 'postgresql://sust:sust@postgresql.home:5432/sust' engine = create_engine(URL_DATABASE) diff --git a/sustApp/models/models.py b/sustApp/models/models.py index 10e2a42..dfc282f 100644 --- a/sustApp/models/models.py +++ b/sustApp/models/models.py @@ -3,13 +3,14 @@ from sqlalchemy.types import TypeDecorator, VARCHAR, Integer, CHAR from sqlalchemy_utils import EmailType from sqlalchemy.orm import relationship #from models.database import Base -from sustApp.models.database import Base +from .database import Base import datetime class TrimChar(TypeDecorator): impl = CHAR cache_key = False + cache_ok = True def process_result_value(self, value, dialect): if value: @@ -59,20 +60,27 @@ class Members(Base): member_geo_longitude = Column(Float, nullable=True) member_deliveries = relationship('Deliveries', backref='members') member_payments = relationship('Payments', backref='members') - #member_deliveries: Mapped[List["Deliveries"]] = relationship() - #current_state_code = Column(String, index=True) - #current_state_name = relationship('MemberStates', backref='state_code') - current_state_code = Column(TrimChar, index=True) # No foreign key - current_state_changed_at = Column(DateTime) - - # Use primaryjoin to specify the join condition - current_state = relationship( - 'MemberStates', - primaryjoin='Members.current_state_code == MemberStates.state_code', - foreign_keys=[current_state_code] + spouse_id = Column(Integer, ForeignKey('members.id'), nullable=True) + spouse = relationship( + "Members", + remote_side=[id], + foreign_keys=[spouse_id], + backref="spouse_of", + single_parent=True ) + current_state_code = Column(TrimChar, index=True) + # current_state = relationship( + # 'MemberStates', + # foreign_keys=[current_state_code], + # # primaryjoin="foreign(Members.current_state_code) == remote(MemberStates.state_code)", + # viewonly=True + # ) + + current_state_changed_at = Column(DateTime) + member_primary = Column(Boolean, default=True) + def __repr__(self): return f'' @@ -118,6 +126,7 @@ class Payments_CSV(Base): pc_currency = Column(String) pc_timestamp = Column(DateTime) pc_processed = Column(Boolean, default=False) + pc_year = Column(DateTime) class MemberStates(Base): __tablename__ = 'member_states' @@ -126,6 +135,7 @@ class MemberStates(Base): state_name = Column(String(20), nullable=False) description = Column(String) created_at = Column(DateTime) + # members = relationship("Members", back_populates="current_state") class MemberStateLog(Base): __tablename__ = 'member_state_log' @@ -139,4 +149,31 @@ class MemberStateLog(Base): # Relationships member = relationship('Members', backref='state_history') - state = relationship('MemberStates') \ No newline at end of file + state = relationship('MemberStates') + +class MembersPaidYears(Base): + """View for members and their payment years""" + __tablename__ = 'members_paid_years' + __table_args__ = {'extend_existing': True} + + id = Column(Integer, primary_key=True) + member_firstname = Column(String) + member_surname = Column(String) + member_suffix = Column(String) + member_email = Column(String) + member_active = Column(Boolean) + # payment_year = Column(DateTime) + # payment_amount = Column(Float) + # payment_date = Column(DateTime) + member_active = Column(Boolean, default=True) + current_state_code = Column(TrimChar, index=True) + # current_state = relationship( + # 'MemberStates', # Ensure this matches the CLASS name exactly + # primaryjoin='Members.current_state_code == MemberStates.state_code', + # foreign_keys='[Members.current_state_code]', # Use a string here to avoid registry issues + # viewonly=True # Recommended if there is no actual ForeignKey constraint in DB + # ) + member_membership_start = Column(DateTime) + paid_2024 = Column(Boolean, default=False) + paid_2025 = Column(Boolean, default=False) + paid_2026 = Column(Boolean, default=False) \ No newline at end of file diff --git a/sustApp/payment_report.py b/sustApp/payment_report.py new file mode 100644 index 0000000..7906d30 --- /dev/null +++ b/sustApp/payment_report.py @@ -0,0 +1,209 @@ +import models.models as models +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import mm +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from datetime import datetime +import locale +import os +from config_loader import load_config +from models.database import engine, SessionLocal +from sqlalchemy import func + +cfg = load_config() +year = cfg['year'] + +locale.setlocale(locale.LC_ALL, '') + +def get_payment_table_data(): + """Get payment data organized by member with summary columns""" + + db = SessionLocal() + + try: + # Query members with their payment information + members = db.query(models.Members).filter( + models.Members.member_active == True + ).order_by( + models.Members.member_surname, + models.Members.member_firstname + ).all() + + table_data = [] + + # Header row + table_data.append([ + 'Paid 2024', + 'Paid 2025' + ]) + + # Data rows - one per member + for member in members: + payments = db.query(models.MembersPaidYears).filter( + models.MembersPaidYears.id == member.id + ) + + if payments: + table_data.append([ + '✓' if payments.paid_2024 else '✗', + '✓' if payments.paid_2025 else '✗' + ]) + + return table_data + + finally: + db.close() + +def create_payment_summary_table(): + """Create a single table with payment summary for all members""" + + try: + # Get table data + table_data = get_payment_table_data() + + if len(table_data) <= 1: + print("⚠️ No member data found") + return None + + # Create filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_path = os.path.join( + os.path.expanduser("~"), + "sust", + f"payment_reports_{year}", + f"payment_summary_{timestamp}.pdf" + ) + + # Ensure directory exists + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Create PDF document + doc = SimpleDocTemplate( + output_path, + pagesize=landscape(A4), + topMargin=15*mm, + bottomMargin=15*mm, + leftMargin=10*mm, + rightMargin=10*mm + ) + elements = [] + + # Title + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontSize=16, + textColor=colors.HexColor('#1f4788'), + spaceAfter=15, + alignment=TA_CENTER + ) + title = Paragraph(f"Zahlungsübersicht Mitgliederbeiträge {year}", title_style) + elements.append(title) + + # Create table + table = Table(table_data, repeatRows=1) + + # Style table + table.setStyle(TableStyle([ + # Header styling + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4788')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, 0), TA_CENTER), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('TOPPADDING', (0, 0), (-1, 0), 8), + + # Body styling + ('ALIGN', (0, 1), (0, -1), TA_CENTER), # Member ID centered + ('ALIGN', (1, 1), (1, -1), TA_LEFT), # Name left + ('ALIGN', (2, 1), (2, -1), TA_LEFT), # Email left + ('ALIGN', (3, 1), (3, -1), TA_CENTER), # Member Count centered + ('ALIGN', (4, 1), (6, -1), TA_CENTER), # Payment columns centered + + ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f9f9f9')]), + ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 5), + ('RIGHTPADDING', (0, 0), (-1, -1), 5), + ('TOPPADDING', (0, 1), (-1, -1), 4), + ('BOTTOMPADDING', (0, 1), (-1, -1), 4), + + # Highlight payment status columns + ('BACKGROUND', (4, 0), (5, -1), colors.HexColor('#f0f8ff')), + ])) + + elements.append(table) + + # Add footer with summary + elements.append(Spacer(1, 1.5*mm)) + summary_style = ParagraphStyle( + 'Summary', + parent=styles['Normal'], + fontSize=8, + textColor=colors.grey + ) + summary = Paragraph( + f"Total Members: {len(table_data) - 1} | Generated: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}", + summary_style + ) + elements.append(summary) + + # Build PDF + doc.build(elements) + + print(f"✅ Payment summary created: {output_path}") + print(f"📊 Total members: {len(table_data) - 1}") + return output_path + + except Exception as e: + print(f"❌ Error creating payment summary: {str(e)}") + import traceback + traceback.print_exc() + return None + +# Function to integrate into existing PDF +def get_payment_table_element(): + """Return a ReportLab Table element for embedding in existing PDFs""" + + table_data = get_payment_table_data() + + if len(table_data) <= 1: + return None + + table = Table(table_data, repeatRows=1) + + table.setStyle(TableStyle([ + # Header styling + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4788')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, 0), TA_CENTER), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('BOTTOMPADDING', (0, 0), (-1, 0), 8), + + # Body styling + ('ALIGN', (0, 1), (0, -1), TA_CENTER), + ('ALIGN', (1, 1), (1, -1), TA_LEFT), + ('ALIGN', (2, 1), (2, -1), TA_LEFT), + ('ALIGN', (3, 1), (-1, -1), TA_CENTER), + ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 7), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f9f9f9')]), + ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ('TOPPADDING', (0, 1), (-1, -1), 2), + ('BOTTOMPADDING', (0, 1), (-1, -1), 2), + ])) + + return table + +if __name__ == '__main__': + create_payment_summary_table() \ No newline at end of file diff --git a/sustApp/print_payment_slips.py b/sustApp/print_payment_slips.py index bd5140b..8a41624 100644 --- a/sustApp/print_payment_slips.py +++ b/sustApp/print_payment_slips.py @@ -1,9 +1,9 @@ from reportlab.lib.pagesizes import A4 from create_payment_slip import create_payment_slip -from sustApp.models.database import engine, SessionLocal +from models.database import engine, SessionLocal from pydantic import BaseModel from sqlalchemy.orm import Session -import sustApp.models.models as models +import models.models as models from reportlab.lib.utils import ImageReader import logging, sys from sqlalchemy import extract @@ -17,15 +17,17 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) db = SessionLocal() models.Base.metadata.create_all(bind=engine) -#members = db.query(models.Members).filter(models.Members.member_email == None).all() members = db.query(models.Members).filter( - models.Members.member_active == True, + models.Members.current_state_code == 'A', models.Members.member_email == None, + models.Members.member_primary == True ).all() -#members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all() -#members = db.query(models.Members).filter(models.Members.id.in_([204,205,206])).all() +# members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all() +# members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all() +# members = db.query(models.Members).filter(models.Members.id.in_([15,207,166,195,52,179])).all() +members = db.query(models.Members).filter(models.Members.id.in_([108])).all() -for member in members: +for member in members: # TOOD: Add column payment_date to Payments # create only, if not already delivered/paid paid = db.query(models.Payments.payment_year)\ @@ -37,7 +39,7 @@ for member in members: .filter(models.Deliveries.member_id == member.id, extract('year', models.Deliveries.delivery_date) == year)\ .count() if not delivered: - #create_payment_slip(member=member) + create_payment_slip(member=member) logging.debug(f'Member: {member.member_surname}') #logging.info('We processed %d records', len(processed_records)) else: diff --git a/sustApp/qr-code_scannen.png b/sustApp/qr-code_scannen.png new file mode 100644 index 0000000..c9e7405 Binary files /dev/null and b/sustApp/qr-code_scannen.png differ diff --git a/sustApp/qr-code_scannen_v2.png b/sustApp/qr-code_scannen_v2.png new file mode 100644 index 0000000..0c2113e Binary files /dev/null and b/sustApp/qr-code_scannen_v2.png differ diff --git a/sustApp/qr-code_scannen_v3.png b/sustApp/qr-code_scannen_v3.png new file mode 100644 index 0000000..4c3ccd6 Binary files /dev/null and b/sustApp/qr-code_scannen_v3.png differ diff --git a/sustApp/react-frontend/src/FastAPIFrontend.tsx b/sustApp/react-frontend/src/FastAPIFrontend.tsx index 58aefe6..a9ad306 100644 --- a/sustApp/react-frontend/src/FastAPIFrontend.tsx +++ b/sustApp/react-frontend/src/FastAPIFrontend.tsx @@ -1,748 +1,392 @@ import React, { useState, useEffect } from 'react'; -import { - Server, - Database, - Users, - Activity, - RefreshCw, - Plus, - Edit3, - Trash2, - Eye, - AlertCircle, - CheckCircle, - XCircle -} from 'lucide-react'; +import { BarChart3, Users, CreditCard, Settings, LogOut, AlertCircle, CheckCircle } from 'lucide-react'; +import 'bootstrap/dist/css/bootstrap.min.css'; -import { useEffect } from 'react'; - -function App() { - useEffect(() => { - const ws = new WebSocket('ws://localhost:8000/ws/logs'); - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === 'log') { - console.log(`[Backend Log]: ${data.message}`); - } - }; - - ws.onclose = () => { - console.log('WebSocket connection closed'); - }; - - return () => { - ws.close(); - }; - }, []); - - return
Your React App
; +interface Member { + id: number; + member_firstname: string; + member_surname: string; + member_email: string; + member_active: boolean; + member_count: number; } -const Dashboard = () => { - const [apiUrl, setApiUrl] = useState('http://127.0.0.1:8000'); - const [connectionStatus, setConnectionStatus] = useState('disconnected'); - const [isLoading, setIsLoading] = useState(false); - const [activeTab, setActiveTab] = useState('dashboard'); - const [members, setMembers] = useState([]); - const [totalMembers, setTotalMembers] = useState(0); - const [loadingData, setLoadingData] = useState(false); +interface Payment { + id: number; + member_id: number; + payment_date: string; + payment_amount: number; + payment_year: string; +} - const testConnection = async () => { - setIsLoading(true); - try { - console.log('Testing connection to:', apiUrl); - // Test with a simple endpoint first - try /member/1 since /member/100 works - const response = await fetch(`${apiUrl}/member/100`); - - if (response.ok) { - setConnectionStatus('connected'); - console.log('Connection successful!'); - // Load members data when connection is successful - await loadMembersCount(); - } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - } catch (error) { - console.error('Connection error:', error); - setConnectionStatus('error'); - } - setIsLoading(false); - }; +interface DashboardStats { + total_members: number; + active_members: number; + total_payments: number; + total_amount: number; +} - const loadMembersCount = async () => { - if (connectionStatus !== 'connected' && !isLoading) return; - - setLoadingData(true); - console.log('Attempting to load active members count...'); - - // const fetchActiveMembersCount = async () => { - try { - const response = await fetch(`${apiUrl}/activememberscount`); - if (response.ok) { - const count = await response.json(); // Single number return - setTotalMembers(count); - console.log('Total active members count:', count); - } else { - console.log('Failed to fetch active members count:', response.statusText); - setTotalMembers(0); - } - } catch (error) { - console.log('Error fetching active members count:', error); - setMembers([]); - setTotalMembers(0); - } - //} - - setLoadingData(false); - }; +const FastAPIFrontend: React.FC = () => { + const [activeTab, setActiveTab] = useState<'dashboard' | 'members' | 'payments' | 'settings'>('dashboard'); + const [members, setMembers] = useState([]); + const [payments, setPayments] = useState([]); + const [stats, setStats] = useState({ + total_members: 0, + active_members: 0, + total_payments: 0, + total_amount: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - const loadMembers = async () => { - if (connectionStatus !== 'connected' && !isLoading) return; - - setLoadingData(true); - try { - // Since /members/ gives 500 error, let's try different approaches - console.log('Attempting to load active members count...'); - - // Option 1: Try individual member requests (if you know IDs) - // For now, let's try to get a few members by ID - const memberIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; // Adjust these IDs as needed - const memberPromises = memberIds.map(async (id) => { - try { - const response = await fetch(`${apiUrl}/member/${id}`); - if (response.ok) { - return await response.json(); - } - return null; - } catch (error) { - console.log(`Member ${id} not found or error:`, error); - return null; - } - }); - - const results = await Promise.all(memberPromises); - const validMembers = results.filter(member => member !== null); - - console.log('Found members:', validMembers); - setMembers(validMembers); - setTotalMembers(validMembers.length); - - if (validMembers.length === 0) { - console.warn('No members found with IDs 1-10. You may need to adjust the ID range or fix the /members/ endpoint.'); - } - - } catch (error) { - console.error('Error loading members:', error); - setMembers([]); - setTotalMembers(0); - } - setLoadingData(false); - }; + const apiUrl = 'http://localhost:8000'; - // Load members when component mounts if already connected useEffect(() => { - if (connectionStatus === 'connected') { - loadMembersCount(); - } - }, [connectionStatus]); + fetchDashboardData(); + }, []); - const getStatusColor = () => { - switch (connectionStatus) { - case 'connected': return '#10b981'; - case 'error': return '#ef4444'; - default: return '#f59e0b'; + const fetchDashboardData = async () => { + setLoading(true); + setError(null); + try { + console.log('Fetching data from:', apiUrl); + + const [membersRes, paymentsRes] = await Promise.all([ + fetch(`${apiUrl}/members`), + fetch(`${apiUrl}/payments`), + ]); + + console.log('Members response status:', membersRes.status); + console.log('Payments response status:', paymentsRes.status); + + if (membersRes.ok && paymentsRes.ok) { + const membersData = await membersRes.json(); + const paymentsData = await paymentsRes.json(); + + console.log('Members data:', membersData); + console.log('Payments data:', paymentsData); + + setMembers(membersData); + setPayments(paymentsData); + + // Calculate stats + const activeCount = membersData.filter((m: Member) => m.member_active).length; + const totalAmount = paymentsData.reduce((sum: number, p: Payment) => sum + p.payment_amount, 0); + + setStats({ + total_members: membersData.length, + active_members: activeCount, + total_payments: paymentsData.length, + total_amount: totalAmount, + }); + } else { + console.error('API error - Members OK:', membersRes.ok, 'Payments OK:', paymentsRes.ok); + setError('Failed to fetch data from server'); + } + } catch (err) { + console.error('Fetch error:', err); + setError('Error connecting to server: ' + (err instanceof Error ? err.message : 'Unknown error')); + } finally { + setLoading(false); } }; - const getStatusIcon = () => { - switch (connectionStatus) { - case 'connected': return ; - case 'error': return ; - default: return ; - } - }; + const DashboardView = () => ( +
+
+ } + bgClass="bg-primary" + /> +
+
+ } + bgClass="bg-success" + /> +
+
+ } + bgClass="bg-info" + /> +
+
+ } + bgClass="bg-warning" + /> +
+
+ ); - const getStatusText = () => { - switch (connectionStatus) { - case 'connected': return 'Connected'; - case 'error': return 'Connection Error'; - default: return 'Disconnected'; - } - }; + const MembersView = () => ( +
+
+
Members List
+
+
+
+ + + + + + + + + + + + {members.length > 0 ? ( + members.map((member) => ( + + + + + + + + )) + ) : ( + + + + )} + +
First NameLast NameEmailStatusMember Count
{member.member_firstname}{member.member_surname}{member.member_email} + + {member.member_active ? 'Active' : 'Inactive'} + + {member.member_count}
+ No members found +
+
+
+
+ ); - const getFullName = (member) => { - const firstName = member.member_firstname || ''; - const lastName = member.member_surname || ''; - const memberSuffix = member.member_suffix || ''; - const fullName = `${firstName} ${lastName} ${memberSuffix}`.trim(); - return fullName || 'N/A'; - }; + const PaymentsView = () => ( +
+
+
Payments List
+
+
+
+ + + + + + + + + + + {payments.length > 0 ? ( + payments.map((payment) => ( + + + + + + + )) + ) : ( + + + + )} + +
Member IDPayment DateAmountYear
+ {payment.member_id} + {new Date(payment.payment_date).toLocaleDateString()} + €{payment.payment_amount.toFixed(2)} + {payment.payment_year}
+ No payments found +
+
+
+
+ ); - const MEMBER_STATE_COLORS = { - 'A': '#10B981', // Green - Active - 'I': '#F59E0B', // Amber/Orange - Inactive - 'D': '#EF4444' // Red - Deactivated - }; - - const getMemberStateColor = (stateCode) => { - return MEMBER_STATE_COLORS[stateCode] || '#6B7280'; // Gray as fallback - }; - - const styles = { - container: { - minHeight: '100vh', - backgroundColor: '#f9fafb', - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' - }, - header: { - backgroundColor: 'white', - boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', - borderBottom: '1px solid #e5e7eb' - }, - headerContent: { - maxWidth: '1280px', - margin: '0 auto', - padding: '0 24px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: '24px', - paddingBottom: '24px' - }, - title: { - fontSize: '30px', - fontWeight: 'bold', - color: '#111827', - margin: 0 - }, - subtitle: { - color: '#6b7280', - marginTop: '4px', - fontSize: '16px' - }, - statusContainer: { - display: 'flex', - alignItems: 'center', - gap: '8px', - color: getStatusColor(), - fontWeight: '500' - }, - mainContent: { - maxWidth: '1280px', - margin: '0 auto', - padding: '32px 24px' - }, - card: { - backgroundColor: 'white', - borderRadius: '8px', - boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', - border: '1px solid #e5e7eb', - padding: '24px', - marginBottom: '32px' - }, - cardTitle: { - fontSize: '18px', - fontWeight: '600', - color: '#111827', - marginBottom: '16px' - }, - connectionForm: { - display: 'flex', - alignItems: 'center', - gap: '16px' - }, - input: { - flex: 1, - padding: '8px 12px', - border: '1px solid #d1d5db', - borderRadius: '6px', - fontSize: '14px', - outline: 'none' - }, - button: { - display: 'inline-flex', - alignItems: 'center', - gap: '8px', - padding: '8px 16px', - backgroundColor: '#2563eb', - color: 'white', - border: 'none', - borderRadius: '6px', - fontSize: '14px', - fontWeight: '500', - cursor: 'pointer', - transition: 'background-color 0.2s' - }, - buttonDisabled: { - opacity: 0.5, - cursor: 'not-allowed' - }, - errorAlert: { - marginTop: '16px', - padding: '16px', - backgroundColor: '#fef2f2', - border: '1px solid #fecaca', - borderRadius: '6px', - display: 'flex', - alignItems: 'center', - gap: '8px' - }, - errorText: { - color: '#991b1b' - }, - tabs: { - borderBottom: '1px solid #e5e7eb', - marginBottom: '32px' - }, - tabsNav: { - display: 'flex', - gap: '32px' - }, - tab: { - padding: '8px 4px', - fontSize: '14px', - fontWeight: '500', - border: 'none', - background: 'none', - cursor: 'pointer', - borderBottom: '2px solid transparent', - transition: 'all 0.2s' - }, - tabActive: { - borderBottom: '2px solid #2563eb', - color: '#2563eb' - }, - tabInactive: { - color: '#6b7280' - }, - statsGrid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', - gap: '24px', - marginBottom: '32px' - }, - statCard: { - backgroundColor: 'white', - borderRadius: '8px', - boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', - border: '1px solid #e5e7eb', - padding: '24px', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between' - }, - statContent: { - flex: 1 - }, - statLabel: { - fontSize: '14px', - fontWeight: '500', - color: '#6b7280' - }, - statValue: { - fontSize: '24px', - fontWeight: 'bold', - color: '#111827', - marginTop: '8px' - }, - statIcon: { - padding: '12px', - borderRadius: '50%' - }, - iconOrange: { - backgroundColor: '#fed7aa', - color: '#ea580c' - }, - iconBlue: { - backgroundColor: '#dbeafe', - color: '#2563eb' - }, - iconGreen: { - backgroundColor: '#dcfce7', - color: '#16a34a' - }, - iconPurple: { - backgroundColor: '#e9d5ff', - color: '#9333ea' - }, - actionButtons: { - display: 'flex', - flexWrap: 'wrap', - gap: '16px' - }, - actionButton: { - display: 'inline-flex', - alignItems: 'center', - gap: '8px', - padding: '8px 16px', - border: 'none', - borderRadius: '6px', - fontSize: '14px', - fontWeight: '500', - cursor: 'pointer', - transition: 'background-color 0.2s' - }, - actionButtonGray: { - backgroundColor: '#f3f4f6', - color: '#374151' - }, - actionButtonGreen: { - backgroundColor: '#dcfce7', - color: '#166534' - }, - actionButtonBlue: { - backgroundColor: '#dbeafe', - color: '#1e40af' - }, - emptyState: { - textAlign: 'center', - padding: '32px' - }, - emptyStateIcon: { - margin: '0 auto 16px', - color: '#9ca3af' - }, - emptyStateText: { - color: '#6b7280' - }, - cardHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '20px' - }, - - refreshButton: { - display: 'flex', - alignItems: 'center', - padding: '8px 16px', - backgroundColor: '#3B82F6', - color: 'white', - border: 'none', - borderRadius: '6px', - cursor: 'pointer', - fontSize: '14px', - fontWeight: '500' - }, - - loadingState: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: '40px', - color: '#6B7280' - }, - - loadingIcon: { - marginBottom: '10px', - color: '#3B82F6' - }, - - dataContainer: { - marginTop: '20px' - }, - - dataHeader: { - marginBottom: '16px', - padding: '12px', - backgroundColor: '#F3F4F6', - borderRadius: '6px' - }, - - dataCount: { - margin: 0, - fontWeight: '600', - color: '#374151' - }, - - tableContainer: { - overflowX: 'auto', - border: '1px solid #E5E7EB', - borderRadius: '8px' - }, - - table: { - width: '100%', - borderCollapse: 'collapse' - }, - - tableHeaderRow: { - backgroundColor: '#F9FAFB' - }, - - tableHeader: { - padding: '12px', - textAlign: 'left', - fontWeight: '600', - color: '#374151', - borderBottom: '1px solid #E5E7EB' - }, - - tableRow: { - borderBottom: '1px solid #F3F4F6' - }, - - tableCell: { - padding: '12px', - color: '#6B7280' - }, - - statusBadge: { - padding: '4px 8px', - borderRadius: '12px', - fontSize: '12px', - fontWeight: '500' - }, - }; + const SettingsView = () => ( +
+
+
+
+
Settings
+
+
+
+ + + Current API endpoint +
+ +
+
+
+
+ ); return ( -
- {/* Header */} -
-
-
-

FastAPI Dashboard

-

Manage your backend data and monitor API status

-
-
- {getStatusIcon()} - {getStatusText()} +
+ {/* Navigation Bar */} +
+ {/* Main Content */} -
- {/* Connection Section */} -
-

API Connection

-
- setApiUrl(e.target.value)} - style={styles.input} - placeholder="Enter API URL" - /> +
+ {/* Error Alert */} + {error && ( +
+ + Error! {error} + type="button" + className="btn-close" + onClick={() => setError(null)} + >
- {connectionStatus === 'error' && ( -
- - NetworkError when attempting to fetch resource. -
- )} -
+ )} {/* Navigation Tabs */} -
-
+
    +
  • +
  • +
  • -
-
- - {/* Stats Grid */} -
- {/* API Status */} -
-
-

API Status

-

- {connectionStatus === 'connected' ? 'Online' : 'Offline'} -

-
-
- -
-
- - {/* Active Members */} -
-
-

Active Members

-

{totalMembers}

-
-
- -
-
- - {/* Active Users */} -
-
-

Active Users

-

1

-
-
- -
-
- - {/* Activity */} -
-
-

Activity

-

Live

-
-
- -
-
-
- - {/* Quick Actions */} -
-

Quick Actions

-
- - - + + + + {/* Loading Spinner */} + {loading && ( +
+
+ Loading... +
+

Loading data...

-
- - {/* Content based on active tab */} - {activeTab === 'data' && ( -
-
-

Data Management

- -
- - {isLoading ? ( -
- -

Loading members...

-
- ) : members.length === 0 ? ( -
- -

No members found. Click "Load Members" to fetch data.

-
- ) : ( -
-
-

Total Members: {members.length}

-
- -
- - - - - - - - - - - - {members.map((member, index) => ( - - - - - - - - ))} - -
IDNameEmailActiveActions
{member.id}{getFullName(member)}{member.member_email || 'N/A'} - - {member.current_state.state_name} - - - - -
-
-
- )} -
- )} - + )} + + {/* Tab Content */} + {!loading && ( + <> + {activeTab === 'dashboard' && } + {activeTab === 'members' && } + {activeTab === 'payments' && } + {activeTab === 'settings' && } + + )}
- -
); }; -export default Dashboard; \ No newline at end of file +interface StatCardProps { + title: string; + value: string | number; + icon: React.ReactNode; + bgClass: string; +} + +const StatCard: React.FC = ({ title, value, icon, bgClass }) => ( +
+
+
+
+
{title}
+

{value}

+
+
{icon}
+
+
+
+); + +export default FastAPIFrontend; \ No newline at end of file diff --git a/sustApp/react-frontend/src/FastAPIFrontend_v10.tsx b/sustApp/react-frontend/src/FastAPIFrontend_v10.tsx new file mode 100644 index 0000000..51017f7 --- /dev/null +++ b/sustApp/react-frontend/src/FastAPIFrontend_v10.tsx @@ -0,0 +1,334 @@ +import React, { useState, useEffect } from 'react'; +import { BarChart3, Users, CreditCard, FileText, Settings, LogOut } from 'lucide-react'; + +interface Member { + id: number; + member_firstname: string; + member_surname: string; + member_email: string; + member_active: boolean; + member_count: number; +} + +interface Payment { + id: number; + member_id: number; + payment_date: string; + payment_amount: number; + payment_year: string; +} + +interface DashboardStats { + total_members: number; + active_members: number; + total_payments: number; + total_amount: number; +} + +const FastAPIFrontend: React.FC = () => { + const [activeTab, setActiveTab] = useState<'dashboard' | 'members' | 'payments' | 'settings'>('dashboard'); + const [members, setMembers] = useState([]); + const [payments, setPayments] = useState([]); + const [stats, setStats] = useState({ + total_members: 0, + active_members: 0, + total_payments: 0, + total_amount: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const apiUrl = 'http://localhost:8000'; + + useEffect(() => { + fetchDashboardData(); + }, []); + + const fetchDashboardData = async () => { + setLoading(true); + setError(null); + try { + const [membersRes, paymentsRes] = await Promise.all([ + fetch(`${apiUrl}/members/`), + fetch(`${apiUrl}/payments/`), + ]); + + if (membersRes.ok && paymentsRes.ok) { + const membersData = await membersRes.json(); + const paymentsData = await paymentsRes.json(); + + setMembers(membersData); + setPayments(paymentsData); + + // Calculate stats + const activeCount = membersData.filter((m: Member) => m.member_active).length; + const totalAmount = paymentsData.reduce((sum: number, p: Payment) => sum + p.payment_amount, 0); + + setStats({ + total_members: membersData.length, + active_members: activeCount, + total_payments: paymentsData.length, + total_amount: totalAmount, + }); + } else { + setError('Failed to fetch data from server'); + } + } catch (err) { + setError('Error connecting to server: ' + (err instanceof Error ? err.message : 'Unknown error')); + } finally { + setLoading(false); + } + }; + + const DashboardView = () => ( +
+ } + color="blue" + /> + } + color="green" + /> + } + color="purple" + /> + } + color="orange" + /> +
+ ); + + const MembersView = () => ( +
+
+

Members List

+
+
+ + + + + + + + + + + {members.map((member) => ( + + + + + + + ))} + +
NameEmailStatusCount
+ {member.member_firstname} {member.member_surname} + {member.member_email} + + {member.member_active ? 'Active' : 'Inactive'} + + {member.member_count}
+
+
+ ); + + const PaymentsView = () => ( +
+
+

Payments List

+
+
+ + + + + + + + + + + {payments.map((payment) => ( + + + + + + + ))} + +
Member IDDateAmountYear
{payment.member_id} + {new Date(payment.payment_date).toLocaleDateString()} + €{payment.payment_amount.toFixed(2)}{payment.payment_year}
+
+
+ ); + + const SettingsView = () => ( +
+

Settings

+
+
+ + +
+ +
+
+ ); + + return ( +
+ {/* Header */} +
+
+
+
+ +

SUST Dashboard

+
+ +
+
+
+ + {/* Main Content */} +
+ {/* Error Alert */} + {error && ( +
+ {error} +
+ )} + + {/* Navigation Tabs */} +
+ } + isActive={activeTab === 'dashboard'} + onClick={() => setActiveTab('dashboard')} + /> + } + isActive={activeTab === 'members'} + onClick={() => setActiveTab('members')} + /> + } + isActive={activeTab === 'payments'} + onClick={() => setActiveTab('payments')} + /> + } + isActive={activeTab === 'settings'} + onClick={() => setActiveTab('settings')} + /> +
+ + {/* Loading State */} + {loading && ( +
+
Loading...
+
+ )} + + {/* Tab Content */} + {!loading && ( + <> + {activeTab === 'dashboard' && } + {activeTab === 'members' && } + {activeTab === 'payments' && } + {activeTab === 'settings' && } + + )} +
+
+ ); +}; + +interface StatCardProps { + title: string; + value: string | number; + icon: React.ReactNode; + color: 'blue' | 'green' | 'purple' | 'orange'; +} + +const StatCard: React.FC = ({ title, value, icon, color }) => { + const colorClasses = { + blue: 'bg-blue-500', + green: 'bg-green-500', + purple: 'bg-purple-500', + orange: 'bg-orange-500', + }; + + return ( +
+
+
+

{title}

+

{value}

+
+
{icon}
+
+
+ ); +}; + +interface TabButtonProps { + label: string; + icon: React.ReactNode; + isActive: boolean; + onClick: () => void; +} + +const TabButton: React.FC = ({ label, icon, isActive, onClick }) => ( + +); + +export default FastAPIFrontend; \ No newline at end of file diff --git a/sustApp/react-frontend/src/FastAPIFrontend_v8.tsx b/sustApp/react-frontend/src/FastAPIFrontend_v8.tsx new file mode 100644 index 0000000..fb9fdb4 --- /dev/null +++ b/sustApp/react-frontend/src/FastAPIFrontend_v8.tsx @@ -0,0 +1,573 @@ +import React, { useState, useEffect } from 'react'; +import { Search, Plus, Edit3, Trash2, RefreshCw, AlertCircle, CheckCircle, Server, Database, Users, Activity } from 'lucide-react'; + +const FastAPIFrontend = () => { + const [apiUrl, setApiUrl] = useState('http://localhost:8000'); + const [isConnected, setIsConnected] = useState(false); + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('dashboard'); + const [members, setMembers] = useState([]); + const [activeMembersCount, setActiveMembersCount] = useState(0); + const [inactiveMembersCount, setInactiveMembersCount] = useState(0); + const [deactivatedMembersCount, setDeactivatedMembersCount] = useState(0); + const [formData, setFormData] = useState({}); + const [showModal, setShowModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + + // Test API connection + const testConnection = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${apiUrl}/health`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + setIsConnected(true); + setError(null); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (err) { + setIsConnected(false); + setError(err.message || 'Connection failed'); + } finally { + setLoading(false); + } + }; + + // Fetch member counts + const fetchMemberCounts = async () => { + if (!isConnected) return; + + try { + // Fetch active members count + const activeResponse = await fetch(`${apiUrl}/activememberscount`, { + headers: { 'Content-Type': 'application/json' } + }); + if (activeResponse.ok) { + const activeCount = await activeResponse.json(); + setActiveMembersCount(activeCount); + } + + // Fetch inactive members count + const inactiveResponse = await fetch(`${apiUrl}/inactivememberscount`, { + headers: { 'Content-Type': 'application/json' } + }); + if (inactiveResponse.ok) { + const inactiveCount = await inactiveResponse.json(); + setInactiveMembersCount(inactiveCount); + } + + // Fetch deactivated members count + const deactivatedResponse = await fetch(`${apiUrl}/deactivatedmemberscount`, { + headers: { 'Content-Type': 'application/json' } + }); + if (deactivatedResponse.ok) { + const deactivatedCount = await deactivatedResponse.json(); + setDeactivatedMembersCount(deactivatedCount); + } + } catch (err) { + console.error('Error fetching member counts:', err); + } + }; + + // Fetch data from API + const fetchData = async (endpoint = 'items') => { + if (!isConnected) return; + + setLoading(true); + try { + const response = await fetch(`${apiUrl}/${endpoint}`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const result = await response.json(); + setData(Array.isArray(result) ? result : [result]); + } else { + throw new Error(`Failed to fetch data: ${response.statusText}`); + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + // Load members data + const loadMembers = async () => { + setLoading(true); + try { + const response = await fetch(`${apiUrl}/members/`); + if (response.ok) { + const data = await response.json(); + setMembers(data); + } else { + console.error('Failed to fetch members'); + setMembers([]); + } + } catch (error) { + console.error('Error loading members:', error); + setMembers([]); + } finally { + setLoading(false); + } + }; + + // Create new item + const createItem = async (itemData) => { + try { + const response = await fetch(`${apiUrl}/items`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(itemData), + }); + + if (response.ok) { + await fetchData(); + setShowModal(false); + setFormData({}); + } else { + throw new Error('Failed to create item'); + } + } catch (err) { + setError(err.message); + } + }; + + // Update item + const updateItem = async (id, itemData) => { + try { + const response = await fetch(`${apiUrl}/items/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(itemData), + }); + + if (response.ok) { + await fetchData(); + setShowModal(false); + setEditingItem(null); + setFormData({}); + } else { + throw new Error('Failed to update item'); + } + } catch (err) { + setError(err.message); + } + }; + + // Delete item + const deleteItem = async (id) => { + if (!window.confirm('Are you sure you want to delete this item?')) return; + + try { + const response = await fetch(`${apiUrl}/items/${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + await fetchData(); + } else { + throw new Error('Failed to delete item'); + } + } catch (err) { + setError(err.message); + } + }; + + // Handle form submission + const handleSubmit = (e) => { + e.preventDefault(); + if (editingItem) { + updateItem(editingItem.id, formData); + } else { + createItem(formData); + } + }; + + // Open edit modal + const startEdit = (item) => { + setEditingItem(item); + setFormData(item); + setShowModal(true); + }; + + useEffect(() => { + testConnection(); + }, [apiUrl]); + + useEffect(() => { + if (isConnected) { + fetchMemberCounts(); + if (activeTab === 'data') { + fetchData(); + } + } + }, [isConnected, activeTab]); + + const ConnectionStatus = () => ( +
+
+ + {isConnected ? 'Connected' : 'Disconnected'} + + + {apiUrl} +
+ ); + + const Dashboard = () => ( +
+
+
+
+
+

API Status

+

{isConnected ? 'Online' : 'Offline'}

+
+ +
+
+ +
+
+
+

Active Members

+

{activeMembersCount}

+
+ +
+
+ +
+
+
+

Inactive Members

+

{inactiveMembersCount}

+
+ +
+
+ +
+
+
+

Deactivated Members

+

{deactivatedMembersCount}

+
+ +
+
+ +
+
+
+

Total Members

+

{activeMembersCount + inactiveMembersCount + deactivatedMembersCount}

+
+ +
+
+ +
+
+
+

Activity

+

Live

+
+ +
+
+
+ +
+

Member Statistics

+
+
+
{activeMembersCount}
+
Active Members
+
+ {activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0 + ? `${Math.round((activeMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%` + : '0%' + } of total +
+
+ +
+
{inactiveMembersCount}
+
Inactive Members
+
+ {activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0 + ? `${Math.round((inactiveMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%` + : '0%' + } of total +
+
+ +
+
{deactivatedMembersCount}
+
Deactivated Members
+
+ {activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0 + ? `${Math.round((deactivatedMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%` + : '0%' + } of total +
+
+
+ +

Quick Actions

+
+ + + + + +
+
+
+ ); + + const DataView = () => ( +
+
+

Data Management

+ +
+ + {loading ? ( +
+ + Loading... +
+ ) : ( +
+
+ + + + + + + + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((item, index) => ( + + + + + + + )) + )} + +
IDNameDescriptionActions
+ No data available. Add some items to get started. +
+ {item.id || index + 1} + + {item.name || item.title || 'N/A'} + + {item.description || item.content || 'No description'} + +
+ + +
+
+
+
+ )} +
+ ); + + const Modal = () => ( + showModal && ( +
+
+

+ {editingItem ? 'Edit Item' : 'Add New Item'} +

+ +
+
+ + setFormData({...formData, name: e.target.value})} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter name" + /> +
+ +
+ +