ChristopherJKoen commited on
Commit
8cc6d9b
·
2 Parent(s): e722874 b434fc2

Merge branch 'dev'

Browse files
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -36,37 +36,37 @@ type RatingTone = {
36
 
37
  const CATEGORY_SCALE: Record<string, RatingTone> = {
38
  "0": {
39
- label: "100% Original Strength",
40
  bg: "bg-green-100",
41
  text: "text-green-800",
42
  border: "border-green-200",
43
  },
44
  "1": {
45
- label: "100% Original Strength",
46
  bg: "bg-green-200",
47
  text: "text-green-800",
48
  border: "border-green-200",
49
  },
50
  "2": {
51
- label: "95-100% Original Strength",
52
  bg: "bg-yellow-100",
53
  text: "text-yellow-800",
54
  border: "border-yellow-200",
55
  },
56
  "3": {
57
- label: "75-95% Original Strength",
58
  bg: "bg-yellow-200",
59
  text: "text-yellow-800",
60
  border: "border-yellow-200",
61
  },
62
  "4": {
63
- label: "50-75% Original Strength",
64
  bg: "bg-orange-200",
65
  text: "text-orange-800",
66
  border: "border-orange-200",
67
  },
68
  "5": {
69
- label: "<50% Original Strength",
70
  bg: "bg-red-200",
71
  text: "text-red-800",
72
  border: "border-red-200",
@@ -75,31 +75,31 @@ const CATEGORY_SCALE: Record<string, RatingTone> = {
75
 
76
  const PRIORITY_SCALE: Record<string, RatingTone> = {
77
  "1": {
78
- label: "Immediate",
79
  bg: "bg-red-200",
80
  text: "text-red-800",
81
  border: "border-red-200",
82
  },
83
  "2": {
84
- label: "1 Year",
85
  bg: "bg-orange-200",
86
  text: "text-orange-800",
87
  border: "border-orange-200",
88
  },
89
  "3": {
90
- label: "3 Years",
91
  bg: "bg-green-200",
92
  text: "text-green-800",
93
  border: "border-green-200",
94
  },
95
  X: {
96
- label: "At Use",
97
  bg: "bg-purple-200",
98
  text: "text-purple-800",
99
  border: "border-purple-200",
100
  },
101
  M: {
102
- label: "Monitor",
103
  bg: "bg-blue-200",
104
  text: "text-blue-800",
105
  border: "border-blue-200",
@@ -111,7 +111,7 @@ function ratingKey(value: string) {
111
  if (!raw) return "";
112
  const match = raw.match(/^([0-9]|[xXmM])/);
113
  if (match) return match[1].toUpperCase();
114
- return raw.split("-")[0].trim().toUpperCase();
115
  }
116
 
117
  function formatRating(value: string, scale: Record<string, RatingTone>) {
@@ -120,12 +120,12 @@ function formatRating(value: string, scale: Record<string, RatingTone>) {
120
  const tone = key ? scale[key] : undefined;
121
  if (!tone) {
122
  return {
123
- text: raw || "-",
124
  className: "bg-gray-50 text-gray-700 border-gray-200",
125
  };
126
  }
127
  return {
128
- text: `${key} - ${tone.label}`,
129
  className: `${tone.bg} ${tone.text} ${tone.border}`,
130
  };
131
  }
 
36
 
37
  const CATEGORY_SCALE: Record<string, RatingTone> = {
38
  "0": {
39
+ label: "(100% Original Strength)",
40
  bg: "bg-green-100",
41
  text: "text-green-800",
42
  border: "border-green-200",
43
  },
44
  "1": {
45
+ label: "(100% Original Strength)",
46
  bg: "bg-green-200",
47
  text: "text-green-800",
48
  border: "border-green-200",
49
  },
50
  "2": {
51
+ label: "(95-100% Original Strength)",
52
  bg: "bg-yellow-100",
53
  text: "text-yellow-800",
54
  border: "border-yellow-200",
55
  },
56
  "3": {
57
+ label: "(75-95% Original Strength)",
58
  bg: "bg-yellow-200",
59
  text: "text-yellow-800",
60
  border: "border-yellow-200",
61
  },
62
  "4": {
63
+ label: "(50-75% Original Strength)",
64
  bg: "bg-orange-200",
65
  text: "text-orange-800",
66
  border: "border-orange-200",
67
  },
68
  "5": {
69
+ label: "(<50% Original Strength)",
70
  bg: "bg-red-200",
71
  text: "text-red-800",
72
  border: "border-red-200",
 
75
 
76
  const PRIORITY_SCALE: Record<string, RatingTone> = {
77
  "1": {
78
+ label: "(Immediate)",
79
  bg: "bg-red-200",
80
  text: "text-red-800",
81
  border: "border-red-200",
82
  },
83
  "2": {
84
+ label: "(1 Year)",
85
  bg: "bg-orange-200",
86
  text: "text-orange-800",
87
  border: "border-orange-200",
88
  },
89
  "3": {
90
+ label: "(3 Years)",
91
  bg: "bg-green-200",
92
  text: "text-green-800",
93
  border: "border-green-200",
94
  },
95
  X: {
96
+ label: "(At Use)",
97
  bg: "bg-purple-200",
98
  text: "text-purple-800",
99
  border: "border-purple-200",
100
  },
101
  M: {
102
+ label: "(Monitor)",
103
  bg: "bg-blue-200",
104
  text: "text-blue-800",
105
  border: "border-blue-200",
 
111
  if (!raw) return "";
112
  const match = raw.match(/^([0-9]|[xXmM])/);
113
  if (match) return match[1].toUpperCase();
114
+ return raw.split("")[0].trim().toUpperCase();
115
  }
116
 
117
  function formatRating(value: string, scale: Record<string, RatingTone>) {
 
120
  const tone = key ? scale[key] : undefined;
121
  if (!tone) {
122
  return {
123
+ text: raw || "",
124
  className: "bg-gray-50 text-gray-700 border-gray-200",
125
  };
126
  }
127
  return {
128
+ text: `${key} ${tone.label}`,
129
  className: `${tone.bg} ${tone.text} ${tone.border}`,
130
  };
131
  }
frontend/src/components/PdfPreviewPanel.tsx ADDED
@@ -0,0 +1 @@
 
 
1
+
frontend/src/components/report-editor.js CHANGED
@@ -2,20 +2,20 @@
2
  import * as feather from "feather-icons";
3
 
4
  const CATEGORY_SCALE = {
5
- "0": { label: "100% Original Strength", className: "bg-green-100 text-green-800 border-green-200" },
6
- "1": { label: "100% Original Strength", className: "bg-green-200 text-green-800 border-green-200" },
7
- "2": { label: "95-100% Original Strength", className: "bg-yellow-100 text-yellow-800 border-yellow-200" },
8
- "3": { label: "75-95% Original Strength", className: "bg-yellow-200 text-yellow-800 border-yellow-200" },
9
- "4": { label: "50-75% Original Strength", className: "bg-orange-200 text-orange-800 border-orange-200" },
10
- "5": { label: "<50% Original Strength", className: "bg-red-200 text-red-800 border-red-200" },
11
  };
12
 
13
  const PRIORITY_SCALE = {
14
- "1": { label: "Immediate", className: "bg-red-200 text-red-800 border-red-200" },
15
- "2": { label: "1 Year", className: "bg-orange-200 text-orange-800 border-orange-200" },
16
- "3": { label: "3 Years", className: "bg-green-200 text-green-800 border-green-200" },
17
- X: { label: "At Use", className: "bg-purple-200 text-purple-800 border-purple-200" },
18
- M: { label: "Monitor", className: "bg-blue-200 text-blue-800 border-blue-200" },
19
  };
20
 
21
  const MAX_PHOTOS_PRIMARY_PAGE = 2;
@@ -29,14 +29,14 @@ function parseScaleCode(value) {
29
  function buildScaleBadge(value, scale) {
30
  const raw = String(value || "").trim();
31
  if (!raw) {
32
- return { text: "-", className: "bg-gray-50 text-gray-700 border-gray-200" };
33
  }
34
  const code = parseScaleCode(raw);
35
  const tone = scale[code];
36
  if (!tone) {
37
  return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
38
  }
39
- return { text: `${code} - ${tone.label}`, className: tone.className };
40
  }
41
  class ReportEditor extends HTMLElement {
42
  constructor() {
@@ -1034,29 +1034,29 @@ class ReportEditor extends HTMLElement {
1034
  const requiredAction = template.required_action || "";
1035
 
1036
  const categoryScale = {
1037
- "0": { label: "Excellent (100%)", bg: "bg-green-100", text: "text-green-800", border: "border-green-200" },
1038
- "1": { label: "Good (100%)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
1039
- "2": { label: "Fair (95-100%)", bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200" },
1040
- "3": { label: "Unsatisfactory (75-95%)", bg: "bg-yellow-200", text: "text-yellow-800", border: "border-yellow-200" },
1041
- "4": { label: "Poor (50-75%)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
1042
- "5": { label: "Severe (<50%)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
1043
  };
1044
  const priorityScale = {
1045
- "1": { label: "Immediate", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
1046
- "2": { label: "1 Year", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
1047
- "3": { label: "3 Years", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
1048
- X: { label: "At Use", bg: "bg-purple-200", text: "text-purple-800", border: "border-purple-200" },
1049
- M: { label: "Monitor", bg: "bg-blue-200", text: "text-blue-800", border: "border-blue-200" },
1050
  };
1051
  const categoryBadge = this._ratingBadge(category, categoryScale);
1052
  const priorityBadge = this._ratingBadge(priority, priorityScale);
1053
  const categoryOptions = ["0", "1", "2", "3", "4", "5"].map((key) => ({
1054
  value: key,
1055
- label: `${key} - ${categoryScale[key].label}`,
1056
  }));
1057
  const priorityOptions = ["1", "2", "3", "X", "M"].map((key) => ({
1058
  value: key,
1059
- label: `${key} - ${priorityScale[key].label}`,
1060
  }));
1061
 
1062
  const variant =
@@ -1372,16 +1372,16 @@ class ReportEditor extends HTMLElement {
1372
  _ratingBadge(value, scale) {
1373
  const raw = String(value || "").trim();
1374
  if (!raw) {
1375
- return { text: "-", className: "bg-gray-50 text-gray-700 border-gray-200" };
1376
  }
1377
  const match = raw.match(/^([0-9]|[xXmM])/);
1378
- const key = match ? match[1].toUpperCase() : raw.split("-")[0].trim().toUpperCase();
1379
  const tone = scale[key];
1380
  if (!tone) {
1381
  return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
1382
  }
1383
  return {
1384
- text: `${key} - ${tone.label}`,
1385
  className: `${tone.bg} ${tone.text} ${tone.border}`,
1386
  };
1387
  }
 
2
  import * as feather from "feather-icons";
3
 
4
  const CATEGORY_SCALE = {
5
+ "0": { label: "(100% Original Strength)", className: "bg-green-100 text-green-800 border-green-200" },
6
+ "1": { label: "(100% Original Strength)", className: "bg-green-200 text-green-800 border-green-200" },
7
+ "2": { label: "(95-100% Original Strength)", className: "bg-yellow-100 text-yellow-800 border-yellow-200" },
8
+ "3": { label: "(75-95% Original Strength)", className: "bg-yellow-200 text-yellow-800 border-yellow-200" },
9
+ "4": { label: "(50-75% Original Strength)", className: "bg-orange-200 text-orange-800 border-orange-200" },
10
+ "5": { label: "(<50% Original Strength)", className: "bg-red-200 text-red-800 border-red-200" },
11
  };
12
 
13
  const PRIORITY_SCALE = {
14
+ "1": { label: "(Immediate)", className: "bg-red-200 text-red-800 border-red-200" },
15
+ "2": { label: "(1 Year)", className: "bg-orange-200 text-orange-800 border-orange-200" },
16
+ "3": { label: "(3 Years)", className: "bg-green-200 text-green-800 border-green-200" },
17
+ X: { label: "(At Use)", className: "bg-purple-200 text-purple-800 border-purple-200" },
18
+ M: { label: "(Monitor)", className: "bg-blue-200 text-blue-800 border-blue-200" },
19
  };
20
 
21
  const MAX_PHOTOS_PRIMARY_PAGE = 2;
 
29
  function buildScaleBadge(value, scale) {
30
  const raw = String(value || "").trim();
31
  if (!raw) {
32
+ return { text: "", className: "bg-gray-50 text-gray-700 border-gray-200" };
33
  }
34
  const code = parseScaleCode(raw);
35
  const tone = scale[code];
36
  if (!tone) {
37
  return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
38
  }
39
+ return { text: `${code} ${tone.label}`, className: tone.className };
40
  }
41
  class ReportEditor extends HTMLElement {
42
  constructor() {
 
1034
  const requiredAction = template.required_action || "";
1035
 
1036
  const categoryScale = {
1037
+ "0": { label: "(100% Original Strength)", bg: "bg-green-100", text: "text-green-800", border: "border-green-200" },
1038
+ "1": { label: "(100% Original Strength)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
1039
+ "2": { label: "(95-100% Original Strength)", bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200" },
1040
+ "3": { label: "(75-95% Original Strength)", bg: "bg-yellow-200", text: "text-yellow-800", border: "border-yellow-200" },
1041
+ "4": { label: "(50-75% Original Strength)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
1042
+ "5": { label: "(<50% Original Strength)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
1043
  };
1044
  const priorityScale = {
1045
+ "1": { label: "(Immediate)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
1046
+ "2": { label: "(1 Year)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
1047
+ "3": { label: "(3 Years)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
1048
+ X: { label: "(At Use)", bg: "bg-purple-200", text: "text-purple-800", border: "border-purple-200" },
1049
+ M: { label: "(Monitor)", bg: "bg-blue-200", text: "text-blue-800", border: "border-blue-200" },
1050
  };
1051
  const categoryBadge = this._ratingBadge(category, categoryScale);
1052
  const priorityBadge = this._ratingBadge(priority, priorityScale);
1053
  const categoryOptions = ["0", "1", "2", "3", "4", "5"].map((key) => ({
1054
  value: key,
1055
+ label: `${key} ${categoryScale[key].label}`,
1056
  }));
1057
  const priorityOptions = ["1", "2", "3", "X", "M"].map((key) => ({
1058
  value: key,
1059
+ label: `${key} ${priorityScale[key].label}`,
1060
  }));
1061
 
1062
  const variant =
 
1372
  _ratingBadge(value, scale) {
1373
  const raw = String(value || "").trim();
1374
  if (!raw) {
1375
+ return { text: "", className: "bg-gray-50 text-gray-700 border-gray-200" };
1376
  }
1377
  const match = raw.match(/^([0-9]|[xXmM])/);
1378
+ const key = match ? match[1].toUpperCase() : raw.split("")[0].trim().toUpperCase();
1379
  const tone = scale[key];
1380
  if (!tone) {
1381
  return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
1382
  }
1383
  return {
1384
+ text: `${key} ${tone.label}`,
1385
  className: `${tone.bg} ${tone.text} ${tone.border}`,
1386
  };
1387
  }
frontend/src/pages/EditReportPage.tsx CHANGED
@@ -96,7 +96,9 @@ export default function EditReportPage() {
96
  void saveAndNavigate(`/report-viewer${sessionQuery}`);
97
  };
98
  editorEl.addEventListener("editor-closed", handleClose);
99
- return () => editorEl.removeEventListener("editor-closed", handleClose);
 
 
100
  }, [editorEl, saveAndNavigate, sessionQuery]);
101
 
102
  useEffect(() => {
 
96
  void saveAndNavigate(`/report-viewer${sessionQuery}`);
97
  };
98
  editorEl.addEventListener("editor-closed", handleClose);
99
+ return () => {
100
+ editorEl.removeEventListener("editor-closed", handleClose);
101
+ };
102
  }, [editorEl, saveAndNavigate, sessionQuery]);
103
 
104
  useEffect(() => {
server/app/services/pdf_export.py CHANGED
@@ -1,59 +1,42 @@
1
- from __future__ import annotations
2
 
3
- import asyncio
4
- import sys
5
  from pathlib import Path
6
 
7
- from playwright.async_api import async_playwright
8
 
9
  from ..core.config import get_settings
10
 
11
 
12
- async def _render_pdf_async(session_id: str, output_path: Path) -> Path:
13
  settings = get_settings()
14
  base_url = settings.frontend_base_url.rstrip("/")
15
  url = f"{base_url}/print/report?session={session_id}"
16
 
17
  output_path.parent.mkdir(parents=True, exist_ok=True)
18
 
19
- async with async_playwright() as playwright:
20
- browser = await playwright.chromium.launch(headless=True, args=["--no-sandbox"])
21
- context = await browser.new_context()
22
- page = await context.new_page()
23
- await page.goto(url, wait_until="networkidle", timeout=settings.pdf_timeout_ms)
24
- await page.wait_for_selector(
25
- '[data-print-ready="true"]', timeout=settings.pdf_timeout_ms
 
26
  )
27
- await page.wait_for_function("document.fonts && document.fonts.ready")
28
- await page.wait_for_function(
29
  "Array.from(document.images || []).every(img => img.complete)"
30
  )
31
 
32
- await page.pdf(
33
  path=str(output_path),
34
  format="A4",
35
  print_background=True,
36
  prefer_css_page_size=True,
37
- margin={"top": "0mm", "right": "0mm", "bottom": "0mm", "left": "0mm"},
38
  )
39
 
40
- await context.close()
41
- await browser.close()
42
 
43
  return output_path
44
-
45
-
46
- def _run_with_proactor(coro: asyncio.Future) -> Path:
47
- if sys.platform.startswith("win"):
48
- loop = asyncio.ProactorEventLoop()
49
- try:
50
- asyncio.set_event_loop(loop)
51
- return loop.run_until_complete(coro)
52
- finally:
53
- loop.close()
54
- asyncio.set_event_loop(None)
55
- return asyncio.run(coro)
56
-
57
-
58
- def render_pdf_sync(session_id: str, output_path: Path) -> Path:
59
- return _run_with_proactor(_render_pdf_async(session_id, output_path))
 
1
+ from __future__ import annotations
2
 
 
 
3
  from pathlib import Path
4
 
5
+ from playwright.sync_api import sync_playwright
6
 
7
  from ..core.config import get_settings
8
 
9
 
10
+ def render_pdf(session_id: str, output_path: Path) -> Path:
11
  settings = get_settings()
12
  base_url = settings.frontend_base_url.rstrip("/")
13
  url = f"{base_url}/print/report?session={session_id}"
14
 
15
  output_path.parent.mkdir(parents=True, exist_ok=True)
16
 
17
+ with sync_playwright() as playwright:
18
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
19
+ context = browser.new_context()
20
+ page = context.new_page()
21
+ page.goto(url, wait_until="networkidle", timeout=settings.pdf_timeout_ms)
22
+ page.wait_for_selector(
23
+ '[data-print-ready="true"]',
24
+ timeout=settings.pdf_timeout_ms,
25
  )
26
+ page.wait_for_function("document.fonts && document.fonts.ready")
27
+ page.wait_for_function(
28
  "Array.from(document.images || []).every(img => img.complete)"
29
  )
30
 
31
+ page.pdf(
32
  path=str(output_path),
33
  format="A4",
34
  print_background=True,
35
  prefer_css_page_size=True,
36
+ margin={"top": "10mm", "right": "10mm", "bottom": "10mm", "left": "10mm"},
37
  )
38
 
39
+ context.close()
40
+ browser.close()
41
 
42
  return output_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/app/services/pdf_reportlab.py CHANGED
@@ -185,8 +185,8 @@ def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Col
185
  key = match.group(1)
186
  tone = scale.get(key)
187
  if not tone:
188
- return (raw or "-"), colors.HexColor("#f9fafb"), colors.HexColor("#374151")
189
- return f"{key} - {tone['label']}", tone["bg"], tone["text"]
190
 
191
 
192
  def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional[Path]:
@@ -579,19 +579,19 @@ def render_report_pdf(
579
  category = _safe_text(template.get("category"))
580
  priority = _safe_text(template.get("priority"))
581
  cat_scale = {
582
- "0": {"label": "100% Original Strength", "bg": green_100, "text": colors.HexColor("#166534")},
583
- "1": {"label": "100% Original Strength", "bg": green_200, "text": colors.HexColor("#166534")},
584
- "2": {"label": "95-100% Original Strength", "bg": yellow_100, "text": colors.HexColor("#854d0e")},
585
- "3": {"label": "75-95% Original Strength", "bg": yellow_200, "text": colors.HexColor("#854d0e")},
586
- "4": {"label": "50-75% Original Strength", "bg": orange_200, "text": colors.HexColor("#9a3412")},
587
- "5": {"label": "<50% Original Strength", "bg": red_200, "text": colors.HexColor("#991b1b")},
588
  }
589
  pr_scale = {
590
- "1": {"label": "Immediate", "bg": red_200, "text": colors.HexColor("#991b1b")},
591
- "2": {"label": "1 Year", "bg": orange_200, "text": colors.HexColor("#9a3412")},
592
- "3": {"label": "3 Years", "bg": green_200, "text": colors.HexColor("#166534")},
593
- "X": {"label": "At Use", "bg": purple_200, "text": purple_800},
594
- "M": {"label": "Monitor", "bg": blue_200, "text": blue_900},
595
  }
596
  cat_text, cat_bg, cat_text_color = _badge_style(category, cat_scale)
597
  pr_text, pr_bg, pr_text_color = _badge_style(priority, pr_scale)
@@ -662,7 +662,7 @@ def render_report_pdf(
662
  pdf.setStrokeColor(gray_200)
663
  cond_lines = _wrap_lines(
664
  pdf,
665
- condition or "-",
666
  width - 2 * margin - 4 * mm,
667
  4,
668
  "Helvetica",
@@ -698,7 +698,7 @@ def render_report_pdf(
698
  pdf.setStrokeColor(gray_200)
699
  action_lines = _wrap_lines(
700
  pdf,
701
- action or "-",
702
  width - 2 * margin - 4 * mm,
703
  4,
704
  "Helvetica",
 
185
  key = match.group(1)
186
  tone = scale.get(key)
187
  if not tone:
188
+ return (raw or ""), colors.HexColor("#f9fafb"), colors.HexColor("#374151")
189
+ return f"{key} {tone['label']}", tone["bg"], tone["text"]
190
 
191
 
192
  def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional[Path]:
 
579
  category = _safe_text(template.get("category"))
580
  priority = _safe_text(template.get("priority"))
581
  cat_scale = {
582
+ "0": {"label": "(100% Original Strength)", "bg": green_100, "text": colors.HexColor("#166534")},
583
+ "1": {"label": "(100% Original Strength)", "bg": green_200, "text": colors.HexColor("#166534")},
584
+ "2": {"label": "(95-100% Original Strength)", "bg": yellow_100, "text": colors.HexColor("#854d0e")},
585
+ "3": {"label": "(75-95% Original Strength)", "bg": yellow_200, "text": colors.HexColor("#854d0e")},
586
+ "4": {"label": "(50-75% Original Strength)", "bg": orange_200, "text": colors.HexColor("#9a3412")},
587
+ "5": {"label": "(<50% Original Strength)", "bg": red_200, "text": colors.HexColor("#991b1b")},
588
  }
589
  pr_scale = {
590
+ "1": {"label": "(Immediate)", "bg": red_200, "text": colors.HexColor("#991b1b")},
591
+ "2": {"label": "(1 Year)", "bg": orange_200, "text": colors.HexColor("#9a3412")},
592
+ "3": {"label": "(3 Years)", "bg": green_200, "text": colors.HexColor("#166534")},
593
+ "X": {"label": "(At Use)", "bg": purple_200, "text": purple_800},
594
+ "M": {"label": "(Monitor)", "bg": blue_200, "text": blue_900},
595
  }
596
  cat_text, cat_bg, cat_text_color = _badge_style(category, cat_scale)
597
  pr_text, pr_bg, pr_text_color = _badge_style(priority, pr_scale)
 
662
  pdf.setStrokeColor(gray_200)
663
  cond_lines = _wrap_lines(
664
  pdf,
665
+ condition or "",
666
  width - 2 * margin - 4 * mm,
667
  4,
668
  "Helvetica",
 
698
  pdf.setStrokeColor(gray_200)
699
  action_lines = _wrap_lines(
700
  pdf,
701
+ action or "",
702
  width - 2 * margin - 4 * mm,
703
  4,
704
  "Helvetica",
server/app/services/session_store.py CHANGED
@@ -11,6 +11,7 @@ from typing import Iterable, List, Optional
11
  from uuid import uuid4
12
 
13
  from fastapi import UploadFile
 
14
 
15
  from ..core.config import get_settings
16
 
@@ -18,6 +19,7 @@ from ..core.config import get_settings
18
  IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
19
  DOC_EXTS = {".pdf", ".doc", ".docx"}
20
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
 
21
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
22
  BUILTIN_PAGE_TEMPLATES = [
23
  {
@@ -82,6 +84,27 @@ def _category_for(filename: str) -> str:
82
  return "documents"
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def _validate_session_id(session_id: str) -> str:
86
  if not session_id:
87
  raise ValueError("Invalid session id.")
@@ -585,6 +608,9 @@ class SessionStore:
585
  handle.write(chunk)
586
 
587
  category = _category_for(filename)
 
 
 
588
  return StoredFile(
589
  id=file_id,
590
  name=filename,
 
11
  from uuid import uuid4
12
 
13
  from fastapi import UploadFile
14
+ from PIL import Image, ImageOps, UnidentifiedImageError
15
 
16
  from ..core.config import get_settings
17
 
 
19
  IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
20
  DOC_EXTS = {".pdf", ".doc", ".docx"}
21
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
22
+ EXIF_NORMALIZE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
23
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
24
  BUILTIN_PAGE_TEMPLATES = [
25
  {
 
84
  return "documents"
85
 
86
 
87
+ def _normalize_uploaded_photo(path: Path) -> None:
88
+ ext = path.suffix.lower()
89
+ if ext not in EXIF_NORMALIZE_EXTS:
90
+ return
91
+
92
+ try:
93
+ with Image.open(path) as image:
94
+ normalized = ImageOps.exif_transpose(image)
95
+ format_name = image.format
96
+ if normalized.mode in ("RGBA", "LA", "P") and format_name in {"JPEG", "JPG"}:
97
+ normalized = normalized.convert("RGB")
98
+ save_kwargs = {"exif": b""}
99
+ if format_name in {"JPEG", "JPG"}:
100
+ save_kwargs["quality"] = 95
101
+ save_kwargs["optimize"] = True
102
+ normalized.save(path, format=format_name, **save_kwargs)
103
+ except (UnidentifiedImageError, OSError, ValueError, TypeError):
104
+ # Keep original bytes if the file cannot be decoded by Pillow.
105
+ return
106
+
107
+
108
  def _validate_session_id(session_id: str) -> str:
109
  if not session_id:
110
  raise ValueError("Invalid session id.")
 
608
  handle.write(chunk)
609
 
610
  category = _category_for(filename)
611
+ if category == "photos":
612
+ _normalize_uploaded_photo(dest)
613
+ size = dest.stat().st_size
614
  return StoredFile(
615
  id=file_id,
616
  name=filename,