diff --git a/package.json b/package.json index 081d1b2..73ef9f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fieldlens/print", - "version": "0.1.19", + "version": "0.1.20", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae70a4a..86830f3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fieldlens-print" -version = "0.1.19" +version = "0.1.20" description = "FieldLens print-stream barcode capture companion" authors = ["Arrowstack"] edition = "2021" diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 5de9d19..48c34cc 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -20,6 +20,7 @@ use uuid::Uuid; use crate::{ model::{AppConfig, CaptureMode, JobSummary}, + pallet::parse_pallet_snapshot, state::SharedState, zpl::extract_barcodes, }; @@ -178,6 +179,7 @@ fn raw_proxy_loop( label: "listener".into(), bytes: 0, barcodes: vec![], + pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded: false, error: Some(format!("accept failed: {error}")), @@ -437,6 +439,7 @@ fn process_captured_pdf( label: label.to_string(), bytes, barcodes: vec![], + pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded, error: Some(format!("archive captured PDF failed: {error}")), @@ -451,6 +454,7 @@ fn process_captured_pdf( label: label.to_string(), bytes, barcodes: vec![], + pallet_print: None, captured_at: chrono::Utc::now().to_rfc3339(), forwarded, error: None, @@ -483,22 +487,38 @@ fn process_captured_pdf( match decode_pdf_page(&target, page, &id) { Ok(found) => { + let primary = found.first().cloned(); + for barcode in found { if job.barcodes.iter().any(|existing| existing.format == barcode.format && existing.value == barcode.value ) { continue; } - - state.queue_scan( - barcode.value.clone(), - barcode.format.clone(), - "print".into(), - &id, - label, - ); job.barcodes.push(barcode); } + + if job.pallet_print.is_none() { + if let Some(barcode) = primary.or_else(|| job.barcodes.first().cloned()) { + let text = extract_pdf_page_text(&target, page).unwrap_or_default(); + let snapshot = parse_pallet_snapshot(&text, &barcode.value); + let comparison = state.record_print_event( + format!("{source}:{label}"), + &id, + &job.captured_at, + snapshot, + ); + state.queue_print_scan( + barcode.value, + barcode.format, + &id, + label, + &comparison, + ); + job.pallet_print = Some(comparison); + } + } + // Upsert the same job after each page so barcode results can // appear incrementally without creating duplicate jobs. state.push_job(app, job.clone()); @@ -553,6 +573,37 @@ fn pdf_page_count(path: &std::path::Path) -> Result { Ok(pages) } +#[cfg(target_os = "linux")] +fn extract_pdf_page_text(path: &std::path::Path, page: u32) -> Result { + let output = Command::new("pdftotext") + .args([ + "-f", + &page.to_string(), + "-l", + &page.to_string(), + "-layout", + ]) + .arg(path) + .arg("-") + .output() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "PDF text extraction requires poppler-utils (pdftotext)".to_string() + } else { + format!("run pdftotext: {error}") + } + })?; + + if !output.status.success() { + return Err(format!( + "pdftotext failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + #[cfg(target_os = "linux")] fn decode_pdf_page( path: &std::path::Path, @@ -775,16 +826,25 @@ fn process_payload( ), }; let error = error.or(parse_error); - - for barcode in &barcodes { - state.queue_scan( + let captured_at = chrono::Utc::now().to_rfc3339(); + let pallet_print = barcodes.first().map(|barcode| { + let text = String::from_utf8_lossy(&bytes); + let snapshot = parse_pallet_snapshot(&text, &barcode.value); + let comparison = state.record_print_event( + format!("{source}:{id}"), + &id, + &captured_at, + snapshot, + ); + state.queue_print_scan( barcode.value.clone(), barcode.format.clone(), - "print".into(), &id, &label, + &comparison, ); - } + comparison + }); state.push_job( app, @@ -794,7 +854,8 @@ fn process_payload( label, bytes: bytes.len(), barcodes, - captured_at: chrono::Utc::now().to_rfc3339(), + pallet_print, + captured_at, forwarded, error, }, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c6c3bc..48cf69d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod capture; mod fieldlens; mod model; +mod pallet; mod printers; mod state; mod updates; diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 8a107b4..5e4931a 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -58,6 +58,63 @@ pub struct DecodedBarcode { pub value: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct PalletSnapshot { + pub pallet_id: String, + pub origin: Option, + pub variety: Option, + pub brand: Option, + pub class: Option, + pub base: Option, + pub pack: Option, + pub grower: Option, + pub packrun: Option, + pub size: Option, + pub qty: Option, + pub pick_date: Option, + pub pack_date: Option, + pub total: Option, + pub printed_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PalletFieldChange { + pub field: String, + pub previous: String, + pub current: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum PalletPrintStatus { + NewPallet, + ReprintSame, + ReprintChanged, + ReprintUnreadable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PalletPrintComparison { + pub pallet_id: String, + pub status: PalletPrintStatus, + pub occurrence: usize, + pub previous_captured_at: Option, + pub changes: Vec, + pub snapshot: PalletSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PalletPrintRecord { + pub source_key: String, + pub job_id: String, + pub captured_at: String, + pub comparison: PalletPrintComparison, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JobSummary { @@ -66,6 +123,7 @@ pub struct JobSummary { pub label: String, pub bytes: usize, pub barcodes: Vec, + pub pallet_print: Option, pub captured_at: String, pub forwarded: bool, pub error: Option, diff --git a/src-tauri/src/pallet.rs b/src-tauri/src/pallet.rs new file mode 100644 index 0000000..b4417b2 --- /dev/null +++ b/src-tauri/src/pallet.rs @@ -0,0 +1,207 @@ +use regex::Regex; + +use crate::model::{PalletFieldChange, PalletSnapshot}; + +fn capture(text: &str, pattern: &str, group: usize) -> Option { + let regex = Regex::new(pattern).ok()?; + let value = regex.captures(text)?.get(group)?.as_str(); + clean(value) +} + +fn clean(value: &str) -> Option { + let collapsed = value.split_whitespace().collect::>().join(" "); + let trimmed = collapsed.trim().trim_matches(|ch: char| matches!(ch, ',' | ';')).trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.trim_end_matches('.').to_string()) + } +} + +fn canonical(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':')) + .to_ascii_uppercase() +} + +pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot { + let flat = raw_text.split_whitespace().collect::>().join(" "); + let mut snapshot = PalletSnapshot { + pallet_id: pallet_id.trim().to_string(), + ..PalletSnapshot::default() + }; + + snapshot.pick_date = capture( + &flat, + r"(?i)\bPick\s+Date\s*:\s*([0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4})", + 1, + ); + snapshot.pack_date = capture( + &flat, + r"(?i)\bPack\s+Date\s*:\s*([0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4})", + 1, + ); + snapshot.printed_time = capture( + &flat, + r"(?i)\bTime\s*:\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)", + 1, + ); + snapshot.total = capture(&flat, r"(?i)\bTotal\s+([0-9]{1,5})\b", 1); + + if let Ok(regex) = Regex::new( + r"(?i)\bGrower\s+Packrun\s+Size\s+Qty\s+([A-Z0-9-]+)\s+([A-Z0-9/._-]+)\s+([0-9]{1,3})\s+([0-9]{1,5})\b", + ) { + if let Some(captures) = regex.captures(&flat) { + snapshot.grower = captures.get(1).and_then(|m| clean(m.as_str())); + snapshot.packrun = captures.get(2).and_then(|m| clean(m.as_str())); + snapshot.size = captures.get(3).and_then(|m| clean(m.as_str())); + snapshot.qty = captures.get(4).and_then(|m| clean(m.as_str())); + } + } + + if let Ok(regex) = Regex::new( + r"(?i)\bORIGIN\s+VAR\s+BRAND\s+CLASS(?:\s+Pallet\s*:\s*[A-Z0-9-]+)?\s+([A-Z]{2,3})\s+([A-Z][A-Z -]{1,20}?)\s+([A-Z0-9]{2,12})\s+(CLASS\s*\.?\s*[0-9]+\.?)(?:\s|$)", + ) { + if let Some(captures) = regex.captures(&flat) { + snapshot.origin = captures.get(1).and_then(|m| clean(m.as_str())); + snapshot.variety = captures.get(2).and_then(|m| clean(m.as_str())); + snapshot.brand = captures.get(3).and_then(|m| clean(m.as_str())); + snapshot.class = captures.get(4).and_then(|m| clean(m.as_str())); + } + } + + if snapshot.variety.is_none() { + snapshot.variety = capture( + &flat, + r"(?i)\bAvocados?\s*-\s*([A-Z][A-Z -]{1,20}?)(?=\s+(?:ORIGIN|Pallet|Grower|Pick|Pack|Time|Total)\b|$)", + 1, + ); + } + + if let Ok(regex) = Regex::new( + r"(?i)\bBASE\s+PACK\s+([A-Z]{2,12})\s+([A-Z0-9-]{2,12})\b", + ) { + if let Some(captures) = regex.captures(&flat) { + snapshot.base = captures.get(1).and_then(|m| clean(m.as_str())); + snapshot.pack = captures.get(2).and_then(|m| clean(m.as_str())); + } + } + + snapshot +} + +fn comparable_pairs<'a>( + previous: &'a PalletSnapshot, + current: &'a PalletSnapshot, +) -> [(&'static str, &'a Option, &'a Option); 13] { + [ + ("origin", &previous.origin, ¤t.origin), + ("variety", &previous.variety, ¤t.variety), + ("brand", &previous.brand, ¤t.brand), + ("class", &previous.class, ¤t.class), + ("base", &previous.base, ¤t.base), + ("pack", &previous.pack, ¤t.pack), + ("grower", &previous.grower, ¤t.grower), + ("packrun", &previous.packrun, ¤t.packrun), + ("size", &previous.size, ¤t.size), + ("qty", &previous.qty, ¤t.qty), + ("pickDate", &previous.pick_date, ¤t.pick_date), + ("packDate", &previous.pack_date, ¤t.pack_date), + ("total", &previous.total, ¤t.total), + ] +} + +pub fn comparable_field_count(previous: &PalletSnapshot, current: &PalletSnapshot) -> usize { + comparable_pairs(previous, current) + .into_iter() + .filter(|(_, before, after)| before.is_some() && after.is_some()) + .count() +} + +pub fn diff_snapshots( + previous: &PalletSnapshot, + current: &PalletSnapshot, +) -> Vec { + comparable_pairs(previous, current) + .into_iter() + .filter_map(|(field, before, after)| { + let before = before.as_ref()?; + let after = after.as_ref()?; + if canonical(before) == canonical(after) { + return None; + } + Some(PalletFieldChange { + field: field.to_string(), + previous: before.clone(), + current: after.clone(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const CARD: &str = r#" + Avocados - Hass + ORIGIN VAR BRAND CLASS + NZ Hass NZAG CLASS 3. + BASE PACK + CHEP B37 + Pick Date: 16/09/2026 + Pack Date: 17/09/2026 + Time: 14:46:10 + Pallet: 40595983 + Grower Packrun Size Qty + 89930 89930/16/17/26 20 30 + Total 30 + "#; + + #[test] + fn parses_export_card_fields() { + let snapshot = parse_pallet_snapshot(CARD, "40595983"); + assert_eq!(snapshot.pallet_id, "40595983"); + assert_eq!(snapshot.origin.as_deref(), Some("NZ")); + assert_eq!(snapshot.variety.as_deref(), Some("Hass")); + assert_eq!(snapshot.brand.as_deref(), Some("NZAG")); + assert_eq!(snapshot.class.as_deref(), Some("CLASS 3")); + assert_eq!(snapshot.base.as_deref(), Some("CHEP")); + assert_eq!(snapshot.pack.as_deref(), Some("B37")); + assert_eq!(snapshot.grower.as_deref(), Some("89930")); + assert_eq!(snapshot.packrun.as_deref(), Some("89930/16/17/26")); + assert_eq!(snapshot.size.as_deref(), Some("20")); + assert_eq!(snapshot.qty.as_deref(), Some("30")); + assert_eq!(snapshot.total.as_deref(), Some("30")); + assert_eq!(snapshot.printed_time.as_deref(), Some("14:46:10")); + } + + #[test] + fn ignores_print_time_but_reports_business_field_changes() { + let previous = parse_pallet_snapshot(CARD, "40595983"); + let changed = CARD + .replace("Time: 14:46:10", "Time: 15:07:42") + .replace("20 30\n Total 30", "20 28\n Total 28"); + let current = parse_pallet_snapshot(&changed, "40595983"); + let diff = diff_snapshots(&previous, ¤t); + + assert_eq!(diff.len(), 2); + assert!(diff.iter().any(|item| item.field == "qty" && item.current == "28")); + assert!(diff.iter().any(|item| item.field == "total" && item.current == "28")); + } + + #[test] + fn missing_extraction_is_not_treated_as_a_changed_value() { + let previous = parse_pallet_snapshot(CARD, "40595983"); + let current = PalletSnapshot { + pallet_id: "40595983".into(), + ..PalletSnapshot::default() + }; + + assert_eq!(comparable_field_count(&previous, ¤t), 0); + assert!(diff_snapshots(&previous, ¤t).is_empty()); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 98746bf..ddfa29f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -11,15 +11,23 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::{AppHandle, Emitter}; -use crate::model::{AppConfig, AuthState, JobSummary, ScanEvent}; +use crate::{ + model::{ + AppConfig, AuthState, JobSummary, PalletPrintComparison, PalletPrintRecord, + PalletPrintStatus, PalletSnapshot, ScanEvent, + }, + pallet::{comparable_field_count, diff_snapshots}, +}; const MAX_JOBS: usize = 200; +const MAX_PRINT_HISTORY: usize = 5000; #[derive(Clone)] pub struct SharedState { pub config: Arc>, pub jobs: Arc>>, pub pending: Arc>>, + pub print_history: Arc>>, pub auth: Arc>, pub data_dir: PathBuf, } @@ -38,6 +46,9 @@ impl SharedState { fs::create_dir_all(&config.spool_dir).ok(); let pending = read_json::>(&data_dir.join("pending.json")).unwrap_or_default(); + let print_history = + read_json::>(&data_dir.join("print-history.json")) + .unwrap_or_default(); let persisted_auth = read_json::(&data_dir.join("auth.json")).unwrap_or_default(); let auth = AuthState { @@ -49,6 +60,7 @@ impl SharedState { config: Arc::new(Mutex::new(config)), jobs: Arc::new(Mutex::new(VecDeque::new())), pending: Arc::new(Mutex::new(pending)), + print_history: Arc::new(Mutex::new(print_history)), auth: Arc::new(Mutex::new(auth)), data_dir, }; @@ -64,6 +76,13 @@ impl SharedState { write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock()) } + pub fn persist_print_history(&self) -> Result<()> { + write_json_atomic( + &self.data_dir.join("print-history.json"), + &*self.print_history.lock(), + ) + } + pub fn persist_auth(&self) -> Result<()> { let path = self.data_dir.join("auth.json"); let persisted = PersistedAuth { @@ -107,30 +126,89 @@ impl SharedState { let _ = app.emit("capture-job", &job); } - pub fn queue_scan( + pub fn record_print_event( + &self, + source_key: String, + job_id: &str, + captured_at: &str, + snapshot: PalletSnapshot, + ) -> PalletPrintComparison { + let mut history = self.print_history.lock(); + + if let Some(existing) = history.iter().find(|record| record.source_key == source_key) { + return existing.comparison.clone(); + } + + let previous = history + .iter() + .rev() + .find(|record| record.comparison.pallet_id == snapshot.pallet_id); + let occurrence = history + .iter() + .filter(|record| record.comparison.pallet_id == snapshot.pallet_id) + .count() + + 1; + + let (status, previous_captured_at, changes) = if let Some(previous) = previous { + let changes = diff_snapshots(&previous.comparison.snapshot, &snapshot); + let comparable = comparable_field_count(&previous.comparison.snapshot, &snapshot); + let status = if !changes.is_empty() { + PalletPrintStatus::ReprintChanged + } else if comparable > 0 { + PalletPrintStatus::ReprintSame + } else { + PalletPrintStatus::ReprintUnreadable + }; + (status, Some(previous.captured_at.clone()), changes) + } else { + (PalletPrintStatus::NewPallet, None, Vec::new()) + }; + + let comparison = PalletPrintComparison { + pallet_id: snapshot.pallet_id.clone(), + status, + occurrence, + previous_captured_at, + changes, + snapshot, + }; + history.push(PalletPrintRecord { + source_key, + job_id: job_id.to_string(), + captured_at: captured_at.to_string(), + comparison: comparison.clone(), + }); + if history.len() > MAX_PRINT_HISTORY { + let overflow = history.len() - MAX_PRINT_HISTORY; + history.drain(0..overflow); + } + drop(history); + let _ = self.persist_print_history(); + comparison + } + + pub fn queue_print_scan( &self, raw_value: String, format: String, - source: String, job_id: &str, label: &str, + comparison: &PalletPrintComparison, ) -> ScanEvent { - let mut pending = self.pending.lock(); - let duplicate = pending.iter().any(|event| event.raw_value == raw_value); let event = ScanEvent { id: uuid::Uuid::new_v4().to_string(), raw_value, format, - duplicate, + duplicate: !matches!(comparison.status, PalletPrintStatus::NewPallet), captured_at: chrono::Utc::now().to_rfc3339(), - source, + source: "print".into(), source_metadata: json!({ "jobId": job_id, "label": label, + "palletPrint": comparison, }), }; - pending.push(event.clone()); - drop(pending); + self.pending.lock().push(event.clone()); let _ = self.persist_pending(); event } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7555760..9326a77 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FieldLens Print", - "version": "0.1.19", + "version": "0.1.20", "identifier": "dev.arrowstack.fieldlens.print", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/main.ts b/src/main.ts index 70332d5..1293be7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,12 +38,29 @@ type VirtualPrinterStatus = { type Barcode = { format: string; value: string } +type PalletPrintStatus = 'new-pallet' | 'reprint-same' | 'reprint-changed' | 'reprint-unreadable' + +type PalletFieldChange = { + field: string + previous: string + current: string +} + +type PalletPrintComparison = { + palletId: string + status: PalletPrintStatus + occurrence: number + previousCapturedAt?: string | null + changes: PalletFieldChange[] +} + type Job = { id: string source: string label: string bytes: number barcodes: Barcode[] + palletPrint?: PalletPrintComparison | null capturedAt: string forwarded: boolean error?: string | null @@ -124,7 +141,7 @@ let config: AppConfig let jobs: Job[] = [] let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 } let loginTimer: number | undefined -let currentVersion = '0.1.19' +let currentVersion = '0.1.20' let bundleType: BundleType | null = null let availableUpdate: Update | null = null let availableManualUpdate: ManualUpdate | null = null @@ -563,6 +580,32 @@ function friendlySource(source: string) { return source } +function fieldLabel(field: string) { + const labels: Record = { + origin: 'Origin', + variety: 'Variety', + brand: 'Brand', + class: 'Class', + base: 'Base', + pack: 'Pack', + grower: 'Grower', + packrun: 'Packrun', + size: 'Size', + qty: 'Qty', + pickDate: 'Pick date', + packDate: 'Pack date', + total: 'Total', + } + return labels[field] ?? field +} + +function palletStatusLabel(status: PalletPrintStatus) { + if (status === 'new-pallet') return 'First print' + if (status === 'reprint-same') return 'Reprint · same' + if (status === 'reprint-changed') return 'Reprint · changed' + return 'Reprint · diff unavailable' +} + function render() { const pill = el('status-pill') const captureLive = (status?.running ?? false) && !captureError @@ -588,9 +631,28 @@ function render() { const isTest = job.source === 'cups-test' const routeText = isTest ? 'No paper' : job.forwarded ? 'Printed' : 'Captured' const routeClass = isTest ? 'test' : job.forwarded ? 'success' : 'neutral' - const decodeText = job.barcodes.length - ? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected` - : 'Captured · no barcode decoded yet' + const decodeText = job.palletPrint + ? `Pallet ${job.palletPrint.palletId}` + : job.barcodes.length + ? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected` + : 'Captured · no barcode decoded yet' + const palletPrint = job.palletPrint + const palletBadge = palletPrint + ? `${escapeHtml(palletStatusLabel(palletPrint.status))}` + : '' + const palletDetail = palletPrint + ? ` +
+ Print #${palletPrint.occurrence} + ${palletPrint.previousCapturedAt ? `Previous ${new Date(palletPrint.previousCapturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''} +
+ ${palletPrint.changes.length + ? `
${palletPrint.changes.map((change) => ` + ${escapeHtml(fieldLabel(change.field))} ${escapeHtml(change.previous)} → ${escapeHtml(change.current)} + `).join('')}
` + : ''} + ` + : '' return `
@@ -598,9 +660,11 @@ function render() {
${escapeHtml(source)} ${routeText} + ${palletBadge}
${decodeText} + ${palletDetail}
${job.barcodes.map((code) => `${escapeHtml(code.format)}${escapeHtml(code.value)}`).join('')}
${job.error ? `
${escapeHtml(job.error)}
` : ''} diff --git a/src/styles.css b/src/styles.css index 2c78d66..36bbcb8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -445,6 +445,50 @@ select:focus { .route-badge.test { background: #172c43; color: #9fcef5; } .route-badge.neutral { background: #2a2519; color: #e3c47d; } +.pallet-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + border-radius: 999px; + padding: 0 8px; + font-size: 9px; + font-weight: 800; +} + +.pallet-badge.new-pallet { background: #123225; color: #8ce1b1; } +.pallet-badge.reprint-same { background: #2a2519; color: #e3c47d; } +.pallet-badge.reprint-changed { background: #411d1d; color: #ffb0b0; } +.pallet-badge.reprint-unreadable { background: #242a33; color: #b6c0cc; } + +.pallet-print-detail { + display: flex; + gap: 10px; + margin: -2px 0 7px; + color: #667386; + font-size: 9.5px; +} + +.pallet-diff { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 8px; +} + +.pallet-diff span { + border: 1px solid #573232; + border-radius: 7px; + padding: 5px 7px; + background: #241313; + color: #ffc1c1; + font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pallet-diff strong { + color: #f5d8d8; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + .job-result { display: block; margin: 9px 0 7px;