Skip to content
17 changes: 15 additions & 2 deletions adminapp/src/components/BackTo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@ import relativeLink from "../modules/relativeLink";
import Link from "./Link";
import LeftIcon from "@mui/icons-material/ChevronLeft";
import React from "react";
import { useNavigate } from "react-router-dom";

export const BACK = "_BackTo_BACK";

export default function BackTo({ to }) {
const [relto] = relativeLink(to);
const navigate = useNavigate();
const props = {};
if (to === BACK) {
props.to = "";
props.onClick = () => navigate(-1);
} else {
const [relto] = relativeLink(to);
props.to = relto;
}
return (
<Link to={relto} sx={{ verticalAlign: "text-top" }}>
<Link {...props} sx={{ verticalAlign: "text-top" }}>
<LeftIcon />
</Link>
);
}

BackTo.BACK = BACK;
9 changes: 7 additions & 2 deletions adminapp/src/components/ResourceDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import { useNavigate, useParams } from "react-router-dom";
* show the delete icon.
* @param {function|string} title
* @param {function} properties Called with the resource state and returns the property pairs (label/value).
* @param {function|string} backTo Where the 'back' icon goes.
* @param {function|string} backTo Where clicking the 'back' icon goes.
* Use BackTo.BACK to use the router 'back' rather than a link.
* @param children
* @constructor
*/
Expand Down Expand Up @@ -104,12 +105,16 @@ export default function ResourceDetail({
}
canDelete = canDelete ? invokeIfFunc(canDelete, state) : Boolean(apiDelete);

const backToVal =
backTo === BackTo.BACK
? BackTo.BACK
: invokeIfFunc(backTo, state) || resourceListRoute(resource);
const topCards = [
<DetailGrid
key={-1}
title={
<Title onDelete={canDelete && handleDelete} toEdit={toEdit}>
<BackTo to={invokeIfFunc(backTo, state) || resourceListRoute(resource)} />
<BackTo to={backToVal} />
{title(state)}
</Title>
}
Expand Down
14 changes: 11 additions & 3 deletions adminapp/src/components/ResourceEdit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ import { useSnackbar } from "notistack";
import React from "react";
import { useParams } from "react-router-dom";

export default function ResourceEdit({ apiGet, apiUpdate, Form }) {
/**
* @param apiGet API method to get the resource.
* @param apiUpdate API method to update the resource.
* @param alwaysApply If false, show 'no changes to save' if there are no changes queued.
* If true, always update on submit. Only useful where submitting no parameters
* has some other side effect.
* @param Form Form component to use.
*/
export default function ResourceEdit({ apiGet, apiUpdate, alwaysApply, Form }) {
const { enqueueErrorSnackbar } = useErrorSnackbar();
const { enqueueSnackbar } = useSnackbar();
const { id: idStr } = useParams();
Expand All @@ -21,14 +29,14 @@ export default function ResourceEdit({ apiGet, apiUpdate, Form }) {
});
const handleApplyChange = React.useCallback(
(changes) => {
if (isEmpty(changes)) {
if (!alwaysApply && isEmpty(changes)) {
enqueueSnackbar("No changes to save.");
window.history.back();
return Promise.resolve();
}
return apiUpdate({ id, ...changes });
},
[apiUpdate, enqueueSnackbar, id]
[apiUpdate, enqueueSnackbar, id, alwaysApply]
);

// TODO: Add an error page at some point
Expand Down
16 changes: 14 additions & 2 deletions adminapp/src/pages/OfferingProductDetailPage.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import api from "../api";
import AdminLink from "../components/AdminLink";
import BackTo from "../components/BackTo";
import Link from "../components/Link";
import RelatedList from "../components/RelatedList";
import ResourceDetail from "../components/ResourceDetail";
import { dayjs } from "../modules/dayConfig";
import formatDate from "../modules/formatDate";
import { resourceEditRoute } from "../modules/resourceRoutes";
import Money from "../shared/react/Money";
import React from "react";

Expand All @@ -12,9 +15,10 @@ export default function OfferingProductDetailPage() {
<ResourceDetail
resource="offering_product"
apiGet={api.getCommerceOfferingProduct}
canEdit
apiSoftDelete={api.closeCommerceOfferingProduct}
canEdit={(m) => !m.closedAt}
canDelete={(m) => !m.closedAt}
backTo={BackTo.BACK}
apiSoftDelete={api.closeCommerceOfferingProduct}
properties={(model) => [
{ label: "ID", value: model.id },
{ label: "Created At", value: dayjs(model.createdAt) },
Expand Down Expand Up @@ -43,6 +47,14 @@ export default function OfferingProductDetailPage() {
label: "Undiscounted Price",
value: <Money>{model.undiscountedPrice}</Money>,
},
model.closedAt && {
label: "Edit",
value: (
<Link to={resourceEditRoute("offering_product", model)}>
Reopen and Change Price
</Link>
),
},
]}
>
{(model) => [
Expand Down
1 change: 1 addition & 0 deletions adminapp/src/pages/OfferingProductEditPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default function OfferingProductEditPage() {
apiGet={api.getCommerceOfferingProduct}
apiUpdate={api.updateCommerceOfferingProduct}
Form={OfferingProductForm}
alwaysApply
/>
);
}
8 changes: 7 additions & 1 deletion adminapp/src/pages/OfferingProductForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ export default function OfferingProductForm({
return (
<FormLayout
title={isCreate ? "Create an Offering Product" : "Update Offering Product"}
subtitle="This adds any product to any offering. The product will be listed in the offering at the set customer price."
subtitle={
isCreate
? "Offering Products associate a product and an offering, with the given prices."
: "Offering Products associate a product and an offering, with the given prices. " +
"Changing a price closes any existing offering products for this product/offering combo, " +
"and creates a new (open) offering product with the given prices."
}
onSubmit={onSubmit}
isBusy={isBusy}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import api from "../api";
import AutocompleteSearch from "../components/AutocompleteSearch";
import FormLayout from "../components/FormLayout";
import ResourceEdit from "../components/ResourceEdit";
import StateMachineStateSelect from "../components/StateMachineStateSelect";
Expand All @@ -15,7 +16,14 @@ export default function OrganizationMembershipVerificationEditPage() {
);
}

function VerificationForm({ resource, setFieldFromInput, register, isBusy, onSubmit }) {
function VerificationForm({
resource,
setFieldFromInput,
setField,
register,
isBusy,
onSubmit,
}) {
return (
<FormLayout
title="Update Verification Fields"
Expand Down Expand Up @@ -50,6 +58,17 @@ function VerificationForm({ resource, setFieldFromInput, register, isBusy, onSub
variant="outlined"
onChange={setFieldFromInput}
/>
<AutocompleteSearch
{...register("organizationName")}
label="Organization"
value={resource.organizationName}
fullWidth
required
disabled={!resource.organizationNameEditable}
search={api.searchOrganizations}
style={{ flex: 1 }}
onValueSelect={(org) => setField("organizationName", org.name)}
/>
</Stack>
</FormLayout>
);
Expand Down
3 changes: 3 additions & 0 deletions adminapp/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ export default defineConfig({
outDir: "../build-adminapp",
emptyOutDir: true,
},
server: {
strictPort: true,
},
});
1 change: 1 addition & 0 deletions data/i18n/seeds/en/backend.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"cart_quantity_reason.closed": "Unavailable",
"cart_quantity_reason.max_ordered": "Unavailable",
"cart_quantity_reason.out_of_stock": "Out of stock",
"cart_quantity_reason.purchased": "Maximum purchased",
Expand Down
1 change: 1 addition & 0 deletions data/i18n/seeds/en/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
"food.press_and_hold": "Press\n\n and\n\n hold",
"food.price": "Price",
"food.price_times_quantity": "{{price, sumaCurrency}} x {{quantity}}",
"food.product_404": "Sorry, this product is unavailable for purchase.",
"food.quantity": "Quantity: {{quantity}}",
"food.remove_from_cart": "Remove from cart",
"food.return_to_dashboard_alert": "Please return to the dashboard to add more funds or edit the cart quantities to lower your cost.",
Expand Down
1 change: 1 addition & 0 deletions data/i18n/seeds/es/backend.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"cart_quantity_reason.closed": "No disponible",
"cart_quantity_reason.max_ordered": "No disponible",
"cart_quantity_reason.out_of_stock": "Fuera de stock",
"cart_quantity_reason.purchased": "Máximo comprado",
Expand Down
1 change: 1 addition & 0 deletions data/i18n/seeds/es/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
"food.press_and_hold": "Presionar\n\n y\n\n sostener",
"food.price": "precio",
"food.price_times_quantity": "{{price, sumaCurrency}} x {{quantity}}",
"food.product_404": "Lo siento, este producto no está disponible para comprar.",
"food.quantity": "Cantidad: {{quantity}}",
"food.remove_from_cart": "Eliminar del carrito",
"food.return_to_dashboard_alert": "Por favor regrese al inicio para agregar más fondos o edite las cantidades del carrito para reducir el costo.",
Expand Down
4 changes: 4 additions & 0 deletions lib/suma/admin_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ def check_admin_role_access!(rw, key, admin: admin_member)
end
end

rescue_from Suma::InvalidPrecondition do |e|
invalid!(e.to_s)
end

rescue_from Sequel::UniqueConstraintViolation do |e|
invalid!(e.to_s)
end
Expand Down
3 changes: 1 addition & 2 deletions lib/suma/admin_api/commerce_offering_products.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,13 @@ class DetailedCommerceOfferingProductEntity < BaseEntity
params do
optional(:customer_price, allow_blank: false, type: JSON) { use :money }
optional(:undiscounted_price, allow_blank: false, type: JSON) { use :money }
at_least_one_of :customer_price, :undiscounted_price
end
post do
check_admin_role_access!(:write, Suma::Commerce::OfferingProduct)
Suma::Commerce::OfferingProduct.db.transaction do
(m = Suma::Commerce::OfferingProduct[params[:id]]) or forbidden!
new_op = m.with_changes(
customer_price: params[:customer_price], undiscounted_price: params[:undiscounted_price],
customer_price: params[:customer_price], undiscounted_price: params[:undiscounted_price], reopen_ok: true,
)
created_resource_headers(new_op.id, new_op.admin_link)
status 200
Expand Down
3 changes: 3 additions & 0 deletions lib/suma/admin_api/organization_membership_verifications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class DetailedMembershipVerificationEntity < VerificationListEntity
expose :front_partner_conversation_status
expose :front_member_conversation_status
expose :address, with: AddressEntity, &self.delegate_to(:membership, :member, :legal_entity, :address, safe: true)
expose :organization_name
expose :organization_name_editable?, as: :organization_name_editable
expose :audit_logs, with: AuditLogEntity
expose :partner_outreach_front_conversation_id
expose :member_outreach_front_conversation_id
Expand Down Expand Up @@ -107,6 +109,7 @@ class DetailedMembershipVerificationEntity < VerificationListEntity
params do
optional :account_number, type: String
optional :status, type: String
optional :organization_name, type: String
optional :partner_outreach_front_conversation_id, type: String
optional :member_outreach_front_conversation_id, type: String
end
Expand Down
48 changes: 31 additions & 17 deletions lib/suma/api/commerce.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,22 @@ def lookup_cart!(offering)
end

def present_offering(offering)
items = offering.offering_products_dataset.available.all
items.sort_by! { |op| [op.product.ordinal, op.created_at, op.id] }
offering_products = offering.offering_products_dataset.for_purchase.all
cart = lookup_cart!(offering)
vendors = items.map { |v| v.product.vendor }.uniq(&:id)
present offering, with: OfferingWithContextEntity, cart:, items:, vendors:, context: new_context
vendors = offering_products.map { |v| v.product.vendor }.uniq(&:id)
context = new_context
# This is gross, but we need to move out of stock items to the end of the item list.
# We don't want to move Cart.max_quantity_and_reason_for into the database,
# so we use the entity here, which allows us to sort based on out-of-stock (calculated on the entity),
# and then render using the same instance (so do not need to re-calculate).
# We tried other solutions (proxy object) but it was more complex,
# because we use the same offering product entity in multiple places,
# included through straight :expose, not pre-created in the endpoint code.
options = {cart:, vendors:, context:}
all_items = offering_products.map { |op| PricedOfferingProductEntity.new(op, options) }
# Partition the items list so out of stock rows are moved to the end.
items = [].concat(*all_items.partition { |op| !op.out_of_stock? })
present offering, with: OfferingWithContextEntity, **options.merge(items:)
end
end

Expand Down Expand Up @@ -274,47 +285,50 @@ class OfferingEntity < BaseEntity
class BaseOfferingProductEntity < BaseEntity
expose_translated :name, &self.delegate_to(:product, :name)
expose_translated :description, &self.delegate_to(:product, :description)
expose :offering_id
expose :offering_id, &self.delegate_to(:offering, :id)
expose :product_id, &self.delegate_to(:product, :id)
expose :vendor, with: VendorEntity, &self.delegate_to(:product, :vendor)
expose :images, with: Suma::API::Entities::ImageEntity, &self.delegate_to(:product, :images?)
end

class PricedOfferingProductEntity < BaseOfferingProductEntity
include Suma::API::Entities

expose :listable?, as: :listable
expose :max_quantity
expose :out_of_stock?, as: :out_of_stock
expose :out_of_stock_reason
expose_translated :out_of_stock_reason_text

expose :displayable_noncash_ledger_contribution_amount, with: Suma::Service::Entities::Money do |_inst|
expose :displayable_noncash_ledger_contribution_amount, with: MoneyEntity do |_inst|
self.noncash_ledger_contrib
end
expose :displayable_cash_price, with: Suma::Service::Entities::Money do |inst|
expose :displayable_cash_price, with: MoneyEntity do |inst|
noncash = self.noncash_ledger_contrib
inst.customer_price - noncash
end

expose :is_discounted, &self.delegate_to(:discounted?, safe_with_default: false)
expose :customer_price,
with: Suma::Service::Entities::Money,
with: MoneyEntity,
&self.delegate_to(:customer_price, safe_with_default: Money.new(0))
expose :undiscounted_price,
with: Suma::Service::Entities::Money,
with: MoneyEntity,
&self.delegate_to(:undiscounted_price, safe_with_default: Money.new(0))
expose :discount_amount,
with: Suma::Service::Entities::Money,
with: MoneyEntity,
&self.delegate_to(:discount_amount, safe_with_default: Money.new(0))

private def max_quantity_and_reason
def max_quantity_and_reason
return @max_quantity_and_reason ||= self.options.fetch(:cart).max_quantity_and_reason_for(self.object)
end

private def max_quantity = max_quantity_and_reason[0]
private def out_of_stock? = max_quantity <= 0
private def out_of_stock_reason = max_quantity_and_reason[1]
private def out_of_stock_reason_text = Suma::Commerce::Cart.localize_max_quantity_reason(out_of_stock_reason)
def max_quantity = max_quantity_and_reason[0]
def out_of_stock? = max_quantity <= 0
def out_of_stock_reason = max_quantity_and_reason[1]
def out_of_stock_reason_text = Suma::Commerce::Cart.localize_max_quantity_reason(out_of_stock_reason)

private def noncash_ledger_contrib
def noncash_ledger_contrib
return @noncash_ledger_contrib ||= self.options.fetch(:cart).
cost_info(self.options.fetch(:context)).
product_noncash_ledger_contribution_amount(self.object)
Expand All @@ -325,7 +339,7 @@ class OfferingWithContextEntity < BaseEntity
expose :offering, with: OfferingEntity do |instance|
instance
end
expose :items, with: PricedOfferingProductEntity do |_, opts|
expose :items do |_, opts|
opts.fetch(:items)
end
expose :vendors, with: VendorEntity do |_, opts|
Expand Down
5 changes: 4 additions & 1 deletion lib/suma/commerce/cart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def set_item(product, quantity, timestamp:)
if item.nil?
return if quantity <= 0
raise ActualProductUnavailable.new(product, self.offering) if
self.offering.offering_products_dataset.available.where(product:).empty?
self.offering.offering_products_dataset.where(closed_at: nil, product:).empty?
self.add_item(product:, quantity:, timestamp: tsval)
return
end
Expand All @@ -103,6 +103,7 @@ def set_item(product, quantity, timestamp:)
# This seems like a reasonable default...
DEFAULT_MAX_QUANTITY = 12
DEFAULT = :default
CLOSED = :closed
OUT_OF_STOCK = :out_of_stock
ALREADY_PURCHASED = :purchased
MAX_ITEMS_ORDERED = :max_ordered
Expand All @@ -111,6 +112,8 @@ def set_item(product, quantity, timestamp:)
# along with the reason why the limit is set.
# Generally the limit is only relevant if the max quantity is zero.
def max_quantity_and_reason_for(offering_product)
return [0, CLOSED] if offering_product.closed?

product = offering_product.product
inv = product.inventory!
offering = offering_product.offering
Expand Down
Loading
Loading