Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a52fcac
fix(assets): consume ONE_WAY stock on custody release, gate activity …
Aug 4, 2026
80fdc0c
fix(companion): word the end-of-custody action as consume for ONE_WAY…
Aug 4, 2026
87bf9e8
test(assets): assert the 403 denial and pin the placement deferral
Aug 4, 2026
1f20216
docs(assets): document both release dispositions on the custody routes
Aug 4, 2026
d879bb4
Merge branch 'main' into fix/one-way-consumable-custody-and-note-authz
DonKoko Aug 5, 2026
26df138
Merge branch 'main' into fix/one-way-consumable-custody-and-note-authz
DonKoko Aug 5, 2026
98dfedf
feat(quantity-control): own the release-category predicate
DonKoko Aug 5, 2026
ad6a5ec
feat(assets): split a custody release into consumed and returned units
DonKoko Aug 5, 2026
e988cf6
feat(assets): accept a consumed split on the web release route
DonKoko Aug 5, 2026
6c77b93
feat(assets): let the custody dialog split a consumable release
DonKoko Aug 5, 2026
3337656
feat(assets): accept a consumed split on the mobile release route
DonKoko Aug 5, 2026
07711f6
feat(companion): split a consumable custody release on mobile
DonKoko Aug 5, 2026
e06dbf8
fix(assets): address review findings on the consumable release split
DonKoko Aug 5, 2026
47c36b6
test(assets): no-op the rate limiter in the mobile release-quantity s…
DonKoko Aug 5, 2026
941fa64
chore(assets): reduce this PR to the ONE_WAY consumable change
DonKoko Aug 5, 2026
490381a
fix(assets): surface release errors and clamp the mobile consumed field
DonKoko Aug 5, 2026
c6f44f0
test(assets): drop the unused submit handler from the release dialog …
DonKoko Aug 5, 2026
9b92ef8
test(assets): make the returnable-path assertion able to fail
DonKoko Aug 5, 2026
54792ed
Merge branch 'main' into fix/one-way-consumable-custody-and-note-authz
DonKoko Aug 5, 2026
ea2f918
Merge branch 'main' into fix/one-way-consumable-custody-and-note-authz
DonKoko Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions apps/companion/app/(tabs)/assets/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { releaseCategory } from "@shelf/quantity-control";
import {
api,
type AssetCustodyListEntry,
Expand Down Expand Up @@ -314,6 +315,10 @@ export default function AssetDetailScreen() {
const releaseMax = releaseQtyEntry
? releaseQtyEntry.releasableQuantity ?? releaseQtyEntry.quantity
: 0;
// The shared predicate decides this, so the wording can never disagree with
// what the server does. Servers predating the field send no consumptionType,
// which falls through to the returnable copy — the server's own default.
const isConsumable = releaseCategory(asset.consumptionType) === "CONSUME";
// Custody holders the server hid from this caller (privacy filtering for
// roles without view-all-custody). Shown as a muted "+N others" row.
const custodyOthersCount = isQtyTracked
Expand Down Expand Up @@ -498,9 +503,13 @@ export default function AssetDetailScreen() {
}
accessibilityLabel={
canReleaseRow
? `Release custody from ${
entry.custodian.name
}, holds ${qtyLabel ?? entry.quantity}`
? isConsumable
? `End hold on units held by ${
entry.custodian.name
}, holds ${qtyLabel ?? entry.quantity}`
: `Release custody from ${
entry.custodian.name
}, holds ${qtyLabel ?? entry.quantity}`
: undefined
}
/>
Expand Down Expand Up @@ -753,28 +762,49 @@ export default function AssetDetailScreen() {
/>
<QuantityInputSheet
visible={releaseQtyEntry != null}
title="Release Quantity"
title={isConsumable ? "End hold" : "Release Quantity"}
subtitle={
releaseQtyEntry
? `Release how many of ${
releaseQtyEntry.custodian.name
}'s ${
formatQuantity(releaseMax, asset.unitOfMeasure) ??
String(releaseMax)
}?`
? isConsumable
? `End the hold on how many of ${
releaseQtyEntry.custodian.name
}'s ${
formatQuantity(releaseMax, asset.unitOfMeasure) ??
String(releaseMax)
}, and how many were used up? Used-up units permanently reduce total stock.`
: `Release how many of ${
releaseQtyEntry.custodian.name
}'s ${
formatQuantity(releaseMax, asset.unitOfMeasure) ??
String(releaseMax)
}?`
: undefined
}
max={releaseMax}
// Web parity: the release dialog pre-fills a full release.
defaultValue={releaseMax}
unitOfMeasure={asset.unitOfMeasure}
confirmLabel="Release"
secondary={
isConsumable
? {
label: "Of those, how many were used up?",
// Pre-fill a full consume — the common case, and what
// the server defaults to when no split is sent.
defaultValue: releaseMax,
}
: undefined
}
confirmLabel={isConsumable ? "Confirm" : "Release"}
destructive
onSubmit={(quantity) => {
onSubmit={(quantity, consumed) => {
const entry = releaseQtyEntry;
setReleaseQtyEntry(null);
if (entry) {
void performReleaseQuantity(entry.custodian.id, quantity);
void performReleaseQuantity(
entry.custodian.id,
quantity,
consumed
);
}
}}
onClose={() => setReleaseQtyEntry(null)}
Expand Down
94 changes: 86 additions & 8 deletions apps/companion/components/quantity-input-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ type Props = {
defaultValue?: number;
/** Display unit echoed under the input (e.g. "pcs"); null/undefined hides it. */
unitOfMeasure?: string | null;
/**
* Optional second numeric field rendered under the primary one. The ONE_WAY
* custody release uses it to ask how many of the released units were used
* up. Its value is clamped to the primary quantity, so it can never
* over-claim.
*/
secondary?: {
/** Field label, e.g. "Of those, how many were used up?". */
label: string;
/** Initial value when the sheet opens (clamped to [0, primary]). */
defaultValue?: number;
};
/** Confirm button label, e.g. "Assign" / "Release". */
confirmLabel: string;
/**
Expand All @@ -46,8 +58,11 @@ type Props = {
* quick-actions.tsx `primaryActionGreen`). Default is the primary black.
*/
destructive?: boolean;
/** Called with the validated quantity when the user confirms. */
onSubmit: (quantity: number) => void;
/**
* Called with the validated quantity when the user confirms, plus the
* secondary value when a `secondary` field is configured.
*/
onSubmit: (quantity: number, secondaryValue?: number) => void;
/** Called when the user dismisses the sheet without confirming. */
onClose: () => void;
};
Expand All @@ -69,6 +84,7 @@ export function QuantityInputSheet({
max,
defaultValue,
unitOfMeasure,
secondary,
confirmLabel,
destructive,
onSubmit,
Expand All @@ -78,22 +94,50 @@ export function QuantityInputSheet({
const styles = useStyles();

const [value, setValue] = useState("1");
const [secondaryValue, setSecondaryValue] = useState("0");
const inputRef = useRef<TextInput>(null);

// Re-seed the input every time the sheet opens: each open targets a fresh
// The secondary field's presence and seed are read out as primitives so the
// re-seed effect below can depend on THEM rather than on the `secondary`
// object. Callers build that object inline, giving it a fresh identity on
// every parent render — as a dependency it would turn "re-seed on open" into
// "re-seed on every parent render", silently discarding a split the operator
// had already typed.
const hasSecondaryField = secondary != null;
const secondaryDefaultValue = secondary?.defaultValue;

// Re-seed the inputs every time the sheet opens: each open targets a fresh
// action (different member/holder), so stale values must not leak across.
useEffect(() => {
if (visible) {
const seed = Math.min(Math.max(defaultValue ?? 1, 1), Math.max(max, 1));
setValue(String(seed));
if (hasSecondaryField) {
// Clamp the secondary seed to the primary seed — the two fields move
// together and the secondary can never exceed the units being released.
setSecondaryValue(
String(Math.min(Math.max(secondaryDefaultValue ?? 0, 0), seed))
);
}
}
}, [visible, defaultValue, max]);
}, [visible, defaultValue, max, hasSecondaryField, secondaryDefaultValue]);

const parsed = value ? parseInt(value, 10) : NaN;
const hasValue = Number.isFinite(parsed);
const overMax = hasValue && parsed > max;
const isValid = hasValue && parsed >= 1 && parsed <= max;

const parsedSecondary = secondaryValue ? parseInt(secondaryValue, 10) : NaN;
const hasSecondary = Number.isFinite(parsedSecondary);
// With no secondary field the sheet behaves exactly as it always has.
const isSecondaryValid =
!secondary ||
(hasSecondary &&
parsedSecondary >= 0 &&
hasValue &&
parsedSecondary <= parsed);
const canConfirm = isValid && isSecondaryValid;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Step the current value by `delta`, clamped to [1, max]. */
const step = (delta: number) => {
const current = hasValue ? parsed : 0;
Expand Down Expand Up @@ -188,20 +232,46 @@ export function QuantityInputSheet({
</Text>
)}

{/* Optional second field, e.g. how many released units were used up */}
{secondary ? (
<View style={styles.secondaryBlock}>
<Text style={styles.secondaryLabel}>{secondary.label}</Text>
<TextInput
style={styles.input}
value={secondaryValue}
onChangeText={(text) => {
setSecondaryValue(text.replace(/[^0-9]/g, ""));
}}
placeholder="0"
placeholderTextColor={colors.placeholderText}
keyboardType="number-pad"
returnKeyType="done"
accessibilityLabel={secondary.label}
/>
{!isSecondaryValid ? (
<Text style={styles.errorHint}>
Cannot exceed the {hasValue ? parsed : 0} being released.
</Text>
) : null}
</View>
) : null}

{/* Confirm */}
<TouchableOpacity
style={[
destructive ? styles.confirmRelease : styles.confirmPrimary,
!isValid && styles.confirmDisabled,
!canConfirm && styles.confirmDisabled,
]}
onPress={() => {
if (isValid) onSubmit(parsed);
if (canConfirm) {
onSubmit(parsed, secondary ? parsedSecondary : undefined);
}
}}
disabled={!isValid}
disabled={!canConfirm}
activeOpacity={0.7}
accessibilityLabel={`${confirmLabel} ${echo ?? "quantity"}`}
accessibilityRole="button"
accessibilityState={{ disabled: !isValid }}
accessibilityState={{ disabled: !canConfirm }}
>
<Text style={styles.confirmText}>{confirmLabel}</Text>
</TouchableOpacity>
Expand Down Expand Up @@ -280,6 +350,14 @@ const useStyles = createStyles((colors, shadows) => ({
color: colors.muted,
textAlign: "center",
},
secondaryBlock: {
marginTop: spacing.md,
gap: spacing.xs,
},
secondaryLabel: {
fontSize: fontSize.sm,
color: colors.muted,
},
errorHint: {
fontSize: fontSize.sm,
color: colors.error,
Expand Down
13 changes: 10 additions & 3 deletions apps/companion/hooks/use-custody-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ interface UseCustodyActionsReturn {
* Release `quantity` units of a QUANTITY_TRACKED asset from the custodian
* identified by `custodianId` (team-member id). Confirmed by the sheet,
* same as `performAssignQuantity`.
*
* `consumed` records how many of those units were used up rather than
* handed back. Omit it and the server derives the outcome from the asset's
* consumptionType.
*/
performReleaseQuantity: (
custodianId: string,
quantity: number
quantity: number,
consumed?: number
) => Promise<void>;
}

Expand Down Expand Up @@ -142,7 +147,8 @@ export function useCustodyActions({

const performReleaseQuantity = async (
custodianId: string,
quantity: number
quantity: number,
consumed?: number
) => {
if (!currentOrg || !asset) return;
setIsActionLoading(true);
Expand All @@ -151,7 +157,8 @@ export function useCustodyActions({
currentOrg.id,
asset.id,
custodianId,
quantity
quantity,
{ consumed }
);
if (err) Alert.alert("Error", err);
else {
Expand Down
18 changes: 14 additions & 4 deletions apps/companion/lib/api/custody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,33 @@ export const custodyApi = {
},

/**
* Release N units of a QUANTITY_TRACKED asset from a team member's custody.
* Mobile twin of the web's /api/assets/release-quantity-custody — only
* End a team member's hold on N units of a QUANTITY_TRACKED asset. Mobile
* twin of the web's /api/assets/release-quantity-custody — only
* operator-assigned units are releasable (kit-allocated units are cleared
* by releasing the kit's custody); the server enforces the held cap.
*
* `options.consumed` records how many of the released units were used up.
* Omit it and the server derives the outcome from the asset's
* consumptionType, so an older build still behaves correctly.
*/
releaseQuantityCustody: async (
orgId: string,
assetId: string,
teamMemberId: string,
quantity: number,
note?: string
options?: { consumed?: number; note?: string }
) => {
const result = await apiFetch<QuantityCustodyResponse>(
`/api/mobile/custody/release-quantity?orgId=${orgId}`,
{
method: "POST",
body: JSON.stringify({ assetId, teamMemberId, quantity, note }),
body: JSON.stringify({
assetId,
teamMemberId,
quantity,
consumed: options?.consumed,
note: options?.note,
}),
// why: non-idempotent — a timed-out-but-landed request must not be
// auto-retried, or the release double-applies.
retry: false,
Expand Down
1 change: 1 addition & 0 deletions apps/companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@sentry/react-native": "~7.2.0",
"@shelf/datetime": "workspace:*",
"@shelf/labels": "workspace:*",
"@shelf/quantity-control": "workspace:*",
"@supabase/supabase-js": "^2.49.1",
"expo": "~54.0.33",
"expo-av": "^16.0.8",
Expand Down
Loading
Loading