Use Setting Module

Now the ice cream shop can receive and process orders, and with many customers coming in, it's easy to run out of stock. Let's find out how to handle this situation.
You need to add a service that checks the remaining ice cream and toppings every morning, sets the inventory status in the system, and automatically disables orders when the inventory is depleted.

Create Scalar

akan create-scalar stock
# then select koyo application
apps/koyo/lib/__scalar/stock/stock.constant.ts
1import { enumOf, Int } from "@akanjs/base";
2import { via } from "@akanjs/constant";
3
4export class StockType extends enumOf("stockType", [
5  "yogurtIcecream",
6  "fruitRings",
7  "oreo",
8  "strawberry",
9  "mango",
10  "cheeseCube",
11  "corn",
12  "granola",
13  "banana",
14  "fig",
15] as const) {}
16
17export class Stock extends via((field) => ({
18  type: field(StockType),
19  totalQty: field(Int, { default: 0, min: 0 }),
20  currentQty: field(Int, { default: 0, min: 0 }),
21})) {}
apps/koyo/lib/__scalar/stock/stock.dictionary.ts
1import { scalarDictionary } from "@akanjs/dictionary";
2
3import type { Stock } from "./stock.constant";
4
5export const dictionary = scalarDictionary(["en", "ko"])
6  .of((t) =>
7    t(["Stock", "재고"]).desc([
8      "Stock is a collection of items that are available for purchase",
9      "재고는 구매 가능한 아이템들의 모음입니다",
10    ])
11  )
12  .model<Stock>((t) => ({
13    type: t(["Type", "타입"]).desc(["Type of the stock", "재고의 타입"]),
14    totalQty: t(["Total Quantity", "총 수량"]).desc(["Total Quantity", "총 수량"]),
15    currentQty: t(["Current Quantity", "현재 수량"]).desc(["Current quantity of the stock", "재고의 현재 수량"]),
16  }));

Create Inventory

akan create-module inventory
# then select koyo application
apps/koyo/lib/inventory/inventory.constant.ts
1import { dayjs } from "@akanjs/base";
2import { via } from "@akanjs/constant";
3
4import { Stock } from "../__scalar/stock/stock.constant";
5
6export class InventoryInput extends via((field) => ({
7  stocks: field([Stock]),
8})) {}
9
10export class InventoryObject extends via(InventoryInput, (field) => ({
11  at: field(Date, { default: () => dayjs().set("hour", 0).set("minute", 0).set("second", 0).set("millisecond", 0) }),
12})) {}
13
14export class LightInventory extends via(InventoryObject, [] as const, (resolve) => ({})) {}
15
16  export class Inventory extends via(InventoryObject, LightInventory, (resolve) => ({})) {}
17
18  export class InventoryInsight extends via(Inventory, (field) => ({})) {}
19  
apps/koyo/lib/inventory/inventory.dictionary.ts
1import { modelDictionary } from "@akanjs/dictionary";
2
3import type { Inventory, InventoryInsight } from "./inventory.constant";
4import type { InventoryEndpoint, InventorySlice } from "./inventory.signal";
5
6export const dictionary = modelDictionary(["en", "ko"])
7  .of((t) => t(["Inventory", "Inventory"]).desc(["Inventory description", "Inventory 설명"]))
8  .model<Inventory>((t) => ({
9    stocks: t(["Stocks", "재고"]).desc([
10      "A list of stock items associated with inventory record. Each entry represents a type of item and its current stock level.",
11      "인벤토리 레코드와 관련된 재고 항목들의 목록입니다. 각 항목은 아이템 종류와 현재 재고량을 의미합니다.",
12    ]),
13    at: t(["At", "일시"]).desc([
14      "The timestamp indicating when inventory record was created or is valid for.",
15      "인벤토리 레코드가 생성된 시점 또는 해당되는 일자를 나타내는 타임스탬프입니다.",
16    ]),
17  }))
18  .insight<InventoryInsight>((t) => ({}))
19  .slice<InventorySlice>((fn) => ({
20    inPublic: fn(["Inventory In Public", "Inventory 공개"]).arg((t) => ({})),
21  }))
22  .endpoint<InventoryEndpoint>((fn) => ({}))
23  .error({
24    stockNotFound: ["Stock not found: {type}", "재고를 찾을 수 없습니다: {type}"],
25    stockNotEnough: ["Stock not enough: {type}, {quantity}", "재고가 부족합니다: {type}, {quantity}"],
26  })
27  .translate({
28    outOfStock: ["Out of stock", "재고가 부족합니다"],
29  });

