Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
ApiCore now owns the remaining stock per batch; the shop sells from it and reports every paid sale and every return back. - Outbox apicore_stock_events, written in the same transaction as the status change: paid -> SALE, cancelled-after-payment / refunded return -> RETURN onto the batches of the sale, reopened paid order -> SALE. Whether a return is due is decided by the open sales in the outbox, so unpaid orders never book and nothing is booked twice. - Delivery right after the response (next/server after()) plus CronJob wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff; ApiCore deduplicates by event id. Rejected bookings stop and show up on the dashboard. - Reconcile: shop stock = ApiCore remaining - unconfirmed bookings - reservations of unpaid orders, in a serializable transaction like the checkout. Triggered after bookings, by the signed ApiCore webhook stock.changed (ids only, numbers are fetched) and by the sync button. - Manufactured goods (candles, propolis products, ...): one shop product per ApiCore article via Product.apicoreProductId, drafts created by the sync, existing products linkable in the product form; new category SONSTIGES. Stock field is read-only for linked products. - OrderItem.apicoreAllocations keeps which lots were sold (recalls). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
import type { ReactNode } from "react";
|
|
|
|
// Gemeinsame Bausteine der Admin-Formulare (Produkt anlegen/bearbeiten,
|
|
// spaeter Gutscheine, Versand, Benutzer). Reine Server-Komponenten — sie
|
|
// rendern nur Markup, damit jedes Formular gleich gegliedert aussieht.
|
|
|
|
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2";
|
|
|
|
export function AdminField({
|
|
name,
|
|
label,
|
|
type = "text",
|
|
defaultValue,
|
|
placeholder,
|
|
required = false,
|
|
min,
|
|
hint,
|
|
readOnly = false,
|
|
}: {
|
|
name: string;
|
|
label: string;
|
|
type?: string;
|
|
defaultValue?: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
min?: number;
|
|
/** Kurzer Zusatz unter dem Feld, statt ihn in die Beschriftung zu packen. */
|
|
hint?: string;
|
|
/** Wird angezeigt und mitgeschickt, laesst sich aber nicht aendern. */
|
|
readOnly?: boolean;
|
|
}) {
|
|
return (
|
|
<label className="block text-sm">
|
|
<span className="text-ink/70">{label}</span>
|
|
<input
|
|
name={name}
|
|
type={type}
|
|
defaultValue={defaultValue}
|
|
placeholder={placeholder}
|
|
required={required}
|
|
min={min}
|
|
readOnly={readOnly}
|
|
className={readOnly ? `${inputClass} bg-ink/5 text-ink/70` : inputClass}
|
|
/>
|
|
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function AdminTextarea({
|
|
name,
|
|
label,
|
|
defaultValue,
|
|
required = false,
|
|
rows = 3,
|
|
}: {
|
|
name: string;
|
|
label: string;
|
|
defaultValue?: string;
|
|
required?: boolean;
|
|
rows?: number;
|
|
}) {
|
|
return (
|
|
<label className="block text-sm">
|
|
<span className="text-ink/70">{label}</span>
|
|
<textarea name={name} defaultValue={defaultValue} required={required} rows={rows} className={inputClass} />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function AdminSelect({
|
|
name,
|
|
label,
|
|
defaultValue,
|
|
hint,
|
|
children,
|
|
}: {
|
|
name: string;
|
|
label: string;
|
|
defaultValue?: string;
|
|
hint?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="block text-sm">
|
|
<span className="text-ink/70">{label}</span>
|
|
<select name={name} defaultValue={defaultValue} className={inputClass}>
|
|
{children}
|
|
</select>
|
|
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function AdminFieldset({
|
|
legend,
|
|
last = false,
|
|
className = "",
|
|
children,
|
|
}: {
|
|
legend: string;
|
|
/** Letzter Abschnitt vor der Speicherleiste: ohne Trennlinie. */
|
|
last?: boolean;
|
|
className?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<fieldset className={`space-y-4 ${last ? "" : "border-b border-ink/10 pb-8"} ${className}`}>
|
|
<legend className="mb-4 font-serif text-xl text-heading">{legend}</legend>
|
|
{children}
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
export function AdminHint({ children }: { children: ReactNode }) {
|
|
return <p className="text-xs text-ink/65">{children}</p>;
|
|
}
|
|
|
|
/** Schalter statt kleiner Checkbox — dasselbe Formularfeld, nur als Umschalter dargestellt. */
|
|
export function AdminSwitch({
|
|
name,
|
|
label,
|
|
defaultChecked,
|
|
}: {
|
|
name: string;
|
|
label: string;
|
|
defaultChecked?: boolean;
|
|
}) {
|
|
return (
|
|
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
|
|
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="peer sr-only" />
|
|
<span
|
|
aria-hidden="true"
|
|
className="relative h-6 w-11 flex-shrink-0 rounded-full bg-ink/20 transition-colors duration-300 peer-checked:bg-forest peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-honey-dark after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-cream after:shadow after:transition-transform after:duration-300 after:content-[''] peer-checked:after:translate-x-5"
|
|
/>
|
|
{label}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
/** Formularrahmen samt Speicherleiste, die beim Scrollen am unteren Rand
|
|
bleibt — der Knopf ist bei langen Formularen immer erreichbar. */
|
|
export function AdminFormPanel({
|
|
action,
|
|
actions,
|
|
className = "",
|
|
children,
|
|
}: {
|
|
action: (formData: FormData) => void | Promise<void>;
|
|
actions: ReactNode;
|
|
className?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<form action={action} className={`space-y-8 border border-ink/10 bg-background p-6 sm:p-8 ${className}`}>
|
|
{children}
|
|
<div className="sticky bottom-0 -mx-6 -mb-6 flex items-center justify-end gap-4 border-t border-ink/10 bg-background/95 px-6 py-4 backdrop-blur sm:-mx-8 sm:-mb-8 sm:px-8">
|
|
{actions}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|