Enhancements
This commit is contained in:
@@ -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@;^ppLLtJcW<P4-=p5BfaS3>oI9_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`+^<Wt`k*LE2%)9K:u@*'QQS7tsrNCihb^niU&n8'S/GkdY?O0CEb*X6q%0E/r^%if9=[S6ANcJHRO$+-U6%/m]!"p50iSC$h`:_+cF,4H5cKcBo3RB\a034VggqaeZ65=?J#8F2k`@DBV"6SBj/:"4ArWe3]-X#2>?(2,Rm)g.g(i,@O%<oT3tE*Z>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*<f$:JnBF$5uObV\_g8G<%BL1k7CXY.1.dC$HogM=K/(bW-5nh&1_lR7(#jUNnU.(7j4mH.qm;!3d28ZeOD?)HlY<VNV_Xr91YL7<k/5\5ng6Jr*)k3r%fZCN9F=766oln`;c&mN*_<e6p_o^koCt!oYoo06bW$ZpA851%;tPbDNE_!`,:r4=R2R',Fh!6m5s8=@LV:F*eAfl7lW#kbp3#-^$pjX/<:@b&YU6k&q[j+keG:dI`n&C10YK)$>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""<rs$b;m]pHeJbRHT3t)uQC2/26<-AN\$[u$)iTa&)]9]ZLG6iI0N\WM>/0$_1Z8CY99ZirV$`6KB&q3SPK<ich29;d*HfOP#<6m"-:`1%XdU6eaD:Y7KLn.1HmW,*8^2k`q7C!3m2F-Y-,f.j%+\R5=fT&IDYQ-umduiRXTUVP@AZ$WYa_-$_^h=u8rC.nS9s`I]=Q-X4l_qabfr1ji;<FdEs1=%`fqk^^KTFS`HE0DUkq.mdXti95^Qcndl-!G/X7["gO.MqCST9aVW8_soo)a?EK[8/`4,Q>%\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%);<C).@P']o=9pnul]B(Z&K;Rpa9=sZn#Q$'o_3Iu\6e<:?h0pJ#:k=$$]ZO__S7/'U/C2bhjFA[^0*:^?((0ODQ!%$"l;p47hL.?YfX:A(o!Qk2`XM-@QdZ>4nCM5aZ5lj^oLX+bZjR2;&K`$MD1%WF!,LrQR63g_'2RJ)k?3:oh=Iem&IShAml40?KH7?>YD2Q^4BE%gYF!Fk>_]!c0QCbhPQss-?):cbA(Wnf$$0oZt=<Kc,-WlX1H!33DQXZJ(,E^;jPDr1^N:oc,,KQ[L5nTaIn:M.CI2]$67rd\:0aViUGu*XV$LqCt\^)gWns!C9r.61sV`OU79*p\7Bu+A'h)3J^/,(Bjl?*E,H>/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<G?),[SqoHc@4R5@UNm]VmBd[:?Y@m&VTo>'95,3]n'ulhpNbHiCA(!X-9piUq6n]7$rMR>l$C+FoLoK1C@1Y_L>!R0gIq<GoD?d=S:14X#lj`/p2[YFD8e)QkYrg,OO:g7!"9u+NnV,=:h<%u10_RkAON^WgR(fK7&Oe'bQCiqH;uj1UD:Hl)26Iq?6r'L[%^X,;&HG!<Fd'R)8]4F.+Aer3f5($6BIrJj:D;5]q5P5+2?XFS>M[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
|
||||||
@@ -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 = """
|
||||||
|
<br/><b>1. Tatsächliche Mitgliedschaft:</b> 175 Personen in 142 primären Haushaltskonten (66 Personen in Paaren / 37,7%, 109 Einzelpersonen / 62,3%)
|
||||||
|
<br/><b>2. 2025 Leistung:</b> Paare 100% Quote, Einzelpersonen 93,6%, personenbasierte Quote 96,0%
|
||||||
|
<br/><b>3. 2026 Leistung:</b> Paare 97,0% Quote, Einzelpersonen 89,0%, personenbasierte Quote 92,0% (4% Rückgang)
|
||||||
|
<br/><b>4. Umsatzwachstum:</b> €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()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<style>
|
||||||
|
table {border: medium solid #6495ed;border-collapse: collapse;width: 100%;}
|
||||||
|
th{font-family: monospace;border: thin solid #6495ed;padding: 5px;background-color: #D0E3FA;text-align: left;}
|
||||||
|
td{font-family: sans-serif;border: thin solid #6495ed;padding: 5px;text-align: center;}
|
||||||
|
img{padding:5px; border:solid; border-color: #dddddd #aaaaaa #aaaaaa #dddddd; border-width: 1px 2px 2px 1px; background-color:white;}
|
||||||
|
|
||||||
|
tr.unpaid-both { background-color: #ff9999 !important; }
|
||||||
|
tr.unpaid-either{ background-color: #ffcccc !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatAustrianDate(isoDate) {
|
||||||
|
if (!isoDate || isoDate === ' ' || isoDate.trim() === '') return '';
|
||||||
|
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
if (isNaN(date.getTime())) return isoDate; // Invalid date
|
||||||
|
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const rows = document.querySelectorAll('table tr');
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
if (index === 0) return; // Skip header
|
||||||
|
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length < 11) return;
|
||||||
|
|
||||||
|
// Format membership_start (column 7) as DD.MM.YYYY
|
||||||
|
const startCell = cells[7];
|
||||||
|
const originalText = startCell.textContent.trim();
|
||||||
|
const formattedDate = formatAustrianDate(originalText);
|
||||||
|
if (formattedDate && formattedDate !== originalText) {
|
||||||
|
startCell.textContent = formattedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment coloring logic (unchanged)
|
||||||
|
const startText = formattedDate || originalText;
|
||||||
|
const year2024 = cells[9].textContent.trim();
|
||||||
|
const year2025 = cells[10].textContent.trim();
|
||||||
|
|
||||||
|
const paid2024 = year2024 === 'X';
|
||||||
|
const paid2025 = year2025 === 'X';
|
||||||
|
const started2025 = startText.includes('2025');
|
||||||
|
|
||||||
|
if (started2025 && paid2025) return;
|
||||||
|
|
||||||
|
if (!paid2024 && !paid2025) {
|
||||||
|
row.classList.add('unpaid-both');
|
||||||
|
} else if (!paid2024 || !paid2025) {
|
||||||
|
row.classList.add('unpaid-either');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
@@ -95,7 +95,7 @@ SELECT
|
|||||||
msl.changed_by
|
msl.changed_by
|
||||||
FROM member_state_log msl
|
FROM member_state_log msl
|
||||||
JOIN member_states ms ON msl.state_code = ms.state_code
|
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;
|
ORDER BY msl.changed_at DESC;
|
||||||
|
|
||||||
-- Count members by state
|
-- Count members by state
|
||||||
@@ -109,7 +109,7 @@ ORDER BY ms.state_code;
|
|||||||
|
|
||||||
-- Step 7: Example usage of the change_member_state function
|
-- Step 7: Example usage of the change_member_state function
|
||||||
-- Change member 123 to inactive
|
-- 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
|
-- Reactivate member 123
|
||||||
SELECT change_member_state(123, 'A', 1, 'Member reactivation approved');
|
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;
|
ORDER BY ms.state_code;
|
||||||
|
|
||||||
-- Step 9: After testing, remove the old column (CAREFUL!)
|
-- Step 9: After testing, remove the old column (CAREFUL!)
|
||||||
-- ALTER TABLE members DROP COLUMN member_active;
|
-- 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%';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
Generated
+157
-122
@@ -4,8 +4,10 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "SUST",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -14,9 +16,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
|
||||||
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
|
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -31,9 +33,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
|
||||||
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
|
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -48,9 +50,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
|
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -65,9 +67,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
|
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -82,9 +84,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
|
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -99,9 +101,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
|
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -116,9 +118,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
|
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -133,9 +135,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
|
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -150,9 +152,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
|
||||||
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
|
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -167,9 +169,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
|
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -184,9 +186,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
|
||||||
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
|
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -201,9 +203,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
|
||||||
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
|
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -218,9 +220,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
|
||||||
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
|
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -235,9 +237,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
|
||||||
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
|
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -252,9 +254,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
|
||||||
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
|
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -269,9 +271,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
|
||||||
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
|
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -286,9 +288,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
|
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -303,9 +305,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
|
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -320,9 +322,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
|
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -337,9 +339,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
|
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -354,9 +356,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
|
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -371,9 +373,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
|
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -388,9 +390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
|
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -405,9 +407,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
|
||||||
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
|
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -422,9 +424,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
|
||||||
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
|
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -439,9 +441,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
|
||||||
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
|
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -455,6 +457,17 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.45.3",
|
"version": "4.45.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.3.tgz",
|
"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"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
@@ -758,9 +790,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.8",
|
"version": "0.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
|
||||||
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
|
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -771,40 +803,43 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.25.8",
|
"@esbuild/aix-ppc64": "0.27.1",
|
||||||
"@esbuild/android-arm": "0.25.8",
|
"@esbuild/android-arm": "0.27.1",
|
||||||
"@esbuild/android-arm64": "0.25.8",
|
"@esbuild/android-arm64": "0.27.1",
|
||||||
"@esbuild/android-x64": "0.25.8",
|
"@esbuild/android-x64": "0.27.1",
|
||||||
"@esbuild/darwin-arm64": "0.25.8",
|
"@esbuild/darwin-arm64": "0.27.1",
|
||||||
"@esbuild/darwin-x64": "0.25.8",
|
"@esbuild/darwin-x64": "0.27.1",
|
||||||
"@esbuild/freebsd-arm64": "0.25.8",
|
"@esbuild/freebsd-arm64": "0.27.1",
|
||||||
"@esbuild/freebsd-x64": "0.25.8",
|
"@esbuild/freebsd-x64": "0.27.1",
|
||||||
"@esbuild/linux-arm": "0.25.8",
|
"@esbuild/linux-arm": "0.27.1",
|
||||||
"@esbuild/linux-arm64": "0.25.8",
|
"@esbuild/linux-arm64": "0.27.1",
|
||||||
"@esbuild/linux-ia32": "0.25.8",
|
"@esbuild/linux-ia32": "0.27.1",
|
||||||
"@esbuild/linux-loong64": "0.25.8",
|
"@esbuild/linux-loong64": "0.27.1",
|
||||||
"@esbuild/linux-mips64el": "0.25.8",
|
"@esbuild/linux-mips64el": "0.27.1",
|
||||||
"@esbuild/linux-ppc64": "0.25.8",
|
"@esbuild/linux-ppc64": "0.27.1",
|
||||||
"@esbuild/linux-riscv64": "0.25.8",
|
"@esbuild/linux-riscv64": "0.27.1",
|
||||||
"@esbuild/linux-s390x": "0.25.8",
|
"@esbuild/linux-s390x": "0.27.1",
|
||||||
"@esbuild/linux-x64": "0.25.8",
|
"@esbuild/linux-x64": "0.27.1",
|
||||||
"@esbuild/netbsd-arm64": "0.25.8",
|
"@esbuild/netbsd-arm64": "0.27.1",
|
||||||
"@esbuild/netbsd-x64": "0.25.8",
|
"@esbuild/netbsd-x64": "0.27.1",
|
||||||
"@esbuild/openbsd-arm64": "0.25.8",
|
"@esbuild/openbsd-arm64": "0.27.1",
|
||||||
"@esbuild/openbsd-x64": "0.25.8",
|
"@esbuild/openbsd-x64": "0.27.1",
|
||||||
"@esbuild/openharmony-arm64": "0.25.8",
|
"@esbuild/openharmony-arm64": "0.27.1",
|
||||||
"@esbuild/sunos-x64": "0.25.8",
|
"@esbuild/sunos-x64": "0.27.1",
|
||||||
"@esbuild/win32-arm64": "0.25.8",
|
"@esbuild/win32-arm64": "0.27.1",
|
||||||
"@esbuild/win32-ia32": "0.25.8",
|
"@esbuild/win32-ia32": "0.27.1",
|
||||||
"@esbuild/win32-x64": "0.25.8"
|
"@esbuild/win32-x64": "0.27.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.4.6",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@@ -966,14 +1001,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -983,18 +1018,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.0.6",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.4.6",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.40.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.14"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
+16
-16
@@ -1,15 +1,15 @@
|
|||||||
annotated-doc==0.0.4
|
annotated-doc==0.0.4
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
anyio==4.12.0
|
anyio==4.12.1
|
||||||
branca==0.8.2
|
branca==0.8.2
|
||||||
certifi==2025.11.12
|
certifi==2026.1.4
|
||||||
chardet==5.2.0
|
chardet==5.2.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
click==8.3.1
|
click==8.3.1
|
||||||
dnspython==2.8.0
|
dnspython==2.8.0
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
fastapi==0.124.0
|
fastapi==0.128.0
|
||||||
fastapi-cli==0.0.16
|
fastapi-cli==0.0.20
|
||||||
folium==0.20.0
|
folium==0.20.0
|
||||||
geographiclib==2.1
|
geographiclib==2.1
|
||||||
geopy==2.4.1
|
geopy==2.4.1
|
||||||
@@ -23,36 +23,36 @@ Jinja2==3.1.6
|
|||||||
markdown-it-py==4.0.0
|
markdown-it-py==4.0.0
|
||||||
MarkupSafe==3.0.3
|
MarkupSafe==3.0.3
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
numpy==2.3.5
|
numpy==2.4.1
|
||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
pillow==12.0.0
|
pillow==12.1.0
|
||||||
psycopg2-binary==2.9.11
|
psycopg2-binary==2.9.11
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
pydantic_core==2.41.5
|
pydantic_core==2.41.5
|
||||||
Pygments==2.19.2
|
Pygments==2.19.2
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.21
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
reportlab==4.4.5
|
reportlab==4.4.9
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
rich==14.2.0
|
rich==14.2.0
|
||||||
rich-toolkit==0.17.0
|
rich-toolkit==0.17.1
|
||||||
segno==1.6.6
|
segno==1.6.6
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
SQLAlchemy==2.0.44
|
SQLAlchemy==2.0.45
|
||||||
SQLAlchemy-Utils==0.42.0
|
SQLAlchemy-Utils==0.42.1
|
||||||
starlette==0.50.0
|
starlette==0.50.0
|
||||||
typer==0.20.0
|
typer==0.21.1
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
tzdata==2025.2
|
tzdata==2025.3
|
||||||
urllib3==2.6.0
|
urllib3==2.6.3
|
||||||
uvicorn==0.38.0
|
uvicorn==0.40.0
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
watchfiles==1.1.1
|
watchfiles==1.1.1
|
||||||
websockets==15.0.1
|
websockets==16.0
|
||||||
xyzservices==2025.11.0
|
xyzservices==2025.11.0
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 999 KiB |
@@ -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.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SUST Admin - Member & Payment Management</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2158
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Member[]>([])
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([])
|
||||||
|
const [stats, setStats] = useState<Stats>({
|
||||||
|
activeCount: 0,
|
||||||
|
inactiveCount: 0,
|
||||||
|
deactivatedCount: 0,
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedTab, setSelectedTab] = useState<'overview' | 'members' | 'payments'>('overview')
|
||||||
|
const [selectedMember, setSelectedMember] = useState<Member | null>(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 (
|
||||||
|
<div style={{ backgroundColor: '#f8f9fa', minHeight: '100vh' }}>
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||||
|
<div className="container-fluid px-4">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
{tabContent.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setSelectedTab(tab.id as 'overview' | 'members' | 'payments')}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.5rem',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor:
|
||||||
|
selectedTab === tab.id ? '#27ae60' : '#ffffff',
|
||||||
|
color: selectedTab === tab.id ? '#ffffff' : '#555',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: selectedTab === tab.id ? '0 2px 8px rgba(39, 174, 96, 0.2)' : 'none',
|
||||||
|
}}
|
||||||
|
className="btn"
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedTab === 'members' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddMember(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.5rem',
|
||||||
|
backgroundColor: '#f39c12',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
className="btn"
|
||||||
|
>
|
||||||
|
➕ Add Member
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
{selectedTab === 'overview' && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 style={{ color: '#2c3e50', fontWeight: 600, marginBottom: '1.5rem' }}>
|
||||||
|
Dashboard Overview
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="row mb-5">
|
||||||
|
<StatsCard title="Active Members" count={stats.activeCount} icon="✅" color="success" />
|
||||||
|
<StatsCard title="Inactive Members" count={stats.inactiveCount} icon="⏸️" color="warning" />
|
||||||
|
<StatsCard
|
||||||
|
title="Deactivated Members"
|
||||||
|
count={stats.deactivatedCount}
|
||||||
|
icon="❌"
|
||||||
|
color="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6 mb-4">
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title" style={{ color: '#2c3e50', fontWeight: 600 }}>
|
||||||
|
Quick Stats
|
||||||
|
</h5>
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<p className="mb-3">
|
||||||
|
<strong>Total Members:</strong>
|
||||||
|
<span style={{ float: 'right', fontSize: '1.1rem', color: '#27ae60' }}>
|
||||||
|
{members.length}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mb-3">
|
||||||
|
<strong>Total Payments:</strong>
|
||||||
|
<span style={{ float: 'right', fontSize: '1.1rem', color: '#3498db' }}>
|
||||||
|
{payments.length}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Member Distribution:</strong>
|
||||||
|
<span style={{ float: 'right', fontSize: '1.1rem', color: '#e74c3c' }}>
|
||||||
|
{members.reduce((sum, m) => sum + m.member_count, 0)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-6 mb-4">
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title" style={{ color: '#2c3e50', fontWeight: 600 }}>
|
||||||
|
Quick Actions
|
||||||
|
</h5>
|
||||||
|
<div className="d-grid gap-2" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTab('members')}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#27ae60',
|
||||||
|
color: 'white',
|
||||||
|
padding: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse Members
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTab('payments')}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#3498db',
|
||||||
|
color: 'white',
|
||||||
|
padding: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Payments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Members Tab */}
|
||||||
|
{selectedTab === 'members' && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 style={{ color: '#2c3e50', fontWeight: 600, marginBottom: '1.5rem' }}>
|
||||||
|
Member Management
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<MembersTable
|
||||||
|
members={members}
|
||||||
|
onViewDetails={setSelectedMember}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payments Tab */}
|
||||||
|
{selectedTab === 'payments' && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 style={{ color: '#2c3e50', fontWeight: 600, marginBottom: '1.5rem' }}>
|
||||||
|
Payment Records
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<PaymentsTable members={members} payments={payments} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{selectedMember && (
|
||||||
|
<MemberModal member={selectedMember} onClose={() => setSelectedMember(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddMember && (
|
||||||
|
<AddMemberForm
|
||||||
|
onSuccess={handleAddMemberSuccess}
|
||||||
|
onCancel={() => setShowAddMember(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -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<Member[]>('/members/', { params: { skip, limit } })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMember = async (memberId: number) => {
|
||||||
|
const response = await api.get<Member>(`/member/${memberId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMember = async (member: Partial<Member>) => {
|
||||||
|
const response = await api.post<Member>('/members/', member)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMembers = async (members: Partial<Member>[]) => {
|
||||||
|
const response = await api.post<void>('/fullmembers/', members)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
export const getAllPayments = async () => {
|
||||||
|
const response = await api.get<Payment[]>('/payments/')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMemberPayments = async (memberId: number) => {
|
||||||
|
const response = await api.get<Payment[]>(`/payments/${memberId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
export const getStats = async (): Promise<Stats> => {
|
||||||
|
const [activeCount, inactiveCount, deactivatedCount] = await Promise.all([
|
||||||
|
api.get<number>('/activememberscount/').then((r) => r.data || 0),
|
||||||
|
api.get<number>('/inactivememberscount/').then((r) => r.data || 0),
|
||||||
|
api.get<number>('/deactivatedmemberscount/').then((r) => r.data || 0),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
activeCount: activeCount || 0,
|
||||||
|
inactiveCount: inactiveCount || 0,
|
||||||
|
deactivatedCount: deactivatedCount || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -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<AddMemberFormProps> = ({ onSuccess, onCancel }) => {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [formData, setFormData] = useState<Partial<Member>>({
|
||||||
|
member_firstname: '',
|
||||||
|
member_surname: '',
|
||||||
|
member_street: '',
|
||||||
|
member_city: '',
|
||||||
|
member_count: 1,
|
||||||
|
member_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="modal d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-lg">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Add New Member</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{ filter: 'invert(0)' }}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" style={{ borderLeft: '4px solid #e74c3c' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">
|
||||||
|
First Name <span style={{ color: '#e74c3c' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
name="member_firstname"
|
||||||
|
value={formData.member_firstname || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">
|
||||||
|
Surname <span style={{ color: '#e74c3c' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
name="member_surname"
|
||||||
|
value={formData.member_surname || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
name="member_email"
|
||||||
|
value={formData.member_email || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<label className="form-label">
|
||||||
|
Street <span style={{ color: '#e74c3c' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
name="member_street"
|
||||||
|
value={formData.member_street || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label">Street Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
name="member_streetnumber"
|
||||||
|
value={formData.member_streetnumber || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label">ZIP Code</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
name="member_zip"
|
||||||
|
value={formData.member_zip || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-8">
|
||||||
|
<label className="form-label">
|
||||||
|
City <span style={{ color: '#e74c3c' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
name="member_city"
|
||||||
|
value={formData.member_city || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Member Count</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
name="member_count"
|
||||||
|
value={formData.member_count || 1}
|
||||||
|
onChange={handleChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
name="member_active"
|
||||||
|
value={formData.member_active ? 'true' : 'false'}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="true">Active</option>
|
||||||
|
<option value="false">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#7f8c8d',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#27ae60',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? '⏳ Creating...' : '✓ Create Member'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Member } from '../types'
|
||||||
|
|
||||||
|
interface MemberModalProps {
|
||||||
|
member: Member | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemberModal: React.FC<MemberModalProps> = ({ member, onClose }) => {
|
||||||
|
if (!member) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-lg">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">
|
||||||
|
{member.member_firstname} {member.member_surname}
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ filter: 'invert(0)' }}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">ID</label>
|
||||||
|
<p style={{ fontSize: '1.1rem', fontWeight: 600, color: '#27ae60' }}>{member.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">First Name</label>
|
||||||
|
<p style={{ fontSize: '1rem' }}>{member.member_firstname}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">Surname</label>
|
||||||
|
<p style={{ fontSize: '1rem' }}>{member.member_surname}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">Email</label>
|
||||||
|
<p style={{ fontSize: '1rem' }}>{member.member_email || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">Street</label>
|
||||||
|
<p style={{ fontSize: '1rem' }}>{member.member_street}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">ZIP Code</label>
|
||||||
|
<p style={{ fontSize: '1rem' }}>{member.member_zip || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">City</label>
|
||||||
|
<p style={{ fontSize: '1rem' }}>{member.member_city}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-muted small">Status</label>
|
||||||
|
<p>
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
backgroundColor: member.member_active ? '#d4edda' : '#f8d7da',
|
||||||
|
color: member.member_active ? '#155724' : '#721c24',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.member_active ? '✓ Active' : '✗ Inactive'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{member.member_firstname_spouse && (
|
||||||
|
<div className="alert" style={{ backgroundColor: '#eff', borderLeft: '4px solid #3498db' }}>
|
||||||
|
<strong>Spouse:</strong> {member.member_firstname_spouse}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{member.member_payments && member.member_payments.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h6 style={{ fontWeight: 600, color: '#2c3e50', marginBottom: '1rem' }}>Recent Payments</h6>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm">
|
||||||
|
<thead style={{ backgroundColor: '#f8f9fa' }}>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{member.member_payments.slice(0, 5).map((payment) => (
|
||||||
|
<tr key={payment.id}>
|
||||||
|
<td>{new Date(payment.payment_date).toLocaleDateString()}</td>
|
||||||
|
<td>{payment.payment_year}</td>
|
||||||
|
<td style={{ fontWeight: 600, color: '#3498db' }}>
|
||||||
|
€{payment.payment_amount ? payment.payment_amount.toFixed(2) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#7f8c8d',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#27ae60',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<MembersTableProps> = ({ 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 (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="🔍 Search by name or email..."
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
style={{ fontSize: '0.95rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMembers.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>First Name</th>
|
||||||
|
<th>Surname</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>City</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredMembers.map((member) => (
|
||||||
|
<tr key={member.id}>
|
||||||
|
<td style={{ fontWeight: 600, color: '#27ae60' }}>{member.id}</td>
|
||||||
|
<td>{member.member_firstname}</td>
|
||||||
|
<td>{member.member_surname}</td>
|
||||||
|
<td style={{ fontSize: '0.9rem', color: '#7f8c8d' }}>{member.member_email}</td>
|
||||||
|
<td>{member.member_city}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
backgroundColor: member.member_active ? '#d4edda' : '#f8d7da',
|
||||||
|
color: member.member_active ? '#155724' : '#721c24',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.member_active ? '✓ Active' : '✗ Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => onViewDetails(member)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#27ae60',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '0.4rem 0.8rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-muted">
|
||||||
|
Showing <strong>{filteredMembers.length}</strong> of <strong>{members.length}</strong> members
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-5 text-muted">
|
||||||
|
<p>No members found matching your search</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const Navbar: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="navbar navbar-expand-lg navbar-light border-bottom" style={{ backgroundColor: '#ffffff' }}>
|
||||||
|
<div className="container-fluid px-4">
|
||||||
|
<a className="navbar-brand fw-bold" href="/" style={{ fontSize: '1.4rem', color: '#2c3e50' }}>
|
||||||
|
<span style={{ color: '#27ae60' }}>◆</span> SUST Admin
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbarNav"
|
||||||
|
>
|
||||||
|
<span className="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div className="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul className="navbar-nav ms-auto">
|
||||||
|
<li className="nav-item">
|
||||||
|
<a className="nav-link" href="#dashboard" style={{ color: '#555', fontWeight: 500 }}>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<a className="nav-link" href="#members" style={{ color: '#555', fontWeight: 500 }}>
|
||||||
|
Members
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<a className="nav-link" href="#payments" style={{ color: '#555', fontWeight: 500 }}>
|
||||||
|
Payments
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<a className="nav-link" href="#settings" style={{ color: '#555', fontWeight: 500 }}>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div style={{ backgroundColor: '#f9c74f', padding: '0.75rem 1rem', color: '#333', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||||
|
<div className="container-fluid px-4">
|
||||||
|
Last updated: {new Date().toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<PaymentsTableProps> = ({ 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 (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="🔍 Search by member name..."
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
style={{ fontSize: '0.95rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredPayments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Payment ID</th>
|
||||||
|
<th>Member Name</th>
|
||||||
|
<th>Member ID</th>
|
||||||
|
<th>Payment Date</th>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredPayments.map((payment) => (
|
||||||
|
<tr key={payment.id}>
|
||||||
|
<td style={{ fontWeight: 600, color: '#27ae60' }}>{payment.id}</td>
|
||||||
|
<td>{getMemberName(payment.member_id)}</td>
|
||||||
|
<td>{payment.member_id}</td>
|
||||||
|
<td style={{ fontSize: '0.9rem' }}>
|
||||||
|
{new Date(payment.payment_date).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td>{payment.payment_year}</td>
|
||||||
|
<td style={{ fontWeight: 600, color: '#3498db' }}>
|
||||||
|
€{payment.payment_amount ? payment.payment_amount.toFixed(2) : '0.00'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-muted">
|
||||||
|
Showing <strong>{filteredPayments.length}</strong> of <strong>{payments.length}</strong> payments
|
||||||
|
{filteredPayments.length > 0 && (
|
||||||
|
<span className="ms-3">
|
||||||
|
• Total: <strong>€{filteredPayments.reduce((sum, p) => sum + (p.payment_amount || 0), 0).toFixed(2)}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-5 text-muted">
|
||||||
|
<p>No payments found matching your search</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
title: string
|
||||||
|
count: number
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatsCard: React.FC<StatsCardProps> = ({ title, count, icon, color }) => {
|
||||||
|
const colorMap: { [key: string]: string } = {
|
||||||
|
success: '#27ae60',
|
||||||
|
warning: '#f39c12',
|
||||||
|
danger: '#e74c3c',
|
||||||
|
primary: '#3498db',
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgColor = colorMap[color] || '#3498db'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-md-6 col-lg-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="card border-0 shadow-sm h-100"
|
||||||
|
style={{ borderLeft: `4px solid ${bgColor}`, borderRadius: '8px' }}
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted mb-2" style={{ fontSize: '0.9rem', fontWeight: 500 }}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<h3 className="mb-0" style={{ fontSize: '2.5rem', fontWeight: 600, color: '#2c3e50' }}>
|
||||||
|
{count}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '2.5rem', opacity: 0.7 }}>{icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["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/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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"}
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
{'maintainer': 'Markus Zimmerberger',
|
{'maintainer': 'Markus Zimmerberger',
|
||||||
'year': 2025,
|
'year': 2026,
|
||||||
|
'yearly_fee': 18,
|
||||||
'map_params': {'metrics': ['euclidean', 'l1', 'manhattan'],
|
'map_params': {'metrics': ['euclidean', 'l1', 'manhattan'],
|
||||||
'min_dist': [0.01, 0.1, 0.5],
|
'min_dist': [0.01, 0.1, 0.5],
|
||||||
'spread': 1.0},
|
'spread': 1.0},
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import yaml
|
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:
|
with open(config_path, encoding='utf8') as f:
|
||||||
return yaml.load(f, Loader=yaml.FullLoader)
|
return yaml.load(f, Loader=yaml.FullLoader)
|
||||||
@@ -4,8 +4,8 @@ from time import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from models import models
|
from models import models
|
||||||
from models.database import engine, SessionLocal
|
from models.database import engine, SessionLocal
|
||||||
# import sustApp.models.models as models
|
#import sustApp.models.models as models
|
||||||
# from sustApp.models.database import engine, SessionLocal
|
#from sustApp.models.database import engine, SessionLocal
|
||||||
|
|
||||||
import os, re
|
import os, re
|
||||||
from pytz import timezone
|
from pytz import timezone
|
||||||
@@ -13,11 +13,12 @@ import pandas as pd
|
|||||||
import glob
|
import glob
|
||||||
#import csv
|
#import csv
|
||||||
|
|
||||||
# def Load_Data(file_name):
|
def extract_year_from_reference(reference):
|
||||||
# with open(source_csv, encoding='utf-8-sig') as f:
|
"""Extract year from payment reference (e.g., '2025-4732-163-1' -> '2025')"""
|
||||||
# for line in f:
|
if reference:
|
||||||
# fields = line.strip().split(';')
|
year = reference.split('-')[0]
|
||||||
# print(fields)
|
return year
|
||||||
|
return None
|
||||||
|
|
||||||
def Load_Data(file_name):
|
def Load_Data(file_name):
|
||||||
df = pd.read_csv(file_name, delimiter=';', encoding='utf-8-sig', dtype=str, header=None)
|
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])
|
new_list = extract_values(i[1])
|
||||||
|
|
||||||
if len(new_list) > 0:
|
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
|
# Replace None with empty string before saving
|
||||||
outData = [['' if x is None else x for x in row] for row in outData]
|
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")
|
bak_dir = os.path.join(os.path.dirname(source_csv), "bak")
|
||||||
os.makedirs(bak_dir, exist_ok=True)
|
os.makedirs(bak_dir, exist_ok=True)
|
||||||
os.rename(source_csv, os.path.join(bak_dir, os.path.basename(source_csv)))
|
os.rename(source_csv, os.path.join(bak_dir, os.path.basename(source_csv)))
|
||||||
|
|||||||
+152
-40
@@ -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.lib.pagesizes import A4
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
from segno import helpers
|
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 datetime
|
||||||
import locale
|
import locale
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from config_loader import load_config
|
from config_loader import load_config
|
||||||
|
from salutation_helpers import get_gender_salutation, get_pronouns
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
year = cfg['year']
|
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):
|
def print_multiple_lines(lines: int, text):
|
||||||
cnt = 1
|
cnt = 1
|
||||||
@@ -25,27 +35,31 @@ def create_payment_slip(member: models.Members = None,
|
|||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, '')
|
locale.setlocale(locale.LC_ALL, '')
|
||||||
|
|
||||||
salutation = "Deine"
|
# pronouns = get_pronouns(is_plural=False)
|
||||||
salutation2 = "Dein"
|
# salutation = pronouns['possessive']
|
||||||
salutation3 = "Dich"
|
# salutation2 = pronouns['neutral']
|
||||||
salutation4 = "Solltest Du"
|
# salutation3 = pronouns['accusative']
|
||||||
salutation5 = "sende"
|
# salutation4 = pronouns['conditional']
|
||||||
|
# salutation5 = pronouns['verb']
|
||||||
|
|
||||||
subfolder = ""
|
subfolder = ""
|
||||||
|
|
||||||
w, h = A4
|
w, h = A4
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
# create epc qr-code
|
# create epc qr-code
|
||||||
if not member is None:
|
if not member is None:
|
||||||
qrcode = helpers.make_epc_qr(name='Sportunion St. Thomas',
|
qrcode = helpers.make_epc_qr(name='Sportunion St. Thomas',
|
||||||
iban='AT493443700000113407',
|
iban='AT493443700000113407',
|
||||||
amount=15*member.member_count,
|
amount=yearly_fee*member.member_count,
|
||||||
reference=f'{year}-4732-{member.id:03}-{member.member_count}')
|
reference=f'{year}-4732-{member.id:03}-{member.member_count}')
|
||||||
qrcode.save("epc_qr.png", scale=2)
|
qrcode.save("epc_qr.png", scale=2)
|
||||||
|
|
||||||
subfolder = "mail" if member.member_email else ""
|
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:
|
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
|
# Ensure the directory exists
|
||||||
os.makedirs(os.path.dirname(fullfilepath), exist_ok=True)
|
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)
|
c = canvas.Canvas(fullfilepath, pagesize=A4)
|
||||||
|
|
||||||
if not member is None:
|
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')
|
#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")
|
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=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)
|
c.setFont("Helvetica", 9)
|
||||||
text = c.beginText(60, h - 150)
|
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_street} {member.member_streetnumber}")
|
||||||
text.textLine(f"{member.member_zip} {member.member_city}")
|
text.textLine(f"{member.member_zip} {member.member_city}")
|
||||||
print_multiple_lines(5, text)
|
print_multiple_lines(5, text)
|
||||||
|
|
||||||
if member.member_count > 1:
|
if member.member_count > 1:
|
||||||
salutation = "eure"
|
pronouns = get_pronouns(is_plural=True)
|
||||||
salutation2 = "euer"
|
salutation = pronouns['possessive']
|
||||||
salutation3 = "euch"
|
salutation2 = pronouns['neutral']
|
||||||
salutation4 = "Solltet ihr"
|
salutation3 = pronouns['accusative']
|
||||||
salutation5 = "sendet"
|
salutation4 = pronouns['conditional']
|
||||||
text.textLine(f"Liebe {member.member_firstname_spouse} und lieber {member.member_firstname}!")
|
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:
|
else:
|
||||||
salutation = "Deine"
|
pronouns = get_pronouns(is_plural=False)
|
||||||
salutation2 = "Dein"
|
salutation = pronouns['possessive']
|
||||||
salutation3 = "Dich"
|
salutation2 = pronouns['neutral']
|
||||||
salutation4 = "Solltest Du"
|
salutation3 = pronouns['accusative']
|
||||||
salutation5 = "sende"
|
salutation4 = pronouns['conditional']
|
||||||
if member.member_gender == "female":
|
salutation5 = pronouns['verb']
|
||||||
salutation_gender = "liebe"
|
|
||||||
else:
|
member_salutation = get_gender_salutation(str(member.member_gender), capitalize=True)
|
||||||
salutation_gender = "lieber"
|
text.textLine(f"{member_salutation} {member.member_firstname}!")
|
||||||
text.textLine(f"{salutation_gender.capitalize()} {member.member_firstname}!")
|
|
||||||
else:
|
else:
|
||||||
print_multiple_lines(10, text)
|
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:")
|
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)
|
print_multiple_lines(2, text)
|
||||||
text.textLines("* Skigymnastik\n* Radfahren\n* Stockschiessen\n* Pilates\n* Tagesskifahrt\n* uvam")
|
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:
|
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!")
|
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:
|
else:
|
||||||
@@ -104,23 +126,29 @@ def create_payment_slip(member: models.Members = None,
|
|||||||
text.textLines(f"{salutation2.capitalize()} Sportverein-Team!")
|
text.textLines(f"{salutation2.capitalize()} Sportverein-Team!")
|
||||||
c.drawText(text)
|
c.drawText(text)
|
||||||
|
|
||||||
text = c.beginText(60, h - 500)
|
text = c.beginText(60, h - 520)
|
||||||
c.setFont("Helvetica", 7)
|
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.")
|
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)
|
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
|
# payment slip details
|
||||||
text = c.beginText(11, 75)
|
text = c.beginText(11 - offset_2mm, 75 + offset_1mm)
|
||||||
c.setFont("Helvetica", 8)
|
c.setFont("Helvetica", 8)
|
||||||
if not member is None:
|
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}")
|
text.textLine(f"Mitgliedsnummer: 4732-{member.id:03}")
|
||||||
else:
|
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")
|
text.textLine(f"Mitgliedsnummer: 4732-NEU")
|
||||||
c.drawText(text)
|
c.drawText(text)
|
||||||
|
|
||||||
text = c.beginText(188, 103)
|
text = c.beginText(188 - offset_2mm, 103 + offset_2mm)
|
||||||
c.setFont("Helvetica", 8)
|
c.setFont("Helvetica", 8)
|
||||||
if not member is None:
|
if not member is None:
|
||||||
text.textLine(f"Bei Online-Zahlung bitte die Zahlungsreferenz {year}-4732-{member.id:03}-{member.member_count} eintragen")
|
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.drawText(text)
|
||||||
|
|
||||||
c.setFont("Helvetica", 12)
|
c.setFont("Helvetica", 12)
|
||||||
c.drawString(77, 50, f"{year}")
|
c.drawString(300 - offset_2mm, 120 + offset_1mm, f"{year}") # Zahlungsanweisung: Jahr
|
||||||
c.drawString(300, 120, f"{year}")
|
c.drawString(77 - offset_2mm, 50 + offset_1mm, f"{year}") # Auftragsbestätigung: Jahr
|
||||||
|
|
||||||
c.setFont("Helvetica", 10)
|
c.setFont("Helvetica", 10)
|
||||||
c.drawString(188, h - 696, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsreferenz Zahlungsanweisung
|
c.drawString(188 - offset_2mm, h - 696 + offset_1mm, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsanweisung: Zahlungsreferenz
|
||||||
c.drawString(11, 120, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsreferenz Auftragsbestätigung
|
c.drawString(w - 30 - offset_2mm, 170 + offset_2mm, (locale.format_string('%.2f', yearly_fee * member.member_count))) # Zahlungsanweisung: Betrag
|
||||||
c.drawString(130, h - 694, (locale.format_string('%.2f', 15 * member.member_count))) # Betrag Auftragsbestätigung
|
c.drawString(11 - offset_2mm, 120 + offset_2mm, f'{year}-4732-{member.id:03}-{member.member_count}') # Auftragsbestätigung: Zahlungsreferenz
|
||||||
c.drawString(w - 30, 170, (locale.format_string('%.2f', 15 * member.member_count))) # Betrag Zahlungsanweisung
|
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()
|
c.save()
|
||||||
@@ -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}<br/>
|
||||||
|
{member.member_street} {member.member_streetnumber}<br/>
|
||||||
|
{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!<br/><br/>
|
||||||
|
{salutation2.capitalize()} Beitrag ist gut angelegt, denn der Sportverein St. Thomas bietet für jede(n) etwas:<br/>
|
||||||
|
<br/>
|
||||||
|
• Skigymnastik<br/>
|
||||||
|
• Radfahren<br/>
|
||||||
|
• Stockschiessen<br/>
|
||||||
|
• Pilates<br/>
|
||||||
|
• Tagesskifahrt<br/>
|
||||||
|
• 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"""
|
||||||
|
<b>Beitrag gesamt:</b> {amount}<br/>
|
||||||
|
<b>Mitgliedsnummer:</b> {member_number}<br/>
|
||||||
|
<b>Zahlungsreferenz:</b> {payment_reference}<br/>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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}")
|
||||||
@@ -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
|
||||||
@@ -25,12 +25,12 @@ update members set member_city = 'Sankt Thomas' where member_city = 'St. Thomas'
|
|||||||
update members set member_city = trim(member_city)
|
update members set member_city = trim(member_city)
|
||||||
|
|
||||||
insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year)
|
insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year)
|
||||||
values (now(), 204, 186, '2025-01-01') --,
|
values (now(), 126, 186, '2026-01-01'),
|
||||||
(now(), 79, 186, '2024-01-01'),
|
(now(), 176, 186, '2026-01-01'),
|
||||||
(now(), 3, 186, '2024-01-01'),
|
(now(), 4, 186, '2026-01-01'),
|
||||||
(now(), 4, 186, '2024-01-01'),
|
(now(), 128, 186, '2026-01-01'),
|
||||||
(now(), 9, 186, '2024-01-01')
|
(now(), 18, 186, '2026-01-01')
|
||||||
|
|
||||||
insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year)
|
insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year)
|
||||||
values (now(), 8, 186, '2024-02-21')
|
values (now(), 8, 186, '2024-02-21')
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ WHERE m.id IN (
|
|||||||
EXCEPT
|
EXCEPT
|
||||||
SELECT mp.id
|
SELECT mp.id
|
||||||
FROM members_paid mp
|
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;
|
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
|
AND mp.id IS NOT NULL
|
||||||
)
|
)
|
||||||
ORDER BY m.member_surname, m.member_firstname;
|
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()
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
# Importing libraries
|
# Importing libraries
|
||||||
from numpy import genfromtxt
|
#from numpy import genfromtxt
|
||||||
|
import pandas as pd
|
||||||
from time import time
|
from time import time
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from models import models
|
from models import models
|
||||||
from models.database import engine, SessionLocal
|
from models.database import engine, SessionLocal
|
||||||
|
|
||||||
import os, glob
|
import os, glob
|
||||||
from pytz import timezone
|
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)})
|
#data = genfromtxt(file_name, delimiter=';', skip_header=1, converters={0: lambda s: str(s)})
|
||||||
#str2date = lambda x: datetime.strptime(x, '%d-%m-%y')
|
#str2date = lambda x: datetime.strptime(x, '%d-%m-%y')
|
||||||
data = genfromtxt(file_name, delimiter=';', dtype=None, skip_header=1, encoding='utf-8') #
|
data = genfromtxt(file_name, delimiter=';', dtype=None, skip_header=1, encoding='utf-8') #
|
||||||
return data.tolist()
|
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__":
|
if __name__ == "__main__":
|
||||||
t = time()
|
t = time()
|
||||||
@@ -25,24 +32,31 @@ if __name__ == "__main__":
|
|||||||
source_path = os.path.join(home_directory, "OneDrive", "Dokumente", "SUST", "import_csv", "*_out.csv")
|
source_path = os.path.join(home_directory, "OneDrive", "Dokumente", "SUST", "import_csv", "*_out.csv")
|
||||||
for source_csv in glob.glob(source_path):
|
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")
|
#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
|
tz = timezone('UTC') # Replace with your desired timezone
|
||||||
dt = datetime.strptime(data[8], '%d.%m.%Y %H:%M:%S:%f') # type: ignore
|
for data in datas:
|
||||||
ts_timestamp = tz.localize(dt)
|
dt = datetime.strptime(data[8], '%d.%m.%Y %H:%M:%S:%f') # type: ignore
|
||||||
|
ts_timestamp = tz.localize(dt)
|
||||||
|
|
||||||
record = models.Payments_CSV(**{
|
# Parse year - data[9] is now the year (from convert_payment_export_csv.py)
|
||||||
'pc_valuta_date' : datetime.strptime(data[0], '%d.%m.%Y').date(),
|
# Convert year string to a valid date (January 1st of that year)
|
||||||
'pc_client' : data[1],
|
year_value = data[9] # This is '2025' from the CSV
|
||||||
'pc_reference' : data[2],
|
pc_year = date(int(year_value), 1, 1) # Convert to date: 2025-01-01
|
||||||
'pc_intended' : data[3],
|
|
||||||
'pc_iban' : data[4],
|
#TODO: Check if amout is valid!
|
||||||
'pc_entry_date' : datetime.strptime(data[5], '%d.%m.%Y').date(),
|
record = models.Payments_CSV(**{
|
||||||
'pc_amount' : float(data[6]),
|
'pc_valuta_date' : datetime.strptime(data[0], '%d.%m.%Y').date(),
|
||||||
'pc_currency' : data[7],
|
'pc_client' : data[1],
|
||||||
'pc_timestamp' : ts_timestamp
|
'pc_reference' : data[2],
|
||||||
})
|
'pc_intended' : data[3],
|
||||||
db.add(record) #Add all the records
|
'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:
|
try:
|
||||||
db.commit() #Attempt to commit all the records
|
db.commit() #Attempt to commit all the records
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import extract
|
from sqlalchemy import extract, or_
|
||||||
from models.database import engine, SessionLocal
|
from models.database import engine, SessionLocal
|
||||||
import models.models as models
|
import models.models as models
|
||||||
import re
|
import re
|
||||||
@@ -112,7 +112,7 @@ def save_to_postgres(first_name: str, sur_name: str, address: str):
|
|||||||
# find by name
|
# find by name
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
member = db.query(models.Members).filter(
|
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_firstname == first_name,
|
||||||
models.Members.member_surname == sur_name,
|
models.Members.member_surname == sur_name,
|
||||||
).first()
|
).first()
|
||||||
@@ -164,7 +164,6 @@ Dein lokaler Sportverein KI-Assistent
|
|||||||
logging.info(f"Bestätigung gesendet an {sender_email}")
|
logging.info(f"Bestätigung gesendet an {sender_email}")
|
||||||
|
|
||||||
#TODO: In DB markieren als bestätigt
|
#TODO: In DB markieren als bestätigt
|
||||||
#TODO: Re: Mail als gelesen markieren
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -176,7 +175,7 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
mail = connect_imap()
|
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()
|
mail_ids = messages[0].split()
|
||||||
|
|
||||||
for mail_id in mail_ids: # [-5:] letzte 5 neue
|
for mail_id in mail_ids: # [-5:] letzte 5 neue
|
||||||
@@ -215,6 +214,8 @@ def main():
|
|||||||
mail.store(mail_id, '+FLAGS', '\\Seen')
|
mail.store(mail_id, '+FLAGS', '\\Seen')
|
||||||
mail.select('INBOX', readonly=True)
|
mail.select('INBOX', readonly=True)
|
||||||
|
|
||||||
|
# TODO: Move thread to SUST-Folder
|
||||||
|
|
||||||
# Send confirmation reply
|
# Send confirmation reply
|
||||||
msg_id = msg['Message-ID']
|
msg_id = msg['Message-ID']
|
||||||
subject = msg['Subject'] or ''
|
subject = msg['Subject'] or ''
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy import extract
|
from sqlalchemy import extract
|
||||||
from sustApp.models.database import engine, SessionLocal
|
from models.database import engine, SessionLocal
|
||||||
import sustApp.models.models as models
|
import models.models as models
|
||||||
import logging, sys
|
import logging, sys
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
@@ -11,10 +11,17 @@ import datetime
|
|||||||
from create_payment_slip import create_payment_slip
|
from create_payment_slip import create_payment_slip
|
||||||
import os
|
import os
|
||||||
from config_loader import load_config
|
from config_loader import load_config
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from salutation_helpers import get_gender_salutation, get_pronouns
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
year = cfg['year']
|
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)
|
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
|
||||||
|
|
||||||
@@ -28,14 +35,14 @@ def payment_slip_paid(member: models.Members, year: int):
|
|||||||
.count()
|
.count()
|
||||||
|
|
||||||
|
|
||||||
def send_mail(member: models.Members):
|
def send_mail(member: models.Members, force_resend: bool = False):
|
||||||
paid = payment_slip_paid(member, year)
|
paid = payment_slip_paid(member, year)
|
||||||
if paid:
|
if paid:
|
||||||
logging.debug(f'Member: {member.member_surname} already paid!')
|
logging.debug(f'Member: {member.member_surname} already paid!')
|
||||||
return
|
return
|
||||||
|
|
||||||
delivered = payment_slip_delivered(member, year)
|
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!')
|
logging.debug(f'Member: {member.member_surname} already mailed or delivered!')
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -43,12 +50,29 @@ def send_mail(member: models.Members):
|
|||||||
smtp_port = 587
|
smtp_port = 587
|
||||||
smtp_username = 'zisco@gmx.at'
|
smtp_username = 'zisco@gmx.at'
|
||||||
#TODO Secure
|
#TODO Secure
|
||||||
smtp_password = '57spunk07'
|
smtp_password = '[Jy010]Q&7te2qR&2x0d'
|
||||||
|
|
||||||
from_email = 'sv.stthomas@gmx.at'
|
from_email = 'sv.stthomas@gmx.at'
|
||||||
to_email = member.member_email
|
to_email = member.member_email
|
||||||
subject = f'Mitgliedsbeitrag {year}'
|
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 = MIMEMultipart()
|
||||||
msg['From'] = from_email
|
msg['From'] = from_email
|
||||||
@@ -56,7 +80,7 @@ def send_mail(member: models.Members):
|
|||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg.attach(MIMEText(body))
|
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):
|
if not exists(path_to_file):
|
||||||
create_payment_slip(member=member,
|
create_payment_slip(member=member,
|
||||||
forMail=True)
|
forMail=True)
|
||||||
@@ -69,7 +93,10 @@ def send_mail(member: models.Members):
|
|||||||
with smtplib.SMTP(smtp_server, smtp_port) as smtp:
|
with smtplib.SMTP(smtp_server, smtp_port) as smtp:
|
||||||
smtp.starttls()
|
smtp.starttls()
|
||||||
smtp.login(smtp_username, smtp_password)
|
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(),
|
newDelivery = models.Deliveries(delivery_date = datetime.datetime.now(),
|
||||||
member_id = member.id,
|
member_id = member.id,
|
||||||
@@ -85,15 +112,16 @@ db = SessionLocal()
|
|||||||
models.Base.metadata.create_all(bind=engine)
|
models.Base.metadata.create_all(bind=engine)
|
||||||
members = db.query(models.Members).filter(
|
members = db.query(models.Members).filter(
|
||||||
models.Members.member_active == True,
|
models.Members.member_active == True,
|
||||||
models.Members.member_email != None
|
models.Members.member_email != None,
|
||||||
|
models.Members.member_primary == True
|
||||||
).all()
|
).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_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.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_([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:
|
for member in members:
|
||||||
if member.member_email:
|
if member.member_email:
|
||||||
send_mail(member=member)
|
send_mail(member=member, force_resend=args.resend)
|
||||||
else:
|
else:
|
||||||
logging.debug(f'Member {member.member_surname} has no email associated!')
|
logging.debug(f'Member {member.member_surname} has no email associated!')
|
||||||
+116
-15
@@ -1,12 +1,16 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Depends, WebSocket
|
from fastapi import FastAPI, HTTPException, Depends, WebSocket
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
from typing import List, Annotated, Optional
|
from typing import List, Annotated, Optional, Union
|
||||||
from sustApp.models.models import Base, Members, Payments
|
from sustApp.models.models import Base, Members, Payments
|
||||||
from sustApp.models.database import engine, SessionLocal
|
from sustApp.models.database import engine, SessionLocal
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
# from create_payment_report import create_payment_report
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ origins = [
|
|||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
]
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -91,10 +96,12 @@ class User(BaseModel):
|
|||||||
|
|
||||||
class DeliveryBase(BaseModel):
|
class DeliveryBase(BaseModel):
|
||||||
delivery_date: datetime
|
delivery_date: datetime
|
||||||
delivered_by: int
|
delivered_by_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class PaymentBase(BaseModel):
|
class PaymentBase(BaseModel):
|
||||||
payment_date: datetime
|
payment_date: datetime | None = None
|
||||||
|
payment_year: datetime | None = None
|
||||||
|
|
||||||
class MemberBase(BaseModel):
|
class MemberBase(BaseModel):
|
||||||
member_count: int
|
member_count: int
|
||||||
@@ -105,19 +112,31 @@ class MemberBase(BaseModel):
|
|||||||
member_street: str
|
member_street: str
|
||||||
member_streetnumber: str | None = None
|
member_streetnumber: str | None = None
|
||||||
member_email: 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_city: str
|
||||||
member_active: bool
|
member_active: bool | None = None
|
||||||
member_gender: str | None = None
|
member_gender: str | None = None
|
||||||
member_connected: int | None = None
|
member_connected: int | None = None
|
||||||
member_printed: bool
|
|
||||||
#member_coordinates:
|
#member_coordinates:
|
||||||
#payments: Optional[List[PaymentBase]]
|
#payments: Optional[List[PaymentBase]]
|
||||||
member_deliveries: Optional[List[DeliveryBase]]
|
member_deliveries: Optional[List[DeliveryBase]] = []
|
||||||
#deliveries: Mapped[List["DeliveryBase"]] = relationship()
|
member_payments: Optional[List[PaymentBase]] = []
|
||||||
current_state_code: str | None = None
|
current_state_code: str | None = None
|
||||||
current_state_name: 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():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -128,17 +147,84 @@ def get_db():
|
|||||||
db_dependency = Annotated[Session, Depends(get_db)]
|
db_dependency = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
@app.get("/members/", response_model=List[MemberBase])
|
@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 ):
|
async def read_members(db: db_dependency, skip: int = 0, limit: int = 100 ):
|
||||||
members = db.query(Members).offset(skip).limit(limit).all()
|
members = db.query(Members).offset(skip).limit(limit).all()
|
||||||
if not members:
|
if not members:
|
||||||
raise HTTPException(status_code=400, detail='No members')
|
raise HTTPException(status_code=400, detail='No members')
|
||||||
return members
|
return members """
|
||||||
|
|
||||||
@app.get("/activememberscount/", response_model=int)
|
@app.get("/activememberscount/", response_model=int)
|
||||||
async def read_activememberscount(db: db_dependency):
|
async def read_activememberscount(db: db_dependency):
|
||||||
count = db.query(Members).filter(Members.member_active == True).count()
|
count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'A', Members.member_primary == True).scalar()
|
||||||
logger.info("This is a test log message from backend!")
|
return count or 0
|
||||||
return count
|
|
||||||
|
@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}")
|
@app.get("/member/{member_id}")
|
||||||
async def read_member(member_id: int, db: db_dependency): #, token: Annotated[str, Depends(oauth2_scheme)]):
|
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
|
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}")
|
@app.get("/payments/{member_id}")
|
||||||
async def read_payments(member_id: int, db: db_dependency):
|
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:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail=f'No payments found for member {member_id}')
|
raise HTTPException(status_code=400, detail=f'No payments found for member {member_id}')
|
||||||
return result
|
return result
|
||||||
@@ -191,4 +284,12 @@ async def create_members(members: List[MemberBase], db: db_dependency):
|
|||||||
db_payment = Payments(payment_date=payment.payment_date,
|
db_payment = Payments(payment_date=payment.payment_date,
|
||||||
member_id=db_member.id)
|
member_id=db_member.id)
|
||||||
db.add(db_payment)
|
db.add(db_payment)
|
||||||
db.commit()
|
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))
|
||||||
@@ -2,7 +2,7 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
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)
|
engine = create_engine(URL_DATABASE)
|
||||||
|
|
||||||
|
|||||||
+50
-13
@@ -3,13 +3,14 @@ from sqlalchemy.types import TypeDecorator, VARCHAR, Integer, CHAR
|
|||||||
from sqlalchemy_utils import EmailType
|
from sqlalchemy_utils import EmailType
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
#from models.database import Base
|
#from models.database import Base
|
||||||
from sustApp.models.database import Base
|
from .database import Base
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
class TrimChar(TypeDecorator):
|
class TrimChar(TypeDecorator):
|
||||||
impl = CHAR
|
impl = CHAR
|
||||||
cache_key = False
|
cache_key = False
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
def process_result_value(self, value, dialect):
|
def process_result_value(self, value, dialect):
|
||||||
if value:
|
if value:
|
||||||
@@ -59,20 +60,27 @@ class Members(Base):
|
|||||||
member_geo_longitude = Column(Float, nullable=True)
|
member_geo_longitude = Column(Float, nullable=True)
|
||||||
member_deliveries = relationship('Deliveries', backref='members')
|
member_deliveries = relationship('Deliveries', backref='members')
|
||||||
member_payments = relationship('Payments', 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
|
spouse_id = Column(Integer, ForeignKey('members.id'), nullable=True)
|
||||||
current_state_changed_at = Column(DateTime)
|
spouse = relationship(
|
||||||
|
"Members",
|
||||||
# Use primaryjoin to specify the join condition
|
remote_side=[id],
|
||||||
current_state = relationship(
|
foreign_keys=[spouse_id],
|
||||||
'MemberStates',
|
backref="spouse_of",
|
||||||
primaryjoin='Members.current_state_code == MemberStates.state_code',
|
single_parent=True
|
||||||
foreign_keys=[current_state_code]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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):
|
def __repr__(self):
|
||||||
return f'<Comment "{self.content[:20]}...">'
|
return f'<Comment "{self.content[:20]}...">'
|
||||||
|
|
||||||
@@ -118,6 +126,7 @@ class Payments_CSV(Base):
|
|||||||
pc_currency = Column(String)
|
pc_currency = Column(String)
|
||||||
pc_timestamp = Column(DateTime)
|
pc_timestamp = Column(DateTime)
|
||||||
pc_processed = Column(Boolean, default=False)
|
pc_processed = Column(Boolean, default=False)
|
||||||
|
pc_year = Column(DateTime)
|
||||||
|
|
||||||
class MemberStates(Base):
|
class MemberStates(Base):
|
||||||
__tablename__ = 'member_states'
|
__tablename__ = 'member_states'
|
||||||
@@ -126,6 +135,7 @@ class MemberStates(Base):
|
|||||||
state_name = Column(String(20), nullable=False)
|
state_name = Column(String(20), nullable=False)
|
||||||
description = Column(String)
|
description = Column(String)
|
||||||
created_at = Column(DateTime)
|
created_at = Column(DateTime)
|
||||||
|
# members = relationship("Members", back_populates="current_state")
|
||||||
|
|
||||||
class MemberStateLog(Base):
|
class MemberStateLog(Base):
|
||||||
__tablename__ = 'member_state_log'
|
__tablename__ = 'member_state_log'
|
||||||
@@ -139,4 +149,31 @@ class MemberStateLog(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
member = relationship('Members', backref='state_history')
|
member = relationship('Members', backref='state_history')
|
||||||
state = relationship('MemberStates')
|
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)
|
||||||
@@ -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()
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from create_payment_slip import create_payment_slip
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import sustApp.models.models as models
|
import models.models as models
|
||||||
from reportlab.lib.utils import ImageReader
|
from reportlab.lib.utils import ImageReader
|
||||||
import logging, sys
|
import logging, sys
|
||||||
from sqlalchemy import extract
|
from sqlalchemy import extract
|
||||||
@@ -17,15 +17,17 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
models.Base.metadata.create_all(bind=engine)
|
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(
|
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_email == None,
|
||||||
|
models.Members.member_primary == True
|
||||||
).all()
|
).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_([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_([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
|
# TOOD: Add column payment_date to Payments
|
||||||
# create only, if not already delivered/paid
|
# create only, if not already delivered/paid
|
||||||
paid = db.query(models.Payments.payment_year)\
|
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)\
|
.filter(models.Deliveries.member_id == member.id, extract('year', models.Deliveries.delivery_date) == year)\
|
||||||
.count()
|
.count()
|
||||||
if not delivered:
|
if not delivered:
|
||||||
#create_payment_slip(member=member)
|
create_payment_slip(member=member)
|
||||||
logging.debug(f'Member: {member.member_surname}')
|
logging.debug(f'Member: {member.member_surname}')
|
||||||
#logging.info('We processed %d records', len(processed_records))
|
#logging.info('We processed %d records', len(processed_records))
|
||||||
else:
|
else:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 848 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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<Member[]>([]);
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
total_members: 0,
|
||||||
|
active_members: 0,
|
||||||
|
total_payments: 0,
|
||||||
|
total_amount: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 = () => (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<StatCard
|
||||||
|
title="Total Members"
|
||||||
|
value={stats.total_members}
|
||||||
|
icon={<Users className="w-8 h-8" />}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Members"
|
||||||
|
value={stats.active_members}
|
||||||
|
icon={<Users className="w-8 h-8" />}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Payments"
|
||||||
|
value={stats.total_payments}
|
||||||
|
icon={<CreditCard className="w-8 h-8" />}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Amount"
|
||||||
|
value={`€${stats.total_amount.toFixed(2)}`}
|
||||||
|
icon={<BarChart3 className="w-8 h-8" />}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MembersView = () => (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Members List</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{member.member_firstname} {member.member_surname}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{member.member_email}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
|
member.member_active
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{member.member_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{member.member_count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PaymentsView = () => (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Payments List</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Member ID</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Year</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<tr key={payment.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{payment.member_id}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(payment.payment_date).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{payment.payment_amount.toFixed(2)}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{payment.payment_year}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsView = () => (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Settings</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">API Server</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiUrl}
|
||||||
|
disabled
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchDashboardData}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Refresh Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-gray-800 border-b border-gray-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<BarChart3 className="w-8 h-8 text-blue-500" />
|
||||||
|
<h1 className="text-2xl font-bold text-white">SUST Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<button className="inline-flex items-center px-3 py-2 border border-gray-600 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-700">
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-900 border border-red-700 rounded-lg text-red-100">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex space-x-4 mb-8 border-b border-gray-700">
|
||||||
|
<TabButton
|
||||||
|
label="Dashboard"
|
||||||
|
icon={<BarChart3 className="w-4 h-4" />}
|
||||||
|
isActive={activeTab === 'dashboard'}
|
||||||
|
onClick={() => setActiveTab('dashboard')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Members"
|
||||||
|
icon={<Users className="w-4 h-4" />}
|
||||||
|
isActive={activeTab === 'members'}
|
||||||
|
onClick={() => setActiveTab('members')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Payments"
|
||||||
|
icon={<CreditCard className="w-4 h-4" />}
|
||||||
|
isActive={activeTab === 'payments'}
|
||||||
|
onClick={() => setActiveTab('payments')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Settings"
|
||||||
|
icon={<Settings className="w-4 h-4" />}
|
||||||
|
isActive={activeTab === 'settings'}
|
||||||
|
onClick={() => setActiveTab('settings')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<div className="text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
{activeTab === 'dashboard' && <DashboardView />}
|
||||||
|
{activeTab === 'members' && <MembersView />}
|
||||||
|
{activeTab === 'payments' && <PaymentsView />}
|
||||||
|
{activeTab === 'settings' && <SettingsView />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: 'blue' | 'green' | 'purple' | 'orange';
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, color }) => {
|
||||||
|
const colorClasses = {
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 text-sm font-medium">{title}</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mt-2">{value}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`${colorClasses[color]} p-3 rounded-full text-white`}>{icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabButton: React.FC<TabButtonProps> = ({ label, icon, isActive, onClick }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center space-x-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-blue-500 text-blue-500'
|
||||||
|
: 'border-transparent text-gray-400 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FastAPIFrontend;
|
||||||
@@ -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 = () => (
|
||||||
|
<div className="flex items-center space-x-2 mb-6">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`}></div>
|
||||||
|
<span className={`text-sm font-medium ${isConnected ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">•</span>
|
||||||
|
<span className="text-sm text-gray-600">{apiUrl}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Dashboard = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-100">API Status</p>
|
||||||
|
<p className="text-2xl font-bold">{isConnected ? 'Online' : 'Offline'}</p>
|
||||||
|
</div>
|
||||||
|
<Server className="w-8 h-8 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-green-100">Active Members</p>
|
||||||
|
<p className="text-2xl font-bold">{activeMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-green-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-yellow-500 to-yellow-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-yellow-100">Inactive Members</p>
|
||||||
|
<p className="text-2xl font-bold">{inactiveMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-yellow-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-red-500 to-red-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-red-100">Deactivated Members</p>
|
||||||
|
<p className="text-2xl font-bold">{deactivatedMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-red-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-purple-500 to-purple-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-purple-100">Total Members</p>
|
||||||
|
<p className="text-2xl font-bold">{activeMembersCount + inactiveMembersCount + deactivatedMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-purple-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-orange-500 to-orange-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-orange-100">Activity</p>
|
||||||
|
<p className="text-2xl font-bold">Live</p>
|
||||||
|
</div>
|
||||||
|
<Activity className="w-8 h-8 text-orange-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">Member Statistics</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{activeMembersCount}</div>
|
||||||
|
<div className="text-sm text-green-700">Active Members</div>
|
||||||
|
<div className="text-xs text-green-600 mt-1">
|
||||||
|
{activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0
|
||||||
|
? `${Math.round((activeMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%`
|
||||||
|
: '0%'
|
||||||
|
} of total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||||
|
<div className="text-2xl font-bold text-yellow-600">{inactiveMembersCount}</div>
|
||||||
|
<div className="text-sm text-yellow-700">Inactive Members</div>
|
||||||
|
<div className="text-xs text-yellow-600 mt-1">
|
||||||
|
{activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0
|
||||||
|
? `${Math.round((inactiveMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%`
|
||||||
|
: '0%'
|
||||||
|
} of total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{deactivatedMembersCount}</div>
|
||||||
|
<div className="text-sm text-red-700">Deactivated Members</div>
|
||||||
|
<div className="text-xs text-red-600 mt-1">
|
||||||
|
{activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0
|
||||||
|
? `${Math.round((deactivatedMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%`
|
||||||
|
: '0%'
|
||||||
|
} of total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('data')}
|
||||||
|
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Database className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-gray-600">View All Members</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchMemberCounts}
|
||||||
|
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-gray-600">Refresh Counts</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={testConnection}
|
||||||
|
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Activity className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-gray-600">Test Connection</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DataView = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">Data Management</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({});
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Add New</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-blue-600" />
|
||||||
|
<span className="ml-2 text-gray-600">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="4" className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No data available. Add some items to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.map((item, index) => (
|
||||||
|
<tr key={item.id || index} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{item.id || index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{item.name || item.title || 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
|
||||||
|
{item.description || item.content || 'No description'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(item)}
|
||||||
|
className="text-blue-600 hover:text-blue-900 p-1 rounded"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteItem(item.id)}
|
||||||
|
className="text-red-600 hover:text-red-900 p-1 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Modal = () => (
|
||||||
|
showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
{editingItem ? 'Edit Item' : 'Add New Item'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={(e) => setFormData({...formData, description: 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"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Enter description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
setFormData({});
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{editingItem ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<div className="bg-white shadow-sm border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">FastAPI Dashboard</h1>
|
||||||
|
<p className="text-sm text-gray-600">Manage your backend data and monitor API status</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiUrl}
|
||||||
|
onChange={(e) => setApiUrl(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="API URL"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Test</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('dashboard')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'dashboard'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('data')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'data'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<ConnectionStatus />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 flex items-center space-x-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
<span className="text-red-700">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'dashboard' ? <Dashboard /> : <DataView />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FastAPIFrontend;
|
||||||
@@ -0,0 +1,835 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Database,
|
||||||
|
Users,
|
||||||
|
Activity,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
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 <div>Your React App</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [activeMembersCount, setActiveMembersCount] = useState(0);
|
||||||
|
const [inactiveMembersCount, setInactiveMembersCount] = useState(0);
|
||||||
|
const [deactivatedMembersCount, setDeactivatedMembersCount] = useState(0);
|
||||||
|
const [loadingData, setLoadingData] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch member counts
|
||||||
|
const fetchMemberCounts = async () => {
|
||||||
|
if (connectionStatus !== 'connected' && !isLoading) 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);
|
||||||
|
console.log('activememberscount: ', 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 (connectionStatus !== 'connected' && !isLoading) return;
|
||||||
|
|
||||||
|
setLoadingData(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 {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionStatus === 'connected') {
|
||||||
|
// loadMembersCount();
|
||||||
|
fetchMemberCounts();
|
||||||
|
if (activeTab === 'data') {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [connectionStatus]);
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected': return '#10b981';
|
||||||
|
case 'error': return '#ef4444';
|
||||||
|
default: return '#f59e0b';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected': return <CheckCircle size={16} />;
|
||||||
|
case 'error': return <XCircle size={16} />;
|
||||||
|
default: return <AlertCircle size={16} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected': return 'Connected';
|
||||||
|
case 'error': return 'Connection Error';
|
||||||
|
default: return 'Disconnected';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<div style={styles.headerContent}>
|
||||||
|
<div>
|
||||||
|
<h1 style={styles.title}>FastAPI Dashboard</h1>
|
||||||
|
<p style={styles.subtitle}>Manage your backend data and monitor API status</p>
|
||||||
|
</div>
|
||||||
|
<div style={styles.statusContainer}>
|
||||||
|
{getStatusIcon()}
|
||||||
|
<span>{getStatusText()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div style={styles.mainContent}>
|
||||||
|
{/* Connection Section */}
|
||||||
|
<div style={styles.card}>
|
||||||
|
<h2 style={styles.cardTitle}>API Connection</h2>
|
||||||
|
<div style={styles.connectionForm}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiUrl}
|
||||||
|
onChange={(e) => setApiUrl(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Enter API URL"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
...styles.button,
|
||||||
|
...(isLoading ? styles.buttonDisabled : {}),
|
||||||
|
':hover': { backgroundColor: '#1d4ed8' }
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isLoading) e.target.style.backgroundColor = '#1d4ed8';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} style={isLoading ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{connectionStatus === 'error' && (
|
||||||
|
<div style={styles.errorAlert}>
|
||||||
|
<XCircle size={20} style={{ color: '#dc2626' }} />
|
||||||
|
<span style={styles.errorText}>NetworkError when attempting to fetch resource.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div style={styles.tabs}>
|
||||||
|
<div style={styles.tabsNav}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('dashboard')}
|
||||||
|
style={{
|
||||||
|
...styles.tab,
|
||||||
|
...(activeTab === 'dashboard' ? styles.tabActive : styles.tabInactive)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('data')}
|
||||||
|
style={{
|
||||||
|
...styles.tab,
|
||||||
|
...(activeTab === 'data' ? styles.tabActive : styles.tabInactive)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div style={styles.statsGrid}>
|
||||||
|
{/* API Status */}
|
||||||
|
<div style={styles.statCard}>
|
||||||
|
<div style={styles.statContent}>
|
||||||
|
<p style={styles.statLabel}>API Status</p>
|
||||||
|
<p style={styles.statValue}>
|
||||||
|
{connectionStatus === 'connected' ? 'Online' : 'Offline'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{...styles.statIcon, ...styles.iconOrange}}>
|
||||||
|
<Server size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Users */}
|
||||||
|
<div style={styles.statCard}>
|
||||||
|
<div style={styles.statContent}>
|
||||||
|
<p style={styles.statLabel}>Active Users</p>
|
||||||
|
<p style={styles.statValue}>1</p>
|
||||||
|
</div>
|
||||||
|
<div style={{...styles.statIcon, ...styles.iconGreen}}>
|
||||||
|
<Users size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity */}
|
||||||
|
<div style={styles.statCard}>
|
||||||
|
<div style={styles.statContent}>
|
||||||
|
<p style={styles.statLabel}>Activity</p>
|
||||||
|
<p style={styles.statValue}>Live</p>
|
||||||
|
</div>
|
||||||
|
<div style={{...styles.statIcon, ...styles.iconPurple}}>
|
||||||
|
<Activity size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-green-100">Active Members</p>
|
||||||
|
<p className="text-2xl font-bold">{activeMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-green-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-yellow-500 to-yellow-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-yellow-100">Inactive Members</p>
|
||||||
|
<p className="text-2xl font-bold">{inactiveMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-yellow-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-red-500 to-red-600 p-6 rounded-xl text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-red-100">Deactivated Members</p>
|
||||||
|
<p className="text-2xl font-bold">{deactivatedMembersCount}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-red-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div style={styles.card}>
|
||||||
|
<h2 style={styles.cardTitle}>Quick Actions</h2>
|
||||||
|
<div style={styles.actionButtons}>
|
||||||
|
<button
|
||||||
|
style={{...styles.actionButton, ...styles.actionButtonGray}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#e5e7eb'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#f3f4f6'}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
View Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{...styles.actionButton, ...styles.actionButtonGreen}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#bbf7d0'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#dcfce7'}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{...styles.actionButton, ...styles.actionButtonBlue}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#bfdbfe'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#dbeafe'}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content based on active tab */}
|
||||||
|
{activeTab === 'data' && (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.cardHeader}>
|
||||||
|
<h2 style={styles.cardTitle}>Data Management</h2>
|
||||||
|
<button
|
||||||
|
onClick={loadMembers}
|
||||||
|
style={styles.refreshButton}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} style={{ marginRight: '8px' }} />
|
||||||
|
{isLoading ? 'Loading...' : 'Load Members'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={styles.loadingState}>
|
||||||
|
<RefreshCw size={24} style={{ ...styles.loadingIcon, animation: 'spin 1s linear infinite' }} />
|
||||||
|
<p>Loading members...</p>
|
||||||
|
</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<div style={styles.emptyState}>
|
||||||
|
<Database size={48} style={styles.emptyStateIcon} />
|
||||||
|
<p style={styles.emptyStateText}>No members found. Click "Load Members" to fetch data.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={styles.dataContainer}>
|
||||||
|
<div style={styles.dataHeader}>
|
||||||
|
<p style={styles.dataCount}>Total Members: {members.length}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.tableContainer}>
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr style={styles.tableHeaderRow}>
|
||||||
|
<th style={styles.tableHeader}>ID</th>
|
||||||
|
<th style={styles.tableHeader}>Name</th>
|
||||||
|
<th style={styles.tableHeader}>Email</th>
|
||||||
|
<th style={styles.tableHeader}>Active</th>
|
||||||
|
<th style={styles.tableHeader}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((member, index) => (
|
||||||
|
<tr key={member.id || index} style={styles.tableRow}>
|
||||||
|
<td style={styles.tableCell}>{member.id}</td>
|
||||||
|
<td style={styles.tableCell}>{getFullName(member)}</td>
|
||||||
|
<td style={styles.tableCell}>{member.member_email || 'N/A'}</td>
|
||||||
|
<td style={styles.tableCell}>
|
||||||
|
<span style={{
|
||||||
|
...styles.statusBadge,
|
||||||
|
backgroundColor: getMemberStateColor(member.current_state.state_code),
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
{member.current_state.state_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={styles.tableCell}>
|
||||||
|
<button style={styles.actionButton}>
|
||||||
|
<Edit3 size={14} />
|
||||||
|
</button>
|
||||||
|
<button style={styles.actionButton}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,762 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Save, X, User, Calendar, Phone, Mail, MapPin, Users, Edit, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
// Member Edit Form Component
|
||||||
|
const MemberEditForm = ({ member = null, onSave, onCancel, isOpen }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
id: '',
|
||||||
|
member_count: 1,
|
||||||
|
member_firstname: '',
|
||||||
|
member_firstname_spouse: '',
|
||||||
|
member_surname: '',
|
||||||
|
member_suffix: '',
|
||||||
|
member_street: '',
|
||||||
|
member_streetnumber: '',
|
||||||
|
member_email: '',
|
||||||
|
member_zip: '',
|
||||||
|
member_city: '',
|
||||||
|
member_gender: '',
|
||||||
|
member_died: '',
|
||||||
|
member_membership_start: '',
|
||||||
|
member_phone: '',
|
||||||
|
current_state_code: '',
|
||||||
|
current_state_changed_at: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const genderOptions = [
|
||||||
|
{ value: '', label: 'Select Gender' },
|
||||||
|
{ value: 'M', label: 'Male' },
|
||||||
|
{ value: 'F', label: 'Female' },
|
||||||
|
{ value: 'O', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateCodes = [
|
||||||
|
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
||||||
|
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
||||||
|
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
||||||
|
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
||||||
|
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (member) {
|
||||||
|
setFormData({
|
||||||
|
id: member.id || '',
|
||||||
|
member_count: member.member_count || 1,
|
||||||
|
member_firstname: member.member_firstname || '',
|
||||||
|
member_firstname_spouse: member.member_firstname_spouse || '',
|
||||||
|
member_surname: member.member_surname || '',
|
||||||
|
member_suffix: member.member_suffix || '',
|
||||||
|
member_street: member.member_street || '',
|
||||||
|
member_streetnumber: member.member_streetnumber || '',
|
||||||
|
member_email: member.member_email || '',
|
||||||
|
member_zip: member.member_zip || '',
|
||||||
|
member_city: member.member_city || '',
|
||||||
|
member_gender: member.member_gender || '',
|
||||||
|
member_died: member.member_died ? new Date(member.member_died).toISOString().split('T')[0] : '',
|
||||||
|
member_membership_start: member.member_membership_start ? new Date(member.member_membership_start).toISOString().split('T')[0] : '',
|
||||||
|
member_phone: member.member_phone || '',
|
||||||
|
current_state_code: member.current_state_code || '',
|
||||||
|
current_state_changed_at: member.current_state_changed_at ? new Date(member.current_state_changed_at).toISOString().slice(0, 16) : ''
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Reset form for new member
|
||||||
|
setFormData({
|
||||||
|
id: '',
|
||||||
|
member_count: 1,
|
||||||
|
member_firstname: '',
|
||||||
|
member_firstname_spouse: '',
|
||||||
|
member_surname: '',
|
||||||
|
member_suffix: '',
|
||||||
|
member_street: '',
|
||||||
|
member_streetnumber: '',
|
||||||
|
member_email: '',
|
||||||
|
member_zip: '',
|
||||||
|
member_city: '',
|
||||||
|
member_gender: '',
|
||||||
|
member_died: '',
|
||||||
|
member_membership_start: '',
|
||||||
|
member_phone: '',
|
||||||
|
current_state_code: '',
|
||||||
|
current_state_changed_at: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
}, [member]);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'number' ? (value === '' ? '' : parseInt(value)) : value
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
if (!formData.member_firstname.trim()) {
|
||||||
|
newErrors.member_firstname = 'First name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.member_surname.trim()) {
|
||||||
|
newErrors.member_surname = 'Surname is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.member_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.member_email)) {
|
||||||
|
newErrors.member_email = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.member_zip && (formData.member_zip < 10000 || formData.member_zip > 99999)) {
|
||||||
|
newErrors.member_zip = 'Please enter a valid 5-digit ZIP code';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.member_phone && formData.member_phone.length < 10) {
|
||||||
|
newErrors.member_phone = 'Please enter a valid phone number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.member_count < 1) {
|
||||||
|
newErrors.member_count = 'Member count must be at least 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
updated_date: new Date().toISOString(),
|
||||||
|
member_died: formData.member_died ? new Date(formData.member_died).toISOString() : null,
|
||||||
|
member_membership_start: formData.member_membership_start ? new Date(formData.member_membership_start).toISOString() : null,
|
||||||
|
current_state_changed_at: formData.current_state_changed_at ? new Date(formData.current_state_changed_at).toISOString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
await onSave(submitData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving member:', error);
|
||||||
|
setErrors({ submit: 'Failed to save member. Please try again.' });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||||
|
<User className="h-6 w-6" />
|
||||||
|
{member ? 'Edit Member' : 'Add New Member'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{errors.submit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Personal Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Personal Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
First Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_firstname"
|
||||||
|
value={formData.member_firstname}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_firstname ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter first name"
|
||||||
|
/>
|
||||||
|
{errors.member_firstname && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_firstname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Spouse First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_firstname_spouse"
|
||||||
|
value={formData.member_firstname_spouse}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter spouse first name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Surname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_surname"
|
||||||
|
value={formData.member_surname}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_surname ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter surname"
|
||||||
|
/>
|
||||||
|
{errors.member_surname && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_surname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Suffix
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_suffix"
|
||||||
|
value={formData.member_suffix}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Jr., Sr., III, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Gender
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="member_gender"
|
||||||
|
value={formData.member_gender}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{genderOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Member Count
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="member_count"
|
||||||
|
value={formData.member_count}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
min="1"
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_count ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.member_count && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_count}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Contact Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="member_email"
|
||||||
|
value={formData.member_email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_email ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="member@example.com"
|
||||||
|
/>
|
||||||
|
{errors.member_email && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="member_phone"
|
||||||
|
value={formData.member_phone}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_phone ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
/>
|
||||||
|
{errors.member_phone && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
Address Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Street Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_streetnumber"
|
||||||
|
value={formData.member_streetnumber}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Street
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_street"
|
||||||
|
value={formData.member_street}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Main Street"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
ZIP Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="member_zip"
|
||||||
|
value={formData.member_zip}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_zip ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="12345"
|
||||||
|
/>
|
||||||
|
{errors.member_zip && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_zip}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
City
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_city"
|
||||||
|
value={formData.member_city}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Anytown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
State
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="current_state_code"
|
||||||
|
value={formData.current_state_code}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select State</option>
|
||||||
|
{stateCodes.map(code => (
|
||||||
|
<option key={code} value={code}>{code}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
State Changed At
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="current_state_changed_at"
|
||||||
|
value={formData.current_state_changed_at}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Membership Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Membership Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Membership Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="member_membership_start"
|
||||||
|
value={formData.member_membership_start}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Date of Death
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="member_died"
|
||||||
|
value={formData.member_died}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-6 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-6 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isLoading ? 'Saving...' : 'Save Member'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main FastAPI Frontend Component
|
||||||
|
const FastAPIFrontend = () => {
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showMemberForm, setShowMemberForm] = useState(false);
|
||||||
|
const [editingMember, setEditingMember] = useState(null);
|
||||||
|
|
||||||
|
// Your existing API base URL - adjust this to match your FastAPI server
|
||||||
|
const API_BASE_URL = 'http://localhost:8000'; // Change this to your FastAPI URL
|
||||||
|
|
||||||
|
// Fetch members from your FastAPI backend
|
||||||
|
const fetchMembers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/members`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setMembers(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch members: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save member (create or update)
|
||||||
|
const saveMember = async (memberData) => {
|
||||||
|
try {
|
||||||
|
const isEdit = memberData.id && memberData.id !== '';
|
||||||
|
const url = isEdit ? `${API_BASE_URL}/members/${memberData.id}` : `${API_BASE_URL}/members`;
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(memberData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedMember = await response.json();
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
if (isEdit) {
|
||||||
|
setMembers(prev => prev.map(m => m.id === savedMember.id ? savedMember : m));
|
||||||
|
} else {
|
||||||
|
setMembers(prev => [...prev, savedMember]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMemberForm(false);
|
||||||
|
setEditingMember(null);
|
||||||
|
alert(isEdit ? 'Member updated successfully!' : 'Member created successfully!');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('Failed to save member: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete member
|
||||||
|
const deleteMember = async (memberId) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this member?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/members/${memberId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMembers(prev => prev.filter(m => m.id !== memberId));
|
||||||
|
alert('Member deleted successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete member: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit member
|
||||||
|
const handleEditMember = (member) => {
|
||||||
|
setEditingMember(member);
|
||||||
|
setShowMemberForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle add new member
|
||||||
|
const handleAddMember = () => {
|
||||||
|
setEditingMember(null);
|
||||||
|
setShowMemberForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle close form
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setShowMemberForm(false);
|
||||||
|
setEditingMember(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load members on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMembers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="bg-white shadow-lg rounded-lg p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800">Member Management</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleAddMember}
|
||||||
|
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
Add New Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-lg text-gray-600">Loading members...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Phone
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
City
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Member Count
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{member.member_firstname} {member.member_surname} {member.member_suffix}
|
||||||
|
</div>
|
||||||
|
{member.member_firstname_spouse && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Spouse: {member.member_firstname_spouse}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{member.member_email || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{member.member_phone || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{member.member_city || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{member.member_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditMember(member)}
|
||||||
|
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-100"
|
||||||
|
title="Edit Member"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMember(member.id)}
|
||||||
|
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-100"
|
||||||
|
title="Delete Member"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" className="text-center py-8 text-gray-500">
|
||||||
|
No members found. Click "Add New Member" to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
Total Members: {members.length}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchMembers}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Member Edit Form Modal */}
|
||||||
|
<MemberEditForm
|
||||||
|
member={editingMember}
|
||||||
|
onSave={saveMember}
|
||||||
|
onCancel={handleCloseForm}
|
||||||
|
isOpen={showMemberForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FastAPIFrontend;
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import FastAPIFrontend from './FastAPIFrontend';
|
import FastAPIFrontend from './FastAPIFrontend.tsx';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Save, X, User, Calendar, Phone, Mail, MapPin, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
const MemberEditForm = ({ member = null, onSave, onCancel }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
id: '',
|
||||||
|
member_count: 1,
|
||||||
|
member_firstname: '',
|
||||||
|
member_firstname_spouse: '',
|
||||||
|
member_surname: '',
|
||||||
|
member_suffix: '',
|
||||||
|
member_street: '',
|
||||||
|
member_streetnumber: '',
|
||||||
|
member_email: '',
|
||||||
|
member_zip: '',
|
||||||
|
member_city: '',
|
||||||
|
member_gender: '',
|
||||||
|
member_died: '',
|
||||||
|
member_membership_start: '',
|
||||||
|
member_phone: '',
|
||||||
|
current_state_code: '',
|
||||||
|
current_state_changed_at: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Gender options based on your enum
|
||||||
|
const genderOptions = [
|
||||||
|
{ value: '', label: 'Select Gender' },
|
||||||
|
{ value: 'M', label: 'Male' },
|
||||||
|
{ value: 'F', label: 'Female' },
|
||||||
|
{ value: 'O', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// US State codes
|
||||||
|
const stateCodes = [
|
||||||
|
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
||||||
|
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
||||||
|
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
||||||
|
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
||||||
|
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load member data when component mounts or member prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (member) {
|
||||||
|
setFormData({
|
||||||
|
id: member.id || '',
|
||||||
|
member_count: member.member_count || 1,
|
||||||
|
member_firstname: member.member_firstname || '',
|
||||||
|
member_firstname_spouse: member.member_firstname_spouse || '',
|
||||||
|
member_surname: member.member_surname || '',
|
||||||
|
member_suffix: member.member_suffix || '',
|
||||||
|
member_street: member.member_street || '',
|
||||||
|
member_streetnumber: member.member_streetnumber || '',
|
||||||
|
member_email: member.member_email || '',
|
||||||
|
member_zip: member.member_zip || '',
|
||||||
|
member_city: member.member_city || '',
|
||||||
|
member_gender: member.member_gender || '',
|
||||||
|
member_died: member.member_died ? new Date(member.member_died).toISOString().split('T')[0] : '',
|
||||||
|
member_membership_start: member.member_membership_start ? new Date(member.member_membership_start).toISOString().split('T')[0] : '',
|
||||||
|
member_phone: member.member_phone || '',
|
||||||
|
current_state_code: member.current_state_code || '',
|
||||||
|
current_state_changed_at: member.current_state_changed_at ? new Date(member.current_state_changed_at).toISOString().slice(0, 16) : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [member]);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'number' ? (value === '' ? '' : parseInt(value)) : value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
// Required field validations
|
||||||
|
if (!formData.member_firstname.trim()) {
|
||||||
|
newErrors.member_firstname = 'First name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.member_surname.trim()) {
|
||||||
|
newErrors.member_surname = 'Surname is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.member_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.member_email)) {
|
||||||
|
newErrors.member_email = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP code validation
|
||||||
|
if (formData.member_zip && (formData.member_zip < 10000 || formData.member_zip > 99999)) {
|
||||||
|
newErrors.member_zip = 'Please enter a valid 5-digit ZIP code';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone validation (basic)
|
||||||
|
if (formData.member_phone && formData.member_phone.length < 10) {
|
||||||
|
newErrors.member_phone = 'Please enter a valid phone number';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member count validation
|
||||||
|
if (formData.member_count < 1) {
|
||||||
|
newErrors.member_count = 'Member count must be at least 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare data for submission
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
updated_date: new Date().toISOString(),
|
||||||
|
// Convert date strings back to proper format for database
|
||||||
|
member_died: formData.member_died ? new Date(formData.member_died).toISOString() : null,
|
||||||
|
member_membership_start: formData.member_membership_start ? new Date(formData.member_membership_start).toISOString() : null,
|
||||||
|
current_state_changed_at: formData.current_state_changed_at ? new Date(formData.current_state_changed_at).toISOString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
await onSave(submitData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving member:', error);
|
||||||
|
setErrors({ submit: 'Failed to save member. Please try again.' });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (dateString) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
return new Date(dateString).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto bg-white shadow-lg rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||||
|
<User className="h-6 w-6" />
|
||||||
|
{member ? 'Edit Member' : 'Add New Member'}
|
||||||
|
</h2>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{errors.submit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Personal Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Personal Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
First Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_firstname"
|
||||||
|
value={formData.member_firstname}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_firstname ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter first name"
|
||||||
|
/>
|
||||||
|
{errors.member_firstname && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_firstname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Spouse First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_firstname_spouse"
|
||||||
|
value={formData.member_firstname_spouse}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter spouse first name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Surname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_surname"
|
||||||
|
value={formData.member_surname}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_surname ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter surname"
|
||||||
|
/>
|
||||||
|
{errors.member_surname && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_surname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Suffix
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_suffix"
|
||||||
|
value={formData.member_suffix}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Jr., Sr., III, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Gender
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="member_gender"
|
||||||
|
value={formData.member_gender}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{genderOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Member Count
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="member_count"
|
||||||
|
value={formData.member_count}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
min="1"
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_count ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.member_count && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_count}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Contact Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="member_email"
|
||||||
|
value={formData.member_email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_email ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="member@example.com"
|
||||||
|
/>
|
||||||
|
{errors.member_email && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="member_phone"
|
||||||
|
value={formData.member_phone}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_phone ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
/>
|
||||||
|
{errors.member_phone && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
Address Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Street Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_streetnumber"
|
||||||
|
value={formData.member_streetnumber}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Street
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_street"
|
||||||
|
value={formData.member_street}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Main Street"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
ZIP Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="member_zip"
|
||||||
|
value={formData.member_zip}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.member_zip ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="12345"
|
||||||
|
/>
|
||||||
|
{errors.member_zip && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.member_zip}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
City
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member_city"
|
||||||
|
value={formData.member_city}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Anytown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
State
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="current_state_code"
|
||||||
|
value={formData.current_state_code}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select State</option>
|
||||||
|
{stateCodes.map(code => (
|
||||||
|
<option key={code} value={code}>{code}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
State Changed At
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="current_state_changed_at"
|
||||||
|
value={formData.current_state_changed_at}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Membership Information */}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Membership Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Membership Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="member_membership_start"
|
||||||
|
value={formData.member_membership_start}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Date of Death
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="member_died"
|
||||||
|
value={formData.member_died}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-6 border-t">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-6 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isLoading ? 'Saving...' : 'Save Member'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example usage component
|
||||||
|
const App = () => {
|
||||||
|
const [currentMember, setCurrentMember] = useState(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
// Example member data
|
||||||
|
const exampleMember = {
|
||||||
|
id: 1,
|
||||||
|
member_count: 2,
|
||||||
|
member_firstname: 'John',
|
||||||
|
member_firstname_spouse: 'Jane',
|
||||||
|
member_surname: 'Doe',
|
||||||
|
member_suffix: 'Jr.',
|
||||||
|
member_street: 'Main Street',
|
||||||
|
member_streetnumber: '123',
|
||||||
|
member_email: 'john.doe@example.com',
|
||||||
|
member_zip: 12345,
|
||||||
|
member_city: 'Anytown',
|
||||||
|
member_gender: 'M',
|
||||||
|
member_died: null,
|
||||||
|
member_membership_start: '2020-01-15',
|
||||||
|
member_phone: '(555) 123-4567',
|
||||||
|
current_state_code: 'CA',
|
||||||
|
current_state_changed_at: '2020-01-15T10:00:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (memberData) => {
|
||||||
|
console.log('Saving member data:', memberData);
|
||||||
|
// Here you would typically make an API call to save the data
|
||||||
|
// await saveMemberToAPI(memberData);
|
||||||
|
alert('Member saved successfully!');
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setCurrentMember(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setCurrentMember(exampleMember);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNew = () => {
|
||||||
|
setCurrentMember(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showForm) {
|
||||||
|
return (
|
||||||
|
<MemberEditForm
|
||||||
|
member={currentMember}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<div className="bg-white shadow-lg rounded-lg p-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-6">Member Management</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-gray-200 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-2">John Doe Jr.</h3>
|
||||||
|
<p className="text-gray-600">Email: john.doe@example.com</p>
|
||||||
|
<p className="text-gray-600">Phone: (555) 123-4567</p>
|
||||||
|
<p className="text-gray-600">Address: 123 Main Street, Anytown, CA 12345</p>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Edit Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddNew}
|
||||||
|
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Add New Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Salutation and pronoun helpers for German language text generation."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_gender_salutation(gender: str, capitalize: bool = False) -> str:
|
||||||
|
"""Return appropriate salutation based on gender.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gender: 'female' or any other value defaults to male
|
||||||
|
capitalize: Whether to capitalize the first letter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'liebe' (if female) or 'lieber' (if male), optionally capitalized
|
||||||
|
"""
|
||||||
|
if gender == "female":
|
||||||
|
salutation = "liebe"
|
||||||
|
else:
|
||||||
|
salutation = "lieber"
|
||||||
|
return salutation.capitalize() if capitalize else salutation
|
||||||
|
|
||||||
|
|
||||||
|
def get_pronouns(is_plural: bool = False) -> dict:
|
||||||
|
"""Return gender-neutral pronoun set for singular or plural forms.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_plural: If True, returns plural pronouns (euer, euch, etc.),
|
||||||
|
otherwise returns singular pronouns (Dein, Dich, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with pronoun variations:
|
||||||
|
- 'possessive': possessive form (Deine/eure)
|
||||||
|
- 'neutral': neutral form (Dein/euer)
|
||||||
|
- 'accusative': accusative form (Dich/euch)
|
||||||
|
- 'conditional': conditional form (Solltest Du/Solltet ihr)
|
||||||
|
- 'verb': verb form (sende/sendet)
|
||||||
|
"""
|
||||||
|
if is_plural:
|
||||||
|
return {
|
||||||
|
'possessive': 'eure',
|
||||||
|
'neutral': 'euer',
|
||||||
|
'accusative': 'euch',
|
||||||
|
'conditional': 'Solltet ihr',
|
||||||
|
'verb': 'sendet'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'possessive': 'Deine',
|
||||||
|
'neutral': 'Dein',
|
||||||
|
'accusative': 'Dich',
|
||||||
|
'conditional': 'Solltest Du',
|
||||||
|
'verb': 'sende'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user