feat(scan): optionally open a bag when scanning coffee
Add "Open a bag of this coffee" checkbox (default: checked) to the scan form with a configurable amount field (default: 250g). When enabled, creates a bag and timeline event alongside the roaster/roast.
This commit is contained in:
parent
ec62a1fc7b
commit
91a9900c24
2 changed files with 64 additions and 1 deletions
|
|
@ -9,9 +9,11 @@ use crate::application::errors::{ApiError, AppError};
|
|||
use crate::application::routes::roasts::TastingNotesInput;
|
||||
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
||||
use crate::application::server::AppState;
|
||||
use crate::domain::bags::NewBag;
|
||||
use crate::domain::errors::RepositoryError;
|
||||
use crate::domain::roasters::NewRoaster;
|
||||
use crate::domain::roasts::NewRoast;
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||
|
|
@ -115,6 +117,10 @@ pub(crate) struct BagScanSubmission {
|
|||
process: String,
|
||||
#[serde(default = "default_tasting_notes")]
|
||||
tasting_notes: TastingNotesInput,
|
||||
#[serde(default)]
|
||||
open_bag: Option<String>,
|
||||
#[serde(default)]
|
||||
bag_amount: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -175,6 +181,7 @@ async fn extract_into_submission(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||
pub(crate) async fn submit_scan(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -262,6 +269,48 @@ pub(crate) async fn submit_scan(
|
|||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
// Optionally create a bag
|
||||
let wants_bag = submission
|
||||
.open_bag
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == "true" || v == "on");
|
||||
if wants_bag {
|
||||
let amount = submission.bag_amount.unwrap_or(250.0);
|
||||
let new_bag = NewBag {
|
||||
roast_id: roast.id,
|
||||
roast_date: None,
|
||||
amount,
|
||||
};
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.insert(new_bag)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let event = NewTimelineEvent {
|
||||
entity_type: "bag".to_string(),
|
||||
entity_id: bag.id.into_inner(),
|
||||
action: "added".to_string(),
|
||||
occurred_at: chrono::Utc::now(),
|
||||
title: roast.name.clone(),
|
||||
details: vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: roaster.name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
label: "Amount".to_string(),
|
||||
value: format!("{}g", bag.amount),
|
||||
},
|
||||
],
|
||||
tasting_notes: vec![],
|
||||
slug: Some(roast.slug.clone()),
|
||||
roaster_slug: Some(roaster.slug.clone()),
|
||||
brew_data: None,
|
||||
};
|
||||
let _ = state.timeline_repo.insert(event).await;
|
||||
}
|
||||
|
||||
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
||||
let roast_id = roast.id.into_inner();
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
data-signals:_producer="''"
|
||||
data-signals:_process="''"
|
||||
data-signals:_tasting-notes="''"
|
||||
data-signals:_open-bag="true"
|
||||
data-signals:_bag-amount="250"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-amber-700 mb-3">Scan Bag</h2>
|
||||
|
||||
|
|
@ -89,7 +91,7 @@
|
|||
<form
|
||||
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm flex flex-col gap-6"
|
||||
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
||||
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; $_openBag = true; $_bagAmount = 250; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-amber-700">Roaster</h3>
|
||||
|
|
@ -143,6 +145,18 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" name="open_bag" value="true" class="accent-amber-600" data-bind:_open-bag />
|
||||
<span class="font-semibold text-amber-700">Open a bag of this coffee</span>
|
||||
</label>
|
||||
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Amount (grams)</span>
|
||||
<input type="number" name="bag_amount" min="1" step="any" class="input-field" placeholder="250" data-bind:_bag-amount />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
|
||||
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-5 w-5") %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue