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::roasts::TastingNotesInput;
|
||||||
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
|
use crate::domain::bags::NewBag;
|
||||||
use crate::domain::errors::RepositoryError;
|
use crate::domain::errors::RepositoryError;
|
||||||
use crate::domain::roasters::NewRoaster;
|
use crate::domain::roasters::NewRoaster;
|
||||||
use crate::domain::roasts::NewRoast;
|
use crate::domain::roasts::NewRoast;
|
||||||
|
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||||
use crate::infrastructure::ai::{self, ExtractionInput};
|
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
|
|
@ -115,6 +117,10 @@ pub(crate) struct BagScanSubmission {
|
||||||
process: String,
|
process: String,
|
||||||
#[serde(default = "default_tasting_notes")]
|
#[serde(default = "default_tasting_notes")]
|
||||||
tasting_notes: TastingNotesInput,
|
tasting_notes: TastingNotesInput,
|
||||||
|
#[serde(default)]
|
||||||
|
open_bag: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
bag_amount: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -175,6 +181,7 @@ async fn extract_into_submission(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn submit_scan(
|
pub(crate) async fn submit_scan(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -262,6 +269,48 @@ pub(crate) async fn submit_scan(
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.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 redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
||||||
let roast_id = roast.id.into_inner();
|
let roast_id = roast.id.into_inner();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@
|
||||||
data-signals:_producer="''"
|
data-signals:_producer="''"
|
||||||
data-signals:_process="''"
|
data-signals:_process="''"
|
||||||
data-signals:_tasting-notes="''"
|
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>
|
<h2 class="text-lg font-semibold text-amber-700 mb-3">Scan Bag</h2>
|
||||||
|
|
||||||
|
|
@ -89,7 +91,7 @@
|
||||||
<form
|
<form
|
||||||
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm flex flex-col gap-6"
|
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: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>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-amber-700">Roaster</h3>
|
<h3 class="text-base font-semibold text-amber-700">Roaster</h3>
|
||||||
|
|
@ -143,6 +145,18 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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">
|
<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") %}
|
{% call icons::spinner("h-5 w-5") %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue