Teamtheo613 commited on
Commit
6b69989
·
verified ·
1 Parent(s): 1629ced

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +594 -0
app.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # ── VAT alignment fix ───────────────────────────────────────────────────────
198
+
199
+ def fix_vat_alignment(doc):
200
+ """
201
+ After replacements, the VAT amount can end up on a different visual line
202
+ than the '+VAT @ 23%' label because the description text wraps.
203
+
204
+ Fix: set the amount cell to bottom-vertical-align and rebuild it with
205
+ just three paragraphs (net → spacer → VAT). The VAT amount then sits
206
+ at the bottom of the cell, beside the '+VAT @ 23%' label.
207
+ """
208
+ from docx.oxml.ns import qn
209
+ ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
210
+
211
+ for table in doc.tables:
212
+ for row in table.rows:
213
+ cells = row.cells
214
+ # Find the description cell that contains '+VAT @ 23%'
215
+ desc_cell = None
216
+ for cell in cells:
217
+ for p in cell.paragraphs:
218
+ if '+VAT @' in p.text:
219
+ desc_cell = cell
220
+ break
221
+ if desc_cell:
222
+ break
223
+
224
+ if not desc_cell:
225
+ continue
226
+
227
+ # Find the amount cell (different element from desc_cell, contains €)
228
+ amt_cell = None
229
+ for cell in cells:
230
+ if cell._element is desc_cell._element:
231
+ continue
232
+ for p in cell.paragraphs:
233
+ if '€' in p.text:
234
+ amt_cell = cell
235
+ break
236
+ if amt_cell:
237
+ break
238
+
239
+ if not amt_cell:
240
+ continue
241
+
242
+ # Extract net and VAT amounts
243
+ amt_paras = amt_cell._element.findall('w:p', ns)
244
+ net_text = None
245
+ net_para_ref = None
246
+ vat_text = None
247
+ vat_para_ref = None
248
+
249
+ for p in amt_paras:
250
+ text = ''.join(t.text or '' for t in p.findall('.//w:t', ns)).strip()
251
+ if '€' in text and net_text is None:
252
+ net_text = text
253
+ net_para_ref = p
254
+ elif '€' in text:
255
+ vat_text = text
256
+ vat_para_ref = p
257
+
258
+ if not net_text or not vat_text:
259
+ continue
260
+
261
+ # ── Set bottom vertical alignment on the amount cell ──
262
+ tcPr = amt_cell._element.find('w:tcPr', ns)
263
+ if tcPr is None:
264
+ tcPr = amt_cell._element.makeelement(qn('w:tcPr'), {})
265
+ amt_cell._element.insert(0, tcPr)
266
+ # Remove any existing vAlign
267
+ for old_va in tcPr.findall('w:vAlign', ns):
268
+ tcPr.remove(old_va)
269
+ vAlign = tcPr.makeelement(qn('w:vAlign'), {qn('w:val'): 'bottom'})
270
+ tcPr.append(vAlign)
271
+
272
+ # ── Rebuild cell paragraphs: net → spacer → VAT ──
273
+ for p in amt_paras:
274
+ amt_cell._element.remove(p)
275
+
276
+ def _make_centered_para(text, fmt_ref):
277
+ """Create a centered paragraph with the given text, copying run formatting."""
278
+ p = amt_cell._element.makeelement(qn('w:p'), {})
279
+ pPr = p.makeelement(qn('w:pPr'), {})
280
+ jc = pPr.makeelement(qn('w:jc'), {qn('w:val'): 'center'})
281
+ pPr.append(jc)
282
+ p.append(pPr)
283
+ r = p.makeelement(qn('w:r'), {})
284
+ if fmt_ref is not None:
285
+ orig_runs = fmt_ref.findall('.//w:r', ns)
286
+ if orig_runs:
287
+ rPr = orig_runs[0].find('w:rPr', ns)
288
+ if rPr is not None:
289
+ r.append(rPr.__deepcopy__(True))
290
+ t = r.makeelement(qn('w:t'), {})
291
+ t.text = text
292
+ t.set(qn('xml:space'), 'preserve')
293
+ r.append(t)
294
+ p.append(r)
295
+ return p
296
+
297
+ # Para 0: net amount
298
+ amt_cell._element.append(_make_centered_para(net_text, net_para_ref))
299
+ # Para 1: empty spacer
300
+ amt_cell._element.append(amt_cell._element.makeelement(qn('w:p'), {}))
301
+ # Para 2: VAT amount
302
+ amt_cell._element.append(_make_centered_para(vat_text, vat_para_ref))
303
+
304
+ return # done — only one such row per fee note
305
+
306
+
307
+ # ── PDF conversion ──────────────────────────────────────────────────────────
308
+
309
+ def convert_docx_to_pdf(docx_path, output_dir):
310
+ """
311
+ Convert a .docx to .pdf using LibreOffice.
312
+ Returns the path to the generated PDF.
313
+ """
314
+ try:
315
+ subprocess.run(
316
+ [
317
+ "libreoffice", "--headless", "--norestore",
318
+ "--convert-to", "pdf",
319
+ "--outdir", output_dir,
320
+ docx_path,
321
+ ],
322
+ capture_output=True,
323
+ timeout=30,
324
+ check=True,
325
+ )
326
+ pdf_name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
327
+ pdf_path = os.path.join(output_dir, pdf_name)
328
+ if os.path.exists(pdf_path):
329
+ return pdf_path
330
+ except Exception:
331
+ pass
332
+ return None
333
+
334
+
335
+ # ── Date formatting ─────────────────────────────────────────────────────────
336
+
337
+ def format_date_for_fee_note(dt=None):
338
+ """
339
+ Format date as '6 March 2026' (no leading zero on day).
340
+ """
341
+ if dt is None:
342
+ dt = datetime.today()
343
+ return f"{dt.day} {dt.strftime('%B')} {dt.year}"
344
+
345
+
346
+ # ── Main generation logic ───────────────────────────────────────────────────
347
+
348
+ def generate_batch_fee_notes(csv_text):
349
+ """
350
+ Process pipe-delimited input and generate fee note DOCX + PDF files.
351
+
352
+ Required columns:
353
+ filename | solicitor_name | case_record | case_name | description | net_amount
354
+ """
355
+ addresses_lookup = load_solicitor_addresses()
356
+
357
+ if not csv_text.strip():
358
+ raise gr.Error("Input is empty. Please paste at least one row of data.")
359
+
360
+ # Check if user included a header row
361
+ lines = csv_text.strip().split("\n")
362
+ first_line_lower = lines[0].lower()
363
+ has_header = "filename" in first_line_lower and "solicitor" in first_line_lower
364
+
365
+ if not has_header:
366
+ # Prepend the header row automatically
367
+ csv_text = "filename | solicitor_name | case_record | case_name | description | net_amount\n" + csv_text
368
+
369
+ csv_reader = csv.DictReader(
370
+ StringIO(csv_text),
371
+ delimiter="|",
372
+ quotechar='"',
373
+ )
374
+
375
+ if not csv_reader.fieldnames:
376
+ raise gr.Error("Could not parse input. Check the format and try again.")
377
+
378
+ csv_reader.fieldnames = [h.strip() for h in csv_reader.fieldnames]
379
+
380
+ required_cols = {"filename", "solicitor_name", "case_record", "case_name", "description", "net_amount"}
381
+ provided_cols = set(csv_reader.fieldnames)
382
+
383
+ if not required_cols.issubset(provided_cols):
384
+ missing = required_cols - provided_cols
385
+ raise gr.Error(
386
+ f"Missing required columns: {', '.join(missing)}\n"
387
+ f"Required: filename | solicitor_name | case_record | case_name | description | net_amount"
388
+ )
389
+
390
+ if not os.path.exists(TEMPLATE_PATH):
391
+ raise gr.Error(
392
+ f"Template '{TEMPLATE_PATH}' not found. "
393
+ f"Please upload Fee-Note-Template.docx to the Space."
394
+ )
395
+
396
+ tmpdir = tempfile.mkdtemp()
397
+ output_files = []
398
+ today_str = format_date_for_fee_note()
399
+ errors = []
400
+
401
+ rows = list(csv_reader)
402
+ if not rows:
403
+ raise gr.Error("No data rows found. Please check your input.")
404
+
405
+ for row_num, row in enumerate(rows, start=1):
406
+ try:
407
+ row = {k: (v.strip() if v else "") for k, v in row.items()}
408
+
409
+ filename = row["filename"].strip()
410
+ if not filename:
411
+ errors.append(f"Row {row_num}: filename is empty, skipped.")
412
+ continue
413
+
414
+ fee_note_number = filename
415
+ docx_name = filename if filename.endswith(".docx") else filename + ".docx"
416
+
417
+ solicitor_name = row["solicitor_name"].strip()
418
+ canonical_name, address = find_solicitor(solicitor_name, addresses_lookup)
419
+
420
+ # Parse net amount
421
+ raw_net = row["net_amount"].replace("€", "").replace(",", "").strip()
422
+ if not raw_net:
423
+ errors.append(f"Row {row_num}: net_amount is empty, skipped.")
424
+ continue
425
+
426
+ net_value = float(raw_net)
427
+ vat_value = round(net_value * 0.23, 2)
428
+ gross_value = round(net_value + vat_value, 2)
429
+
430
+ net_str = f"€{net_value:,.2f}"
431
+ vat_str = f"€{vat_value:,.2f}"
432
+ gross_str = f"€{gross_value:,.2f}"
433
+
434
+ # Load template and apply replacements
435
+ doc = Document(TEMPLATE_PATH)
436
+
437
+ replacements = {
438
+ "[Insert Todays Date Here]": today_str,
439
+ "[Insert Solicitor Here]": canonical_name,
440
+ "[Insert Address Here]": address,
441
+ "[Insert Case Record Number]": row["case_record"].strip(),
442
+ "[Insert Case Name]": row["case_name"].strip(),
443
+ "[Insert Description]": row["description"].strip(),
444
+ "[Insert Net Amount]": net_str,
445
+ "[Insert VAT at .23% of Net Amount]": vat_str,
446
+ "[Insert Gross Here]": gross_str,
447
+ "[Insert Number Here]": fee_note_number,
448
+ }
449
+
450
+ apply_replacements(doc, replacements)
451
+ fix_vat_alignment(doc)
452
+
453
+ docx_path = os.path.join(tmpdir, docx_name)
454
+ doc.save(docx_path)
455
+ output_files.append((docx_name, docx_path))
456
+
457
+ # Convert to PDF
458
+ pdf_path = convert_docx_to_pdf(docx_path, tmpdir)
459
+ if pdf_path:
460
+ pdf_name = os.path.splitext(docx_name)[0] + ".pdf"
461
+ output_files.append((pdf_name, pdf_path))
462
+
463
+ except ValueError as e:
464
+ errors.append(f"Row {row_num}: {str(e)}")
465
+ except Exception as e:
466
+ errors.append(f"Row {row_num}: unexpected error — {str(e)}")
467
+
468
+ if not output_files:
469
+ error_msg = "No fee notes were generated.\n\n" + "\n".join(errors) if errors else "No fee notes were generated."
470
+ raise gr.Error(error_msg)
471
+
472
+ # Create zip
473
+ zip_path = os.path.join(tmpdir, "Fee-Notes.zip")
474
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
475
+ for display_name, file_path in output_files:
476
+ zf.write(file_path, arcname=display_name)
477
+
478
+ status_parts = [f"Generated {len([f for f in output_files if f[0].endswith('.docx')])} fee notes (DOCX + PDF)."]
479
+ if errors:
480
+ status_parts.append("\n**Warnings:**\n" + "\n".join(f"- {e}" for e in errors))
481
+
482
+ return zip_path, "\n".join(status_parts)
483
+
484
+
485
+ # ── Add new solicitor ───────────────────────────────────────────────────────
486
+
487
+ def add_solicitor(firm_name, firm_address, firm_domain, firm_email_notes):
488
+ """Append a new solicitor to Solicitors-Addresses.txt."""
489
+ if not firm_name.strip():
490
+ raise gr.Error("Firm name is required.")
491
+ if not firm_address.strip():
492
+ raise gr.Error("Address is required.")
493
+
494
+ block = f"\n\n{firm_name.strip()}\nAddress: {firm_address.strip()}"
495
+ if firm_domain.strip():
496
+ block += f"\nDomain: {firm_domain.strip()}"
497
+ if firm_email_notes.strip():
498
+ block += f"\nEmail return always: {firm_email_notes.strip()}"
499
+
500
+ with open(ADDRESSES_PATH, "a", encoding="utf-8") as f:
501
+ f.write(block + "\n")
502
+
503
+ # Reload and return updated list
504
+ addresses = load_solicitor_addresses()
505
+ firms_list = "\n".join(f"• {name} — {addr}" for name, addr in sorted(addresses.items()))
506
+ return f"Added **{firm_name.strip()}** successfully.\n\n**Current firms ({len(addresses)}):**\n{firms_list}"
507
+
508
+
509
+ def get_current_firms():
510
+ """Return formatted list of current firms."""
511
+ addresses = load_solicitor_addresses()
512
+ if not addresses:
513
+ return "No firms loaded. Check that Solicitors-Addresses.txt exists in the Space."
514
+ return "\n".join(f"• **{name}** — {addr}" for name, addr in sorted(addresses.items()))
515
+
516
+
517
+ # ── Gradio UI ───────────────────────────────────────────────────────────────
518
+
519
+ with gr.Blocks(title="Fee Note Generator", theme=gr.themes.Soft()) as demo:
520
+ gr.Markdown(
521
+ """
522
+ # Fee Note Generator
523
+
524
+ Paste pipe-delimited fee note data below. Each row generates a DOCX and PDF fee note from your template.
525
+
526
+ **Format** (header row is optional — it will be added automatically if missing):
527
+
528
+ ```
529
+ filename | solicitor_name | case_record | case_name | description | net_amount
530
+ ```
531
+
532
+ - **filename**: becomes the file name and fee note number (e.g. `20264402` → `20264402.docx` / `.pdf`)
533
+ - **solicitor_name**: matched against the solicitor directory (fuzzy matching handles minor variations)
534
+ - **net_amount**: numeric, e.g. `200` or `1000.00` — VAT (23%) and gross are calculated automatically
535
+ - **Date**: today's date is inserted automatically
536
+ """
537
+ )
538
+
539
+ with gr.Row():
540
+ csv_input = gr.Textbox(
541
+ lines=14,
542
+ label="Pipe-delimited input",
543
+ placeholder=(
544
+ "20264402 | O'Connor LLP | 2023/00700 | Permanent TSB PLC v Jeffrey Barrett | "
545
+ "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200\n"
546
+ "20264403 | Beauchamps LLP | 2023/00538 | Pepper Finance v Lynch | "
547
+ "Attendance, Limerick Circuit Court County Registrar 6.3.26, adjourned | 200"
548
+ ),
549
+ )
550
+
551
+ with gr.Row():
552
+ run_button = gr.Button("Generate Fee Notes", variant="primary", size="lg")
553
+
554
+ with gr.Row():
555
+ output_zip = gr.File(label="Download fee notes (.zip)")
556
+ status_output = gr.Markdown(label="Status")
557
+
558
+ run_button.click(
559
+ fn=generate_batch_fee_notes,
560
+ inputs=[csv_input],
561
+ outputs=[output_zip, status_output],
562
+ )
563
+
564
+ # ── Add New Solicitor section ──
565
+ gr.Markdown("---")
566
+ gr.Markdown("### Add New Solicitor")
567
+ gr.Markdown("Add a firm that isn't in the directory yet. It will be available immediately for the next generation run.")
568
+
569
+ with gr.Row():
570
+ with gr.Column():
571
+ new_firm_name = gr.Textbox(label="Firm Name", placeholder="e.g. MDM Solicitors LLP")
572
+ new_firm_address = gr.Textbox(label="Address", placeholder="e.g. 16 Lavitt's Quay, Cork, T12 ED74")
573
+ with gr.Column():
574
+ new_firm_domain = gr.Textbox(label="Domain (optional)", placeholder="e.g. mdmsolicitors.ie")
575
+ new_firm_email = gr.Textbox(label="Email routing notes (optional)", placeholder="e.g. returns@mdmsolicitors.ie")
576
+
577
+ add_button = gr.Button("Add Solicitor", size="sm")
578
+ add_result = gr.Markdown()
579
+
580
+ add_button.click(
581
+ fn=add_solicitor,
582
+ inputs=[new_firm_name, new_firm_address, new_firm_domain, new_firm_email],
583
+ outputs=[add_result],
584
+ )
585
+
586
+ # ── Current directory ──
587
+ gr.Markdown("---")
588
+ with gr.Accordion("View Current Solicitor Directory", open=False):
589
+ firms_display = gr.Markdown(value=get_current_firms)
590
+ refresh_btn = gr.Button("Refresh", size="sm")
591
+ refresh_btn.click(fn=get_current_firms, outputs=[firms_display])
592
+
593
+
594
+ demo.launch()