Business Logic

apps/koyo/lib/inventory/inventory.document.ts
1import { dayjs } from "@akanjs/base";
2import { beyond, by, from, into, type SchemaOf } from "@akanjs/document";
3
4import * as cnst from "../cnst";
5
6export class InventoryFilter extends from(cnst.Inventory, (filter) => ({
7  query: {},
8  sort: {
9    latestAt: { at: -1 },
10  },
11})) {}
12
13export class Inventory extends by(cnst.Inventory) {
14  useStocks(usages: { type: cnst.StockType["value"]; quantity: number }[]) {
15    for (const usage of usages) this.useStock(usage.type, usage.quantity);
16    return this;
17  }
18  useStock(type: cnst.StockType["value"], quantity: number) {
19    const stock = this.stocks.find((stock) => stock.type === type);
20    if (!stock) throw new Revert("inventory.error.stockNotFound", { type });
21    if (stock.currentQty < quantity) throw new Revert("inventory.error.stockNotEnough", { type, quantity });
22    stock.currentQty -= quantity;
23    return this;
24  }
25  refill() {
26    const YOGURT_ICECREAM_QUANTITY = 1000;
27    const TOPPING_QUANTITY = 10;
28    const refillStocks: { type: cnst.StockType["value"]; fillQty: number }[] = [
29      { type: "yogurtIcecream", fillQty: YOGURT_ICECREAM_QUANTITY },
30      ...cnst.Topping.map((topping) => ({ type: topping, fillQty: TOPPING_QUANTITY })),
31    ];
32    const filledStocks = refillStocks.map((stock) => {
33      const existingStock = this.stocks.find((s) => s.type === stock.type);
34      if (!existingStock) return { type: stock.type, totalQty: stock.fillQty, currentQty: stock.fillQty };
35      const fillQty = Math.max(stock.fillQty - existingStock.currentQty, 0);
36      return { type: stock.type, totalQty: existingStock.totalQty, currentQty: existingStock.currentQty + fillQty };
37    });
38    this.stocks = filledStocks;
39    return this;
40  }
41}
42
43export class InventoryModel extends into(Inventory, InventoryFilter, cnst.inventory, () => ({})) {
44  async generateTodaysInventory() {
45    const today = dayjs().set("hour", 0).set("minute", 0).set("second", 0).set("millisecond", 0);
46    const latestInventory = await this.findAny({ sort: "latestAt" });
47    if (latestInventory?.at.isSame(today)) return latestInventory;
48    return await new this.Inventory({ at: today }).refill().save();
49  }
50}
51
52export class InventoryMiddleware extends beyond(InventoryModel, Inventory) {
53  onSchema(schema: SchemaOf<InventoryModel, Inventory>) {
54    // schema.index({ status: 1 })
55  }
56}
apps/koyo/lib/inventory/inventory.service.ts
1import { serve } from "@akanjs/service";
2
3import * as cnst from "../cnst";
4import * as db from "../db";
5
6export class InventoryService extends serve(db.inventory, () => ({})) {
7  async getTodaysInventory() {
8    return await this.inventoryModel.generateTodaysInventory();
9  }
10  async refillTodaysInventory() {
11    const inventory = await this.getTodaysInventory();
12    return await inventory.refill().save();
13  }
14  async useStocks(usages: {type: cnst.StockType["value"], quantity: number}[]) {
15    const inventory = await this.getTodaysInventory();
16    return await inventory.useStocks(usages).save();
17  }
18}

Connect Service

apps/koyo/lib/icecreamOrder/icecreamOrder.service.ts
1// ...existing code...
2import type * as srv from "../srv";
3
4export class IcecreamOrderService extends serve(db.icecreamOrder, ({ use, service }) => ({
5  alarmApi: use<AlarmApi>(),
6  inventoryService: service<srv.InventoryService>(),
7})) {
8  async _preCreate(data: db.IcecreamOrderInput) {
9    await this.inventoryService.useStocks([
10      { type: "yogurtIcecream", quantity: data.size },
11      ...data.toppings.map((topping) => ({ type: topping, quantity: 1 })),
12    ]);
13    return data;
14  }
15  // ...existing code...
16}

Connect Signal

apps/koyo/lib/inventory/inventory.signal.ts
1// ...existing code...
2
3export class InventoryEndpoint extends endpoint(srv.inventory, ({ query }) => ({
4  getTodaysInventory: query(cnst.Inventory).exec(async function () {
5    return await this.inventoryService.getTodaysInventory();
6  }),
7  refillTodaysInventory: query(cnst.Inventory).exec(async function () {
8    return await this.inventoryService.refillTodaysInventory();
9  }),
10})) {}
apps/koyo/lib/inventory/inventory.dictionary.ts
1// ...existing code...
2  .endpoint<InventoryEndpoint>((fn) => ({
3    getTodaysInventory: fn(["Get Todays Inventory", "오늘 재고 조회"]).desc([
4      "Get today's inventory. If not exists, create it.",
5      "오늘의 인벤토리를 조회합니다. 없으면 생성합니다.",
6    ]),
7    refillTodaysInventory: fn(["Refill Todays Inventory", "오늘 재고 채우기"]).desc([
8      "Refill today's inventory.",
9      "오늘의 인벤토리를 채웁니다.",
10    ]),
11  }))
12// ...existing code...
apps/koyo/lib/inventory/inventory.store.ts
1import { store } from "@akanjs/store";
2
3import * as cnst from "../cnst";
4import { fetch, sig } from "../useClient";
5
6export class InventoryStore extends store(sig.inventory, {
7  todaysInventory: null as cnst.Inventory | null,
8}) {
9  async loadTodaysInventory() {
10    const todaysInventory = await fetch.getTodaysInventory();
11    this.set({ todaysInventory });
12  }
13  async refillTodaysInventory() {
14    const todaysInventory = await fetch.refillTodaysInventory();
15    this.set({ todaysInventory });
16  }
17}

Interact on UI

