Teamtheo613 commited on
Commit
a54caa9
·
verified ·
1 Parent(s): 7786add

Upload 6 files

Browse files
Files changed (6) hide show
  1. Fee-Note-Template.docx +0 -0
  2. README.md +42 -0
  3. Solicitors-Addresses.txt +74 -0
  4. app.py +483 -0
  5. packages.txt +1 -0
  6. requirements.txt +2 -0
Fee-Note-Template.docx ADDED
Binary file (42.8 kB). View file
 
README.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: FeeNoteGen
3
+ emoji: ⚖️
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: 5.12.0
8
+ python_version: 3.11
9
+ app_file: app.py
10
+ pinned: false
11
+ license: mit
12
+ ---
13
+
14
+ # Fee Note Generator
15
+
16
+ Batch fee note generator for counsel. Paste pipe-delimited fee note data and receive DOCX + PDF fee notes generated from a template.
17
+
18
+ ## Features
19
+
20
+ - **Auto date**: Today's date is inserted automatically — no need to update the template
21
+ - **Fuzzy solicitor matching**: Handles variations like `AB Wolfe & Co.` vs `AB Wolfe and Co.` automatically
22
+ - **DOCX + PDF output**: Both formats generated for every fee note
23
+ - **VAT calculation**: Net → VAT (23%) → Gross calculated automatically
24
+ - **Add solicitors on the fly**: New firms can be added via the UI without redeploying
25
+ - **Cross-run replacement**: Handles Word's internal text splitting reliably
26
+
27
+ ## Input format
28
+
29
+ ```
30
+ filename | solicitor_name | case_record | case_name | description | net_amount
31
+ 20264402 | O'Connor LLP | 2023/00700 | Permanent TSB PLC v Barrett | Attendance, Limerick CC 6.3.26 | 200
32
+ ```
33
+
34
+ The header row is optional — if omitted, it is added automatically.
35
+
36
+ ## Files
37
+
38
+ - `app.py` — Main application
39
+ - `Fee-Note-Template.docx` — Template with placeholders
40
+ - `Solicitors-Addresses.txt` — Solicitor directory (name, address, domain, email routing)
41
+ - `packages.txt` — System dependencies (LibreOffice for PDF conversion)
42
+ - `requirements.txt` — Python dependencies
Solicitors-Addresses.txt ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Mason Hayes & Curran
2
+ Address: South Bank House, Barrow St, Dublin 4
3
+ Domain: mhc.ie
4
+ Email return always: awallace@mhc.ie unless if last email is sent from noshea@mhc.ie then email noshea@mhc.ie and cc awallace@mhc.ie
5
+
6
+ Lavelle Partners
7
+ Address: Bankside, Charlemont, Dublin 2
8
+ Domain: lavellepartners.ie
9
+ Email return always: courtoutcomes@lavellesolicitors.ie
10
+
11
+ Hugh J Ward and Co
12
+ Address: 9 Seville Place, North Wall, Dublin 1
13
+ Domain: hughjwardsolicitors.ie
14
+ Email return always: hjwagentinstructions@hughjwardsolicitors.ie and also jstuart@hughjwardsolicitors.ie
15
+
16
+ O'Connor LLP
17
+ Address: 8 Clare St, Dublin 2
18
+ Domain: oclegal.ie
19
+ Email return always: always mirra.nepomuceno@oclegal.ie and ashley.coakley@oclegal.ie
20
+
21
+ Matheson
22
+ Address: 70 Sir John Rogerson's Quay, Dublin 2
23
+ Domain: matheson.com
24
+
25
+ AB Wolfe and Co.
26
+ Address: 70 Sir John Rogerson's Quay, Dublin 2
27
+ Domain: matheson.com
28
+
29
+
30
+ Belgard Solicitors
31
+ Address: Cookstown Court, Old Belgard Road, Dublin 24
32
+ Domain: belgardsolicitors.ie
33
+
34
+ Eversheds Sutherland
35
+ Address: One Earlsfort Centre, Dublin 2
36
+ Domain: eversheds-sutherland.com
37
+ Email return always: courtenquiries@evershedssutherland.ie
38
+
39
+ Dillon Eustace
40
+ Address: 2 Grand Canal Square, Dublin 2
41
+ Domain: dilloneustace.com
42
+
43
+ Beauchamps LLP
44
+ Address: Riverside Two, Sir John Rogerson's Quay, Dublin 2
45
+ Domain: beauchamps.ie
46
+ Email Return Always: repocourtoutcomes@beauchamps.ie
47
+
48
+ BHSM LLP
49
+ Address: 76 Baggot Street Lower, Dublin 2
50
+ Domain: bhsm.ie
51
+ Email Return Contact Always: rmelvin@bhsm.ie
52
+
53
+ OSM Partners
54
+ Address: 87 Harcourt Street, Dublin 2
55
+ Domain: osmpartners.ie
56
+ Email Return Contact Always: skeogh@osmpartners.ie and also cc preilly@osmpartners.ie
57
+
58
+ Joynt and Crawford
59
+ Address: 8 Anglesea Terrace, Dublin 2
60
+ Domain: joyntandcrawford.ie
61
+ Email return always: suzannefinglas@joyntcrawford.ie and check for emails in relation to the matter sent from tamara.mclaughlin@joyntcrawford.ie
62
+
63
+ Fieldfisher
64
+ Address: 45 Mespil Road, Dublin 4
65
+ Domain: fieldfisher.com
66
+
67
+ RDJ LLP
68
+ Address: Aengus House, Dock Street, Galway
69
+
70
+ AMOSS
71
+ Address: Warrington House, Mount St Cresc, Dublin 2
72
+
73
+ MDM Solicitors
74
+ Address: 16 Lavitt's Quay, Cork, T12 ED74, Ireland
app.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import subprocess
4
+ import tempfile
5
+ import zipfile
6
+ import csv
7
+ from datetime import datetime
8
+ from io import StringIO
9
+ from difflib import SequenceMatcher
10
+
11
+ import gradio as gr
12
+ from docx import Document
13
+
14
+
15
+ TEMPLATE_PATH = "Fee-Note-Template.docx"
16
+ ADDRESSES_PATH = "Solicitors-Addresses.txt"
17
+
18
+
19
+ # ── Solicitor address loading & fuzzy matching ──────────────────────────────
20
+
21
+ def load_solicitor_addresses():
22
+ """
23
+ Parse Solicitors-Addresses.txt → dict of {canonical_name: address}.
24
+ Also builds a lookup index keyed by normalised name for fuzzy matching.
25
+ """
26
+ addresses = {}
27
+
28
+ if not os.path.exists(ADDRESSES_PATH):
29
+ return addresses
30
+
31
+ with open(ADDRESSES_PATH, "r", encoding="utf-8") as f:
32
+ content = f.read()
33
+
34
+ blocks = content.strip().split("\n\n")
35
+
36
+ for block in blocks:
37
+ lines = block.strip().split("\n")
38
+ if not lines:
39
+ continue
40
+
41
+ firm_name = lines[0].strip()
42
+ address = ""
43
+
44
+ for line in lines[1:]:
45
+ if line.strip().startswith("Address:"):
46
+ address = line.replace("Address:", "").strip()
47
+ break
48
+
49
+ if firm_name and address:
50
+ addresses[firm_name] = address
51
+
52
+ return addresses
53
+
54
+
55
+ # Common legal entity suffixes to strip for matching purposes
56
+ _LEGAL_SUFFIXES = re.compile(
57
+ r"\b(llp|llc|ltd|limited|plc|dac|clg|lp|inc|incorporated|solicitors?)\b",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def normalise_firm_name(name):
63
+ """
64
+ Normalise a firm name for comparison:
65
+ - lowercase
66
+ - '&' ↔ 'and'
67
+ - strip punctuation (dots, commas, apostrophes)
68
+ - strip common legal suffixes (LLP, Ltd, Solicitors, etc.)
69
+ - collapse whitespace
70
+ """
71
+ s = name.lower().strip()
72
+ # Normalise ampersand ↔ 'and'
73
+ s = s.replace(" & ", " and ").replace("&", " and ")
74
+ # Strip common punctuation
75
+ s = re.sub(r"[.,;:'\u2018\u2019\u201C\u201D\"()]+", "", s)
76
+ # Strip legal suffixes (LLP, Ltd, Solicitors, etc.)
77
+ s = _LEGAL_SUFFIXES.sub("", s)
78
+ # Collapse whitespace
79
+ s = re.sub(r"\s+", " ", s).strip()
80
+ return s
81
+
82
+
83
+ def find_solicitor(input_name, addresses_dict):
84
+ """
85
+ Try to match input_name against canonical solicitor names.
86
+ Returns (canonical_name, address) or raises ValueError.
87
+
88
+ Matching strategy (in order):
89
+ 1. Exact match (case-insensitive)
90
+ 2. Normalised exact match (& ↔ and, strip punctuation)
91
+ 3. Substring containment (either direction)
92
+ 4. Token overlap ≥ 70%
93
+ 5. SequenceMatcher ratio ≥ 0.80
94
+ """
95
+ if not input_name:
96
+ raise ValueError("Solicitor name is empty")
97
+
98
+ # 1. Exact case-insensitive
99
+ for canonical, addr in addresses_dict.items():
100
+ if input_name.lower() == canonical.lower():
101
+ return canonical, addr
102
+
103
+ # 2. Normalised exact
104
+ input_norm = normalise_firm_name(input_name)
105
+ for canonical, addr in addresses_dict.items():
106
+ if input_norm == normalise_firm_name(canonical):
107
+ return canonical, addr
108
+
109
+ # 3. Substring containment
110
+ for canonical, addr in addresses_dict.items():
111
+ canon_norm = normalise_firm_name(canonical)
112
+ if input_norm in canon_norm or canon_norm in input_norm:
113
+ return canonical, addr
114
+
115
+ # 4. Token overlap
116
+ input_tokens = set(input_norm.split())
117
+ best_overlap = 0
118
+ best_match = None
119
+ for canonical, addr in addresses_dict.items():
120
+ canon_tokens = set(normalise_firm_name(canonical).split())
121
+ if not canon_tokens:
122
+ continue
123
+ overlap = len(input_tokens & canon_tokens) / max(len(input_tokens), len(canon_tokens))
124
+ if overlap > best_overlap:
125
+ best_overlap = overlap
126
+ best_match = (canonical, addr)
127
+
128
+ if best_overlap >= 0.70 and best_match:
129
+ return best_match
130
+
131
+ # 5. SequenceMatcher
132
+ best_ratio = 0
133
+ best_match = None
134
+ for canonical, addr in addresses_dict.items():
135
+ ratio = SequenceMatcher(None, input_norm, normalise_firm_name(canonical)).ratio()
136
+ if ratio > best_ratio:
137
+ best_ratio = ratio
138
+ best_match = (canonical, addr)
139
+
140
+ if best_ratio >= 0.80 and best_match:
141
+ return best_match
142
+
143
+ # No match — build helpful error
144
+ # Show top 3 closest matches
145
+ scored = []
146
+ for canonical in addresses_dict:
147
+ ratio = SequenceMatcher(None, input_norm, normalise_firm_name(canonical)).ratio()
148
+ scored.append((canonical, ratio))
149
+ scored.sort(key=lambda x: x[1], reverse=True)
150
+ suggestions = [f" • {name} ({ratio:.0%})" for name, ratio in scored[:3]]
151
+
152
+ raise ValueError(
153
+ f"Solicitor '{input_name}' not found.\n"
154
+ f"Closest matches:\n" + "\n".join(suggestions) + "\n\n"
155
+ f"You can add this firm using the 'Add New Solicitor' section below."
156
+ )
157
+
158
+
159
+ # ── Cross-run text replacement ──────────────────────────────────────────────
160
+
161
+ def replace_in_paragraph(paragraph, old_text, new_text):
162
+ """
163
+ Replace text across runs in a paragraph while preserving formatting
164
+ of the first run. Handles placeholders split across multiple runs.
165
+ """
166
+ full_text = paragraph.text
167
+ if old_text not in full_text:
168
+ return False
169
+
170
+ new_full = full_text.replace(old_text, new_text)
171
+
172
+ runs = paragraph.runs
173
+ if not runs:
174
+ return False
175
+
176
+ # Preserve first run's formatting, put all text there, clear the rest
177
+ runs[0].text = new_full
178
+ for r in runs[1:]:
179
+ r.text = ""
180
+ return True
181
+
182
+
183
+ def apply_replacements(doc, replacements):
184
+ """Apply all replacements across paragraphs and table cells."""
185
+ for old, new in replacements.items():
186
+ # Top-level paragraphs
187
+ for p in doc.paragraphs:
188
+ replace_in_paragraph(p, old, new)
189
+ # Table cells
190
+ for table in doc.tables:
191
+ for row in table.rows:
192
+ for cell in row.cells:
193
+ for p in cell.paragraphs:
194
+ replace_in_paragraph(p, old, new)
195
+
196
+
197
+ # ── PDF conversion ──────────────────────────────────────────────────────────
198
+
199
+ def convert_docx_to_pdf(docx_path, output_dir):
200
+ """
201
+ Convert a .docx to .pdf using LibreOffice.
202
+ Returns the path to the generated PDF.
203
+ """
204
+ try:
205
+ subprocess.run(
206
+ [
207
+ "libreoffice", "--headless", "--norestore",
208
+ "--convert-to", "pdf",
209
+ "--outdir", output_dir,
210
+ docx_path,
211
+ ],
212
+ capture_output=True,
213
+ timeout=30,
214
+ check=True,
215
+ )
216
+ pdf_name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
217
+ pdf_path = os.path.join(output_dir, pdf_name)
218
+ if os.path.exists(pdf_path):
219
+ return pdf_path
220
+ except Exception:
221
+ pass
222
+ return None
223
+
224
+
225
+ # ── Date formatting ─────────────────────────────────────────────────────────
226
+
227
+ def format_date_for_fee_note(dt=None):
228
+ """
229
+ Format date as '6 March 2026' (no leading zero on day).
230
+ """
231
+ if dt is None:
232
+ dt = datetime.today()
233
+ return f"{dt.day} {dt.strftime('%B')} {dt.year}"
234
+
235
+
236
+ # ── Main generation logic ───────────────────────────────────────────────────
237
+
238
+ def generate_batch_fee_notes(csv_text):
239
+ """
240
+ Process pipe-delimited input and generate fee note DOCX + PDF files.
241
+
242
+ Required columns:
243
+ filename | solicitor_name | case_record | case_name | description | net_amount
244
+ """
245
+ addresses_lookup = load_solicitor_addresses()
246
+
247
+ if not csv_text.strip():
248
+ raise gr.Error("Input is empty. Please paste at least one row of data.")
249
+
250
+ # Check if user included a header row
251
+ lines = csv_text.strip().split("\n")
252
+ first_line_lower = lines[0].lower()
253
+ has_header = "filename" in first_line_lower and "solicitor" in first_line_lower
254
+
255
+ if not has_header:
256
+ # Prepend the header row automatically
257
+ csv_text = "filename | solicitor_name | case_record | case_name | description | net_amount\n" + csv_text
258
+
259
+ csv_reader = csv.DictReader(
260
+ StringIO(csv_text),
261
+ delimiter="|",
262
+ quotechar='"',
263
+ )
264
+
265
+ if not csv_reader.fieldnames:
266
+ raise gr.Error("Could not parse input. Check the format and try again.")
267
+
268
+ csv_reader.fieldnames = [h.strip() for h in csv_reader.fieldnames]
269
+
270
+ required_cols = {"filename", "solicitor_name", "case_record", "case_name", "description", "net_amount"}
271
+ provided_cols = set(csv_reader.fieldnames)
272
+
273
+ if not required_cols.issubset(provided_cols):
274
+ missing = required_cols - provided_cols
275
+ raise gr.Error(
276
+ f"Missing required columns: {', '.join(missing)}\n"
277
+ f"Required: filename | solicitor_name | case_record | case_name | description | net_amount"
278
+ )
279
+
280
+ if not os.path.exists(TEMPLATE_PATH):
281
+ raise gr.Error(
282
+ f"Template '{TEMPLATE_PATH}' not found. "
283
+ f"Please upload Fee-Note-Template.docx to the Space."
284
+ )
285
+
286
+ tmpdir = tempfile.mkdtemp()
287
+ output_files = []
288
+ today_str = format_date_for_fee_note()
289
+ errors = []
290
+
291
+ rows = list(csv_reader)
292
+ if not rows:
293
+ raise gr.Error("No data rows found. Please check your input.")
294
+
295
+ for row_num, row in enumerate(rows, start=1):
296
+ try:
297
+ row = {k: (v.strip() if v else "") for k, v in row.items()}
298
+
299
+ filename = row["filename"].strip()
300
+ if not filename:
301
+ errors.append(f"Row {row_num}: filename is empty, skipped.")
302
+ continue
303
+
304
+ fee_note_number = filename
305
+ docx_name = filename if filename.endswith(".docx") else filename + ".docx"
306
+
307
+ solicitor_name = row["solicitor_name"].strip()
308
+ canonical_name, address = find_solicitor(solicitor_name, addresses_lookup)
309
+
310
+ # Parse net amount
311
+ raw_net = row["net_amount"].replace("€", "").replace(",", "").strip()
312
+ if not raw_net:
313
+ errors.append(f"Row {row_num}: net_amount is empty, skipped.")
314
+ continue
315
+
316
+ net_value = float(raw_net)
317
+ vat_value = round(net_value * 0.23, 2)
318
+ gross_value = round(net_value + vat_value, 2)
319
+
320
+ net_str = f"€{net_value:,.2f}"
321
+ vat_str = f"€{vat_value:,.2f}"
322
+ gross_str = f"€{gross_value:,.2f}"
323
+
324
+ # Load template and apply replacements
325
+ doc = Document(TEMPLATE_PATH)
326
+
327
+ replacements = {
328
+ "[Insert Todays Date Here]": today_str,
329
+ "[Insert Solicitor Here]": canonical_name,
330
+ "[Insert Address Here]": address,
331
+ "[Insert Case Record Number]": row["case_record"].strip(),
332
+ "[Insert Case Name]": row["case_name"].strip(),
333
+ "[Insert Description]": row["description"].strip(),
334
+ "[Insert Net Amount]": net_str,
335
+ "[Insert VAT at .23% of Net Amount]": vat_str,
336
+ "[Insert Gross Here]": gross_str,
337
+ "[Insert Number Here]": fee_note_number,
338
+ }
339
+
340
+ apply_replacements(doc, replacements)
341
+
342
+ docx_path = os.path.join(tmpdir, docx_name)
343
+ doc.save(docx_path)
344
+ output_files.append((docx_name, docx_path))
345
+
346
+ # Convert to PDF
347
+ pdf_path = convert_docx_to_pdf(docx_path, tmpdir)
348
+ if pdf_path:
349
+ pdf_name = os.path.splitext(docx_name)[0] + ".pdf"
350
+ output_files.append((pdf_name, pdf_path))
351
+
352
+ except ValueError as e:
353
+ errors.append(f"Row {row_num}: {str(e)}")
354
+ except Exception as e:
355
+ errors.append(f"Row {row_num}: unexpected error — {str(e)}")
356
+
357
+ if not output_files:
358
+ error_msg = "No fee notes were generated.\n\n" + "\n".join(errors) if errors else "No fee notes were generated."
359
+ raise gr.Error(error_msg)
360
+
361
+ # Create zip
362
+ zip_path = os.path.join(tmpdir, "Fee-Notes.zip")
363
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
364
+ for display_name, file_path in output_files:
365
+ zf.write(file_path, arcname=display_name)
366
+
367
+ status_parts = [f"Generated {len([f for f in output_files if f[0].endswith('.docx')])} fee notes (DOCX + PDF)."]
368
+ if errors:
369
+ status_parts.append("\n**Warnings:**\n" + "\n".join(f"- {e}" for e in errors))
370
+
371
+ return zip_path, "\n".join(status_parts)
372
+
373
+
374
+ # ── Add new solicitor ───────────────────────────────────────────────────────
375
+
376
+ def add_solicitor(firm_name, firm_address, firm_domain, firm_email_notes):
377
+ """Append a new solicitor to Solicitors-Addresses.txt."""
378
+ if not firm_name.strip():
379
+ raise gr.Error("Firm name is required.")
380
+ if not firm_address.strip():
381
+ raise gr.Error("Address is required.")
382
+
383
+ block = f"\n\n{firm_name.strip()}\nAddress: {firm_address.strip()}"
384
+ if firm_domain.strip():
385
+ block += f"\nDomain: {firm_domain.strip()}"
386
+ if firm_email_notes.strip():
387
+ block += f"\nEmail return always: {firm_email_notes.strip()}"
388
+
389
+ with open(ADDRESSES_PATH, "a", encoding="utf-8") as f:
390
+ f.write(block + "\n")
391
+
392
+ # Reload and return updated list
393
+ addresses = load_solicitor_addresses()
394
+ firms_list = "\n".join(f"• {name} — {addr}" for name, addr in sorted(addresses.items()))
395
+ return f"Added **{firm_name.strip()}** successfully.\n\n**Current firms ({len(addresses)}):**\n{firms_list}"
396
+
397
+
398
+ def get_current_firms():
399
+ """Return formatted list of current firms."""
400
+ addresses = load_solicitor_addresses()
401
+ if not addresses:
402
+ return "No firms loaded. Check that Solicitors-Addresses.txt exists in the Space."
403
+ return "\n".join(f"• **{name}** — {addr}" for name, addr in sorted(addresses.items()))
404
+
405
+
406
+ # ── Gradio UI ───────────────────────────────────────────────────────────────
407
+
408
+ with gr.Blocks(title="Fee Note Generator", theme=gr.themes.Soft()) as demo:
409
+ gr.Markdown(
410
+ """
411
+ # Fee Note Generator
412
+
413
+ Paste pipe-delimited fee note data below. Each row generates a DOCX and PDF fee note from your template.
414
+
415
+ **Format** (header row is optional — it will be added automatically if missing):
416
+
417
+ ```
418
+ filename | solicitor_name | case_record | case_name | description | net_amount
419
+ ```
420
+
421
+ - **filename**: becomes the file name and fee note number (e.g. `20264402` → `20264402.docx` / `.pdf`)
422
+ - **solicitor_name**: matched against the solicitor directory (fuzzy matching handles minor variations)
423
+ - **net_amount**: numeric, e.g. `200` or `1000.00` — VAT (23%) and gross are calculated automatically
424
+ - **Date**: today's date is inserted automatically
425
+ """
426
+ )
427
+
428
+ with gr.Row():
429
+ csv_input = gr.Textbox(
430
+ lines=14,
431
+ label="Pipe-delimited input",
432
+ placeholder=(
433
+ "20264402 | O'Connor LLP | 2023/00700 | Permanent TSB PLC v Jeffrey Barrett | "
434
+ "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200\n"
435
+ "20264403 | Beauchamps LLP | 2023/00538 | Pepper Finance v Lynch | "
436
+ "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200"
437
+ ),
438
+ )
439
+
440
+ with gr.Row():
441
+ run_button = gr.Button("Generate Fee Notes", variant="primary", size="lg")
442
+
443
+ with gr.Row():
444
+ output_zip = gr.File(label="Download fee notes (.zip)")
445
+ status_output = gr.Markdown(label="Status")
446
+
447
+ run_button.click(
448
+ fn=generate_batch_fee_notes,
449
+ inputs=[csv_input],
450
+ outputs=[output_zip, status_output],
451
+ )
452
+
453
+ # ── Add New Solicitor section ──
454
+ gr.Markdown("---")
455
+ gr.Markdown("### Add New Solicitor")
456
+ gr.Markdown("Add a firm that isn't in the directory yet. It will be available immediately for the next generation run.")
457
+
458
+ with gr.Row():
459
+ with gr.Column():
460
+ new_firm_name = gr.Textbox(label="Firm Name", placeholder="e.g. MDM Solicitors LLP")
461
+ new_firm_address = gr.Textbox(label="Address", placeholder="e.g. 16 Lavitt's Quay, Cork, T12 ED74")
462
+ with gr.Column():
463
+ new_firm_domain = gr.Textbox(label="Domain (optional)", placeholder="e.g. mdmsolicitors.ie")
464
+ new_firm_email = gr.Textbox(label="Email routing notes (optional)", placeholder="e.g. returns@mdmsolicitors.ie")
465
+
466
+ add_button = gr.Button("Add Solicitor", size="sm")
467
+ add_result = gr.Markdown()
468
+
469
+ add_button.click(
470
+ fn=add_solicitor,
471
+ inputs=[new_firm_name, new_firm_address, new_firm_domain, new_firm_email],
472
+ outputs=[add_result],
473
+ )
474
+
475
+ # ── Current directory ──
476
+ gr.Markdown("---")
477
+ with gr.Accordion("View Current Solicitor Directory", open=False):
478
+ firms_display = gr.Markdown(value=get_current_firms)
479
+ refresh_btn = gr.Button("Refresh", size="sm")
480
+ refresh_btn.click(fn=get_current_firms, outputs=[firms_display])
481
+
482
+
483
+ demo.launch()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ libreoffice-nogui
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio==5.12.0
2
+ python-docx==1.1.2