akan create-scalar stock
# then select koyo application1import { 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})) {}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 }));akan create-module inventory
# then select koyo application1import { 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 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 });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}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}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}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})) {}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...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}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};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) => ({})) {}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};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};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}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};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}