Enhancements

This commit is contained in:
2026-06-25 11:19:08 +02:00
parent 8804d21c32
commit 9343657d20
62 changed files with 10210 additions and 976 deletions
+99
View File
@@ -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
+243
View File
@@ -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()
+63
View File
@@ -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 === '&nbsp;' || 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>
+19 -3
View File
@@ -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%';
+210
View File
@@ -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;
+74
View File
@@ -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;
+157 -122
View File
@@ -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
View File
@@ -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"
}, },
+139
View File
@@ -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")
+86
View File
@@ -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")
+183
View File
@@ -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")
+197
View File
@@ -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")
+197
View File
@@ -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")
+91
View File
@@ -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
View File
@@ -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

+206
View File
@@ -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.
+13
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+338
View File
@@ -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;
}
}
+262
View File
@@ -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
+59
View File
@@ -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>
)
}
+10
View File
@@ -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>,
)
+41
View File
@@ -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
}
+22
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -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/, '')
}
}
}
})
+47
View File
@@ -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
View File
@@ -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},
+5 -1
View File
@@ -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)
+26 -10
View File
@@ -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
View File
@@ -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()
+202
View File
@@ -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}")
+111
View File
@@ -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
+28 -7
View File
@@ -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()
+33 -19
View File
@@ -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
+79
View File
@@ -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": []
}
]
}
+5 -4
View File
@@ -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 ''
+39 -11
View File
@@ -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
View File
@@ -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))
+1 -1
View File
@@ -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
View File
@@ -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)
+209
View File
@@ -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()
+10 -8
View File
@@ -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 -1
View File
@@ -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;
+51
View File
@@ -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'
}