apps/koyo/lib/icecreamOrder/IcecreamOrder.Template.tsx
1"use client";
2import { Field, Layout, Loading } from "@akanjs/ui";
3import { cnst, st, usePage } from "@koyo/client";
4import { useEffect } from "react";
5
6interface IcecreamOrderEditProps {
7  className?: string;
8}
9
10export const General = ({ className }: IcecreamOrderEditProps) => {
11  const icecreamOrderForm = st.use.icecreamOrderForm();
12  const { l } = usePage();
13  const todaysInventory = st.use.todaysInventory();
14  useEffect(() => {
15    void st.do.loadTodaysInventory();
16  }, []);
17  if (!todaysInventory) return <Loading.Area />;
18  else if (!todaysInventory.isInStock("yogurtIcecream"))
19    return <div className="flex size-full items-center justify-center text-xl">{l("inventory.outOfStock")}</div>;
20  return (
21    <Layout.Template className={className}>
22      <Field.ToggleSelect
23        label={l("icecreamOrder.size")}
24        items={[50, 100, 200].map((size) => ({
25          label: `${size}cc`,
26          value: size,
27          disabled: !todaysInventory.isInStock("yogurtIcecream", size),
28        }))}
29        value={icecreamOrderForm.size}
30        onChange={st.do.setSizeOnIcecreamOrder}
31      />
32      <Field.MultiToggleSelect
33        label={l("icecreamOrder.toppings")}
34        items={cnst.Topping.map((topping) => ({
35          label: topping,
36          value: topping,
37          disabled: !todaysInventory.isInStock(topping),
38        }))}
39        value={icecreamOrderForm.toppings}
40        onChange={st.do.setToppingsOnIcecreamOrder}
41      />
42      <Field.Phone
43        label={l("icecreamOrder.phone")}
44        placeholder="010-0000-0000"
45        value={icecreamOrderForm.phone}
46        onChange={st.do.setPhoneOnIcecreamOrder}
47      />
48    </Layout.Template>
49  );
50};
apps/koyo/lib/inventory/inventory.constant.ts
1import { dayjs } from "@akanjs/base";
2import { via } from "@akanjs/constant";
3
4import { Stock, StockType } from "../__scalar/stock/stock.constant";
5
6export class InventoryInput extends via((field) => ({
7  stocks: field([Stock]),
8})) {}
9
10export class InventoryObject extends via(InventoryInput, (field) => ({
11  at: field(Date, { default: () => dayjs().set("hour", 0).set("minute", 0).set("second", 0).set("millisecond", 0) }),
12})) {}
13
14export class LightInventory extends via(InventoryObject, [] as const, (resolve) => ({})) {}
15
16export class Inventory extends via(InventoryObject, LightInventory, (resolve) => ({})) {
17  isInStock(type: StockType["value"], quantity = 1) {
18    const stock = this.stocks.find((stock) => stock.type === type);
19    if (!stock) return false;
20    return stock.currentQty >= quantity;
21  }
22}
23
24export class InventoryInsight extends via(Inventory, (field) => ({})) {}
apps/koyo/lib/inventory/Inventory.Util.tsx
1"use client";
2import { clsx } from "@akanjs/client";
3import { st, usePage } from "@koyo/client";
4import { BiRefresh } from "react-icons/bi";
5
6interface RefillProps {
7  className?: string;
8}
9export const Refill = ({ className }: RefillProps) => {
10  const { l } = usePage();
11  return (
12    <button
13      className={clsx("btn btn-primary", className)}
14      onClick={() => {
15        void st.do.refillTodaysInventory();
16      }}
17    >
18      <BiRefresh /> {l("inventory.signal.refillTodaysInventory")}
19    </button>
20  );
21};
apps/koyo/lib/inventory/Inventory.View.tsx
1import { dayjs } from "@akanjs/base";
2import { clsx } from "@akanjs/client";
3import { cnst } from "@koyo/client";
4
5interface InventoryViewProps {
6  className?: string;
7  inventory: cnst.Inventory;
8}
9
10export const General = ({ className, inventory }: InventoryViewProps) => {
11  return (
12    <div className={clsx("w-full space-y-2 rounded-xl bg-purple-50 p-4", className)}>
13      <div className="text-lg font-bold text-purple-900">{dayjs(inventory.at).format("YYYY-MM-DD")}</div>
14      <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
15        {inventory.stocks.map((stock, index) => {
16          const status = stock.getStatus();
17          const percentage = stock.getPercentage();
18          return (
19            <div
20              key={`${stock.type}-${index}`}
21              className={clsx("space-y-3 rounded-xl px-6 py-4 shadow-md", {
22                "bg-red-50": status === "empty",
23                "bg-yellow-50": status === "low",
24                "bg-green-50": status === "normal",
25              })}
26            >
27              <div className="flex items-center justify-between">
28                <div
29                  className={clsx("rounded px-2 py-1 text-xs font-bold", {
30                    "bg-red-200 text-red-800": status === "empty",
31                    "bg-yellow-200 text-yellow-800": status === "low",
32                    "bg-green-200 text-green-800": status === "normal",
33                  })}
34                >
35                  {stock.type}
36                </div>
37                <div
38                  className={clsx("text-2xl font-bold", {
39                    "text-red-700": status === "empty",
40                    "text-yellow-700": status === "low",
41                    "text-green-700": status === "normal",
42                  })}
43                >
44                  {stock.currentQty} / {stock.totalQty}
45                </div>
46              </div>
47              <div className="flex items-center justify-between gap-4">
48                <div className="h-2 w-full overflow-hidden rounded-full bg-white/50">
49                  <div
50                    className={clsx("h-full", {
51                      "bg-red-500": status === "empty",
52                      "bg-yellow-500": status === "low",
53                      "bg-green-500": status === "normal",
54                    })}
55                    style={{ width: `${Math.min(percentage, 100)}%` }}
56                  />
57                </div>
58                <div
59                  className={clsx("text-right text-xs font-bold", {
60                    "text-red-700": status === "empty",
61                    "text-yellow-700": status === "low",
62                    "text-green-700": status === "normal",
63                  })}
64                >
65                  {Math.round(percentage)}%
66                </div>
67              </div>
68            </div>
69          );
70        })}
71      </div>
72    </div>
73  );
74};
apps/koyo/lib/__scalar/stock/stock.constant.ts
1//...existing code...
2
3export class Stock extends via((field) => ({
4  type: field(StockType),
5  totalQty: field(Int, { default: 0, min: 0 }),
6  currentQty: field(Int, { default: 0, min: 0 }),
7})) {
8  getPercentage() {
9    return (this.currentQty / this.totalQty) * 100;
10  }
11  getStatus() {
12    const percentage = this.getPercentage();
13    if (percentage === 0) return "empty";
14    if (percentage < 30) return "low";
15    return "normal";
16  }
17}
apps/koyo/lib/inventory/Inventory.Zone.tsx
1//...existing code...
2import { useInterval } from "@akanjs/next";
3import { Loading } from "@akanjs/ui";
4import { st } from "@koyo/client";
5
6//...existing code...
7
8interface TodayProps {
9  className?: string;
10}
11export const Today = ({ className }: TodayProps) => {
12  const todaysInventory = st.use.todaysInventory();
13  useInterval(() => {
14    void st.do.loadTodaysInventory();
15  }, 1000);
16  if (!todaysInventory) return <Loading.Area />;
17  return <Inventory.View.General inventory={todaysInventory} />;
18};
apps/koyo/app/[lang]/icecreamOrder/page.tsx
1import { Load, Model } from "@akanjs/ui";
2import { cnst, fetch, IcecreamOrder, Inventory, usePage } from "@koyo/client";
3
4export default function Page() {
5  const { l } = usePage();
6  return (
7    <Load.Page
8      of={Page}
9      loader={async () => {
10        const { icecreamOrderInitInPublic } = await fetch.initIcecreamOrderInPublic();
11        const icecreamOrderForm: Partial<cnst.IcecreamOrderInput> = {};
12        return { icecreamOrderInitInPublic, icecreamOrderForm };
13      }}
14      render={({ icecreamOrderInitInPublic, icecreamOrderForm }) => {
15        return (
16          <div className="space-y-4">
17            <div className="flex items-center gap-4 text-5xl font-black">
18              <div className="text-5xl font-black">{l("inventory.modelName")}</div>
19              <Inventory.Util.Refill className="absolute top-2 right-2" />
20            </div>
21            <Inventory.Zone.Today />
22            <div className="flex items-center gap-4 text-5xl font-black">
23              {l("icecreamOrder.modelName")}
24              <Model.New
25                className="btn btn-primary"
26                sliceName="icecreamOrderInPublic"
27                renderTitle="name"
28                partial={icecreamOrderForm}
29              >
30                <IcecreamOrder.Template.General />
31              </Model.New>
32            </div>
33            <IcecreamOrder.Zone.Card className="space-y-2" init={icecreamOrderInitInPublic} />
34          </div>
35        );
36      }}
37    />
38  );
39}
Released under the MIT License
Official Akan.js Consulting onAkansoft
Copyright © 2025 Akan.js. All rights reserved.
System managed bybassman