init
This commit is contained in:
commit
161a813b67
27
.eslintrc.cjs
Normal file
27
.eslintrc.cjs
Normal file
@ -0,0 +1,27 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"off",
|
||||
],
|
||||
"unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.sqlite3
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ABDELLATIF LAGHJAJ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Simple Kiosk App
|
||||
|
||||
This is a simple kiosk app that displays a list of items and allows the user to select an item to view more details.
|
||||
|
||||
It is using React + TypeScript + Tailwind CSS + Vite.
|
||||
|
||||
## Features
|
||||
|
||||
- Display a list of items
|
||||
- Order items
|
||||
- Menu CRUD
|
||||
- Order Request CRUD
|
||||
- Order Request Status Update
|
||||
|
||||
## Getting Started
|
||||
|
||||
To get started, clone the repository and run the following commands:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
This will start the development server.
|
||||
|
||||
## Building the App
|
||||
|
||||
To build the app, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This will create a `dist` folder with the built app.
|
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/client/index.css",
|
||||
"baseColor": "gray",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
20
dprint.json
Normal file
20
dprint.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"typescript": {
|
||||
},
|
||||
"json": {
|
||||
},
|
||||
"markdown": {
|
||||
},
|
||||
"toml": {
|
||||
},
|
||||
"excludes": [
|
||||
"**/node_modules",
|
||||
"**/*-lock.json"
|
||||
],
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/typescript-0.90.4.wasm",
|
||||
"https://plugins.dprint.dev/json-0.19.2.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.17.0.wasm",
|
||||
"https://plugins.dprint.dev/toml-0.6.1.wasm"
|
||||
]
|
||||
}
|
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap" rel="stylesheet">
|
||||
<title>Kiosk</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
41
lefthook.yml
Normal file
41
lefthook.yml
Normal file
@ -0,0 +1,41 @@
|
||||
# EXAMPLE USAGE:
|
||||
#
|
||||
# Refer for explanation to following link:
|
||||
# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
|
||||
#
|
||||
# pre-push:
|
||||
# commands:
|
||||
# packages-audit:
|
||||
# tags: frontend security
|
||||
# run: yarn audit
|
||||
# gems-audit:
|
||||
# tags: backend security
|
||||
# run: bundle audit
|
||||
#
|
||||
pre-commit:
|
||||
parallel: true
|
||||
commands:
|
||||
format:
|
||||
tags: style
|
||||
glob: "*.{js,ts,jsx,tsx,css,json}"
|
||||
run: pnpm run format {staged_files}
|
||||
stage_fixed: true
|
||||
eslint:
|
||||
tags: lint
|
||||
glob: "*.{js,ts,jsx,tsx}"
|
||||
run: pnpm run lint {staged_files}
|
||||
# rubocop:
|
||||
# tags: backend style
|
||||
# glob: "*.rb"
|
||||
# exclude: '(^|/)(application|routes)\.rb$'
|
||||
# run: bundle exec rubocop --force-exclusion {all_files}
|
||||
# govet:
|
||||
# tags: backend style
|
||||
# files: git ls-files -m
|
||||
# glob: "*.go"
|
||||
# run: go vet {files}
|
||||
# scripts:
|
||||
# "hello.js":
|
||||
# runner: node
|
||||
# "any.go":
|
||||
# runner: go run
|
62
package.json
Normal file
62
package.json
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "nodemon -w src/server -x tsx src/server/main.ts",
|
||||
"start": "cross-env NODE_ENV=production tsx src/server/main.ts",
|
||||
"build": "vite build",
|
||||
"format": "dprint fmt --allow-no-files",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"shadcn-ui": "shadcn-ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@tanstack/react-query": "^5.32.1",
|
||||
"axios": "^1.6.8",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"kysely": "^0.27.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.3.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vaul": "^0.9.0",
|
||||
"vite-express": "*",
|
||||
"wouter": "^3.1.2",
|
||||
"zod": "^3.23.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.9.3",
|
||||
"@types/react": "^18.0.38",
|
||||
"@types/react-dom": "^18.2.16",
|
||||
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
||||
"@typescript-eslint/parser": "^7.8.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dprint": "^0.45.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"lefthook": "^1.6.10",
|
||||
"nodemon": "^3.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"shadcn-ui": "^0.8.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^5.0.2"
|
||||
}
|
||||
}
|
5300
pnpm-lock.yaml
Normal file
5300
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
14
src/client/App.css
Normal file
14
src/client/App.css
Normal file
@ -0,0 +1,14 @@
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background-color: var(hsl(--background));
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
32
src/client/App.tsx
Normal file
32
src/client/App.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Route, Switch } from "wouter";
|
||||
import "./App.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NavMenu from "./components/nav-menu";
|
||||
import Home from "./pages/Home";
|
||||
import MenuPage from "./pages/Menu";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Order from "./pages/Order";
|
||||
import Stat from "./pages/Stat";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="min-h-dvh">
|
||||
<NavMenu />
|
||||
<main className="h-[calc(100dvh-4rem)]">
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/order" component={Order} />
|
||||
<Route path="/menu" component={MenuPage} />
|
||||
<Route path="/stat" component={Stat} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
19
src/client/api/menu.ts
Normal file
19
src/client/api/menu.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import axios from "axios";
|
||||
import { MenuItemColumns } from "src/db";
|
||||
import { AddNewMenuItemType, UpdateMenuItemType } from "src/schema";
|
||||
|
||||
export async function getListMenuItems() {
|
||||
return (await axios.get<MenuItemColumns[]>("/api/menu")).data;
|
||||
}
|
||||
|
||||
export async function addNewMenuItem(name: string, item: AddNewMenuItemType) {
|
||||
return await axios.post(`/api/menu/${encodeURIComponent(name)}`, item);
|
||||
}
|
||||
|
||||
export async function updateMenuItem(name: string, item: UpdateMenuItemType) {
|
||||
return await axios.patch(`/api/menu/${encodeURIComponent(name)}`, item);
|
||||
}
|
||||
|
||||
export async function deleteMenuItem(name: string) {
|
||||
return await axios.delete(`/api/menu/${encodeURIComponent(name)}`);
|
||||
}
|
39
src/client/api/order.ts
Normal file
39
src/client/api/order.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import axios from "axios";
|
||||
import { OrderStateColumns } from "src/db";
|
||||
import { AddNewOrderType } from "src/schema";
|
||||
|
||||
export async function loadOrder() {
|
||||
return (await axios.get<OrderStateColumns[]>("/api/order")).data;
|
||||
}
|
||||
|
||||
export async function loadOrderById(id: string) {
|
||||
return (await axios.get<OrderStateColumns>(`/api/order/${encodeURIComponent(id)}`)).data;
|
||||
}
|
||||
|
||||
export async function addNewOrder(order: AddNewOrderType) {
|
||||
return await axios.post("/api/order", order);
|
||||
}
|
||||
|
||||
export async function cancelOrder(id: string) {
|
||||
return await axios.delete(`/api/order/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function completeOrder(id: string) {
|
||||
return await axios.post(`/api/order/${encodeURIComponent(id)}/complete`);
|
||||
}
|
||||
|
||||
export async function cancelCompleteOrder(id: string) {
|
||||
return await axios.post(`/api/order/${encodeURIComponent(id)}/cancel-complete`);
|
||||
}
|
||||
|
||||
export async function clearOrder() {
|
||||
return await axios.delete("/api/order");
|
||||
}
|
||||
|
||||
export function notifyOrderChange(callback: () => void) {
|
||||
const eventSource = new EventSource("/api/order/notify");
|
||||
eventSource.onmessage = callback;
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}
|
1
src/client/assets/react.svg
Normal file
1
src/client/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
112
src/client/components/menu.tsx
Normal file
112
src/client/components/menu.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { OrderAction } from "@/hooks/useOrder";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dispatch } from "react";
|
||||
import { MenuItem } from "src/db";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
|
||||
export default function Menu({
|
||||
menus,
|
||||
categories,
|
||||
dispatch,
|
||||
}: {
|
||||
menus: MenuItem[];
|
||||
categories: string[];
|
||||
dispatch: Dispatch<OrderAction>;
|
||||
}) {
|
||||
const menu = menus;
|
||||
return (
|
||||
<Tabs defaultValue={categories[0]}>
|
||||
<TabsList>
|
||||
{[...categories].map(category => <TabsTrigger key={category} value={category}>{category}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
{[...categories].map(category => (
|
||||
<TabsContent key={category} value={category}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{category}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="w-full flex flex-col">
|
||||
{menu.filter(item => item.category === category).map((item, index) => (
|
||||
<li
|
||||
key={item.name}
|
||||
className={cn(
|
||||
"flex flex-col gap-2 sm:flex-wrap sm:justify-between sm:flex-row",
|
||||
index % 2 === 0 ? "bg-accent" : "",
|
||||
)}
|
||||
>
|
||||
<span className="">{item.name}</span>
|
||||
<div className="flex gap-2 justify-between">
|
||||
{item.price != null
|
||||
? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className=""
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "ADD_ORDER",
|
||||
order: {
|
||||
name: item.name,
|
||||
HOT: 0,
|
||||
price: item.price ?? 0,
|
||||
quantity: 1,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{item.price * 1000}원 주문
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-sky-400 hover:bg-sky-500"
|
||||
disabled={!item.COLD}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "ADD_ORDER",
|
||||
order: {
|
||||
name: item.name,
|
||||
HOT: 1,
|
||||
price: item.COLD ?? 0,
|
||||
quantity: 1,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(item.COLD ?? 0) * 1000}원 ICE 주문
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-red-400 hover:bg-red-500"
|
||||
disabled={!item.HOT}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "ADD_ORDER",
|
||||
order: {
|
||||
name: item.name,
|
||||
HOT: 1,
|
||||
price: item.HOT ?? 0,
|
||||
quantity: 1,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(item.HOT ?? 0) * 1000} HOT 주문
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
65
src/client/components/nav-menu.tsx
Normal file
65
src/client/components/nav-menu.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { EnterFullScreenIcon, ExitFullScreenIcon } from "@radix-ui/react-icons";
|
||||
import { Link } from "wouter";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "./ui/navigation-menu";
|
||||
|
||||
export default function NavMenu() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-14 max-w-screen-xl items-center justify-between">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/">
|
||||
홈
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/order">
|
||||
주문
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/menu">
|
||||
메뉴 관리
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/stat">
|
||||
통계
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{document.fullscreenElement ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
85
src/client/components/order-card.tsx
Normal file
85
src/client/components/order-card.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useCancelOrder, useCompleteOrder } from "@/hooks/useOrders";
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { OrderStateColumns } from "src/db";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
|
||||
export default function OrderCard({
|
||||
order,
|
||||
}: {
|
||||
order: OrderStateColumns;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: mutateCompleteOrder, isPending: isCompleteOrderPending } = useCompleteOrder(queryClient);
|
||||
const { mutate: mutateCancelOrder, isPending: isCancelOrderPending } = useCancelOrder(queryClient);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-60">
|
||||
<CardHeader className="relative">
|
||||
<div className="absolute right-2">
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="ghost" disabled={order.completed == 1 || isCancelOrderPending}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>
|
||||
주문 취소
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
정말로 주문을 취소하시겠습니까?
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutateCancelOrder(order.id);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
<CardTitle className={order.completed == 1 ? "text-muted-foreground" : ""}>
|
||||
{order.id.split("/")[1]}번 주문
|
||||
</CardTitle>
|
||||
<CardDescription>{order.completed ? "완료" : "대기중..."}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto">
|
||||
<hr />
|
||||
{order.orders.map((item) => (
|
||||
<div key={item.name}>
|
||||
<span>{item.HOT ? "HOT" : "COLD"} {item.name} {item.quantity}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
disabled={isCompleteOrderPending}
|
||||
onClick={() => {
|
||||
mutateCompleteOrder({
|
||||
completed: order.completed ? 0 : 1,
|
||||
id: order.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{order.completed == 0 ? "완료" : "완료 취소"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
159
src/client/components/order.tsx
Normal file
159
src/client/components/order.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useAddOrder } from "@/hooks/useOrders";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { MenuItem } from "src/db";
|
||||
import { useLocation } from "wouter";
|
||||
import { useOrder } from "../hooks/useOrder";
|
||||
import Menu from "./menu";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
|
||||
export default function Order({
|
||||
menus,
|
||||
categories,
|
||||
}: {
|
||||
menus: MenuItem[];
|
||||
categories: string[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { state, dispatch } = useOrder();
|
||||
const { mutate, isPending } = useAddOrder(queryClient);
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu menus={menus} categories={categories} dispatch={dispatch} />
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="w-full mt-2 flex items-center">
|
||||
Order{" "}
|
||||
<span className="
|
||||
ml-1 flex items-center justify-center rounded-full h-6 px-1 text-center bg-background text-foreground">
|
||||
{state.orders.reduce(
|
||||
(acc, order) => acc + order.quantity,
|
||||
0,
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>다음 주문이 맞나요?</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<hr className="mx-4" />
|
||||
<ul className="flex flex-col gap-2 justify-center m-4">
|
||||
{state.orders.map(order => (
|
||||
<li key={`${order.HOT ? "HOT" : "COLD"} ${order.name}`} className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-x-2 font-bold">
|
||||
<span className="w-12">{order.HOT ? "HOT " : "COLD"}</span>
|
||||
<span className="flex-1 break-words">{order.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<div className="space-x-2">
|
||||
<span className="flex-1 break-words">{order.price * 1000}원</span>
|
||||
<span className="flex-1">{order.quantity}개</span>
|
||||
</div>
|
||||
<span className="flex-1 text-nowrap">총가격 {order.price * order.quantity * 1000}원</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-x-2 flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "ADD_ORDER",
|
||||
order: {
|
||||
...order,
|
||||
quantity: 1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
+1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "ADD_ORDER",
|
||||
order: {
|
||||
...order,
|
||||
quantity: -1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
-1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
dispatch({ type: "REMOVE_ORDER", order })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<hr className="mx-4" />
|
||||
<div className="flex mx-4 flex-col items-end">
|
||||
<span className="text-sm text-muted-foreground">총 가격</span>
|
||||
<span className="flex-1 text-lg">
|
||||
{state.orders.map(order => order.price * order.quantity)
|
||||
.reduce((acc, price) => acc + price, 0) * 1000}원
|
||||
</span>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={async () => {
|
||||
console.log("menu order start");
|
||||
mutate({
|
||||
orders: state.orders,
|
||||
payment: "account",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate("/");
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
계좌이체 주문하기
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={async () => {
|
||||
console.log("cash order start");
|
||||
mutate({
|
||||
orders: state.orders,
|
||||
payment: "cash",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate("/");
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
현금 주문하기
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
53
src/client/components/ui/button.tsx
Normal file
53
src/client/components/ui/button.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants>
|
||||
{
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
74
src/client/components/ui/card.tsx
Normal file
74
src/client/components/ui/card.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
116
src/client/components/ui/drawer.tsx
Normal file
116
src/client/components/ui/drawer.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
};
|
24
src/client/components/ui/input.tsx
Normal file
24
src/client/components/ui/input.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
126
src/client/components/ui/navigation-menu.tsx
Normal file
126
src/client/components/ui/navigation-menu.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenuViewport,
|
||||
};
|
53
src/client/components/ui/tabs.tsx
Normal file
53
src/client/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
56
src/client/hooks/useMenu.ts
Normal file
56
src/client/hooks/useMenu.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { addNewMenuItem, deleteMenuItem, getListMenuItems, updateMenuItem } from "@/api/menu";
|
||||
import { QueryClient, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AddNewMenuItemType } from "src/schema";
|
||||
|
||||
export function useMenu() {
|
||||
return useQuery({
|
||||
queryKey: ["menu"],
|
||||
queryFn: getListMenuItems,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMenuItem(queryClient: QueryClient) {
|
||||
return useMutation({
|
||||
mutationKey: ["menu"],
|
||||
mutationFn: ({
|
||||
name,
|
||||
...rest
|
||||
}: {
|
||||
name: string;
|
||||
} & AddNewMenuItemType) => addNewMenuItem(name, rest),
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["menu"],
|
||||
});
|
||||
},
|
||||
}, queryClient);
|
||||
}
|
||||
|
||||
export function useUpdateMenuItem(queryClient: QueryClient) {
|
||||
return useMutation({
|
||||
mutationKey: ["menu"],
|
||||
mutationFn: ({
|
||||
name,
|
||||
...rest
|
||||
}: {
|
||||
name: string;
|
||||
} & AddNewMenuItemType) => updateMenuItem(name, rest),
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["menu"],
|
||||
});
|
||||
},
|
||||
}, queryClient);
|
||||
}
|
||||
|
||||
export function useDeleteMenuItem(queryClient: QueryClient) {
|
||||
return useMutation({
|
||||
mutationKey: ["menu"],
|
||||
mutationFn: deleteMenuItem,
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["menu"],
|
||||
});
|
||||
},
|
||||
}, queryClient);
|
||||
}
|
67
src/client/hooks/useOrder.ts
Normal file
67
src/client/hooks/useOrder.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useReducer } from "react";
|
||||
import { Order } from "../../db";
|
||||
|
||||
interface OrderState {
|
||||
orders: Order[];
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
orders: [],
|
||||
} as OrderState;
|
||||
|
||||
export type OrderAction =
|
||||
| { type: "ADD_ORDER"; order: Order }
|
||||
| { type: "REMOVE_ORDER"; order: Order }
|
||||
| { type: "UPDATE_ORDER"; order: Order };
|
||||
|
||||
function compareOrder(a: Order, b: Order) {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
if (a.HOT && !b.HOT) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.HOT && b.HOT) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function reducer(state: OrderState, action: OrderAction): OrderState {
|
||||
switch (action.type) {
|
||||
case "ADD_ORDER":
|
||||
if (state.orders.find(order => compareOrder(order, action.order) === 0)) {
|
||||
return {
|
||||
orders: state.orders.map(order =>
|
||||
compareOrder(order, action.order) === 0
|
||||
? {
|
||||
...order,
|
||||
quantity: Math.max(order.quantity + action.order.quantity, 0),
|
||||
}
|
||||
: order
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
orders: [...state.orders, action.order],
|
||||
};
|
||||
case "REMOVE_ORDER":
|
||||
return {
|
||||
orders: state.orders.filter(order => compareOrder(order, action.order) !== 0),
|
||||
};
|
||||
case "UPDATE_ORDER":
|
||||
return {
|
||||
orders: state.orders.map(order => compareOrder(order, action.order) === 0 ? action.order : order),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useOrder() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
return { state, dispatch };
|
||||
}
|
73
src/client/hooks/useOrders.ts
Normal file
73
src/client/hooks/useOrders.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { QueryClient, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
addNewOrder,
|
||||
cancelCompleteOrder,
|
||||
cancelOrder,
|
||||
completeOrder,
|
||||
loadOrder,
|
||||
notifyOrderChange,
|
||||
} from "../api/order";
|
||||
|
||||
export function useOrders() {
|
||||
return useQuery({
|
||||
queryKey: ["orders"],
|
||||
queryFn: loadOrder,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddOrder(queryClient: QueryClient) {
|
||||
return useMutation({
|
||||
mutationKey: ["orders"],
|
||||
mutationFn: addNewOrder,
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["orders"],
|
||||
});
|
||||
},
|
||||
}, queryClient);
|
||||
}
|
||||
|
||||
export function useCancelOrder(queryClient: QueryClient) {
|
||||
return useMutation({
|
||||
mutationKey: ["orders"],
|
||||
mutationFn: cancelOrder,
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["orders"],
|
||||
});
|
||||
},
|
||||
}, queryClient);
|
||||
}
|
||||
|
||||
export function useCompleteOrder(queryClient: QueryClient) {
|
||||
return useMutation({
|
||||
mutationKey: ["orders"],
|
||||
mutationFn: ({
|
||||
id,
|
||||
completed,
|
||||
}: {
|
||||
id: string;
|
||||
completed: 0 | 1;
|
||||
}) => completed ? completeOrder(id) : cancelCompleteOrder(id),
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["orders"],
|
||||
});
|
||||
},
|
||||
}, queryClient);
|
||||
}
|
||||
|
||||
export function useNotifyOrderChange(callback: () => void) {
|
||||
useEffect(() => {
|
||||
return notifyOrderChange(callback);
|
||||
}, [callback]);
|
||||
}
|
||||
|
||||
export function useOrderAutoRefresh(queryClient: QueryClient) {
|
||||
useNotifyOrderChange(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["orders"],
|
||||
});
|
||||
});
|
||||
}
|
78
src/client/index.css
Normal file
78
src/client/index.css
Normal file
@ -0,0 +1,78 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
|
||||
--primary: 220.9 39.3% 11%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 224 71.4% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
|
||||
--primary: 210 20% 98%;
|
||||
--primary-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 216 12.2% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
6
src/client/lib/utils.ts
Normal file
6
src/client/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
12
src/client/main.tsx
Normal file
12
src/client/main.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import "./index.css";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
9
src/client/pages/ErrorPage.tsx
Normal file
9
src/client/pages/ErrorPage.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
export default function ErrorMessage(
|
||||
{ children }: { children: React.ReactNode },
|
||||
) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground text-2xl">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/client/pages/Home.tsx
Normal file
34
src/client/pages/Home.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import OrderCard from "@/components/order-card";
|
||||
import { useOrderAutoRefresh, useOrders } from "@/hooks/useOrders";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import ErrorMessage from "./ErrorPage";
|
||||
import LoadingPage from "./Loading";
|
||||
|
||||
export default function Home() {
|
||||
const { data: orders, status } = useOrders();
|
||||
const queryClient = useQueryClient();
|
||||
useOrderAutoRefresh(queryClient);
|
||||
|
||||
if (status === "pending") {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!orders) {
|
||||
return <ErrorMessage>주문을 불러오지 못했습니다.</ErrorMessage>;
|
||||
}
|
||||
|
||||
const sortedOrders = orders.sort((a, b) => {
|
||||
// id format: "{date}/{order_number}"
|
||||
const [, anum] = a.id.split("/");
|
||||
const [, bnum] = b.id.split("/");
|
||||
return parseInt(bnum) - parseInt(anum);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedOrders.length === 0 && <ErrorMessage>주문이 없습니다.</ErrorMessage>}
|
||||
<section>
|
||||
{sortedOrders.map((order) => <OrderCard key={order.id} order={order} />)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
10
src/client/pages/Loading.tsx
Normal file
10
src/client/pages/Loading.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
export default function LoadingPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div>
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Loading...</h1>
|
||||
<p className="text-xl text-muted-foreground">This page is loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
273
src/client/pages/Menu.tsx
Normal file
273
src/client/pages/Menu.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAddMenuItem, useDeleteMenuItem, useMenu, useUpdateMenuItem } from "@/hooks/useMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrashIcon } from "@radix-ui/react-icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { MenuItemColumns } from "src/db";
|
||||
import { UpdateMenuItemType } from "src/schema";
|
||||
import ErrorMessage from "./ErrorPage";
|
||||
import LoadingPage from "./Loading";
|
||||
|
||||
function currencyFormat(price: number) {
|
||||
return price.toLocaleString("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
});
|
||||
}
|
||||
|
||||
function onlyNumber(str: string) {
|
||||
return str.replace(/[^0-9]/g, "");
|
||||
}
|
||||
|
||||
function MenuInput({
|
||||
data,
|
||||
onChange,
|
||||
className = "",
|
||||
}: {
|
||||
data: UpdateMenuItemType;
|
||||
onChange: (data: UpdateMenuItemType) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<div>
|
||||
<div className="text-muted-foreground">카테고리</div>
|
||||
<Input
|
||||
value={data.category}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
category: e.target.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Hot 가격</div>
|
||||
<Input
|
||||
value={data.HOT ? (data.HOT * 1000).toString() : ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
HOT: e.target.value ? parseInt(onlyNumber(e.target.value)) / 1000 : null,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Ice 가격</div>
|
||||
<Input
|
||||
value={data.COLD ? (data.COLD * 1000).toString() : ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
COLD: e.target.value ? parseInt(onlyNumber(e.target.value)) / 1000 : null,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">그냥 가격</div>
|
||||
<Input
|
||||
value={data.price ? (data.price * 1000).toString() : ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
price: e.target.value ? parseInt(onlyNumber(e.target.value)) / 1000 : null,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
item,
|
||||
}: {
|
||||
item: MenuItemColumns;
|
||||
}) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [deleteDrawerOpen, setDeleteDrawerOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: mutateUpdate, isPending: isUpdatePending } = useUpdateMenuItem(queryClient);
|
||||
const { mutate: mutateDelete, isPending: isDeletePending } = useDeleteMenuItem(queryClient);
|
||||
const [data, setData] = useState<UpdateMenuItemType>({
|
||||
category: item.category,
|
||||
HOT: item.HOT,
|
||||
COLD: item.COLD,
|
||||
price: item.price,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer open={deleteDrawerOpen} onOpenChange={setDeleteDrawerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{item.name} 삭제</DrawerTitle>
|
||||
<DrawerDescription>메뉴를 삭제합니다.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isDeletePending}
|
||||
onClick={() => {
|
||||
mutateDelete(item.name, {
|
||||
onSuccess: () => {
|
||||
setDeleteDrawerOpen(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
삭제하기
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<div className="border p-4 hover:bg-accent relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isDeletePending}
|
||||
className="absolute top-4 right-2"
|
||||
onClick={(e) => {
|
||||
setDeleteDrawerOpen(true);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
<span className="text-muted-foreground font-light text-sm leading-3">{item.category}</span>
|
||||
<h2 className="text-xl font-bold">{item.name}</h2>
|
||||
{item.HOT && <p className="text-sm text-muted-foreground">HOT {currencyFormat(item.HOT * 1000)}</p>}
|
||||
{item.COLD && <p className="text-sm text-muted-foreground">COLD {currencyFormat(item.COLD * 1000)}</p>}
|
||||
{item.price && <p className="text-sm text-muted-foreground">{currencyFormat(item.price * 1000)}</p>}
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{item.name} 수정</DrawerTitle>
|
||||
<DrawerDescription>메뉴를 수정합니다.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<MenuInput className="m-4" data={data} onChange={setData} />
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={isUpdatePending}
|
||||
onClick={() => {
|
||||
mutateUpdate({
|
||||
...item,
|
||||
...data,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setDrawerOpen(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
수정하기
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMenuItem() {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate, isPending } = useAddMenuItem(queryClient);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [data, setData] = useState<MenuItemColumns>({
|
||||
name: "",
|
||||
category: "",
|
||||
HOT: null,
|
||||
COLD: null,
|
||||
price: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<div className="border p-4 hover:bg-accent flex items-center justify-center">
|
||||
<h2 className="text-xl font-bold">메뉴 추가</h2>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>메뉴 추가</DrawerTitle>
|
||||
<DrawerDescription>메뉴를 추가합니다.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="m-4">
|
||||
<div className="mb-2">
|
||||
<div className="text-muted-foreground">메뉴 이름</div>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={(e) =>
|
||||
setData({
|
||||
...data,
|
||||
name: e.target.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<MenuInput
|
||||
data={data}
|
||||
onChange={(c) => {
|
||||
setData({
|
||||
...data,
|
||||
...c,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
mutate(data, {
|
||||
onSuccess: () => {
|
||||
setData({
|
||||
name: "",
|
||||
category: "",
|
||||
HOT: null,
|
||||
COLD: null,
|
||||
price: null,
|
||||
});
|
||||
setDrawerOpen(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
추가하기
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MenuPage() {
|
||||
const { data: menuItems, status } = useMenu();
|
||||
if (status === "pending") {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!menuItems) {
|
||||
return <ErrorMessage>메뉴를 불러오지 못했습니다.</ErrorMessage>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid sm:grid-cols-2 grid-cols-1 md:grid-cols-3 lg:grid-cols-4">
|
||||
{menuItems.map((item) => <MenuItem key={item.name} item={item} />)}
|
||||
<AddMenuItem />
|
||||
</div>
|
||||
);
|
||||
}
|
10
src/client/pages/NotFound.tsx
Normal file
10
src/client/pages/NotFound.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div>
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">404 Not Found</h1>
|
||||
<p className="text-xl text-muted-foreground">이 페이지는 존재하지 않습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
21
src/client/pages/Order.tsx
Normal file
21
src/client/pages/Order.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import OrderComponent from "@/components/order";
|
||||
import { useMenu } from "@/hooks/useMenu";
|
||||
import ErrorMessage from "./ErrorPage";
|
||||
import LoadingPage from "./Loading";
|
||||
|
||||
export default function Order() {
|
||||
const { data: menu, isLoading } = useMenu();
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!menu) {
|
||||
return <ErrorMessage>메뉴를 불러오지 못했습니다.</ErrorMessage>;
|
||||
}
|
||||
|
||||
const categories = [...(new Set(menu.map(item => item.category)))];
|
||||
return (
|
||||
<div className="p-4">
|
||||
<OrderComponent menus={menu} categories={categories} />
|
||||
</div>
|
||||
);
|
||||
}
|
79
src/client/pages/Stat.tsx
Normal file
79
src/client/pages/Stat.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useOrders } from "@/hooks/useOrders";
|
||||
import { Order } from "src/db";
|
||||
import ErrorMessage from "./ErrorPage";
|
||||
import LoadingPage from "./Loading";
|
||||
|
||||
function StatItem({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
<p className="text-lg">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sum(...args: number[]) {
|
||||
return args.reduce((acc, cur) => acc + cur, 0);
|
||||
}
|
||||
|
||||
function ordersSum(orders: Order[]) {
|
||||
return orders.reduce((acc, cur) => acc + cur.price * cur.quantity * 1000, 0);
|
||||
}
|
||||
|
||||
export default function Stat() {
|
||||
const { data: orders, status } = useOrders();
|
||||
|
||||
if (status === "pending") {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!orders) {
|
||||
return <ErrorMessage>주문을 불러오지 못했습니다.</ErrorMessage>;
|
||||
}
|
||||
|
||||
const completed_order = orders.filter(order => order.completed);
|
||||
const cash_order = completed_order.filter(order => order.payment === "cash");
|
||||
const account_order = completed_order.filter(order => order.payment === "account");
|
||||
return (
|
||||
<Card className="w-[300px] m-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>주문통계</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2">
|
||||
<StatItem title="총 주문 수" value={orders.length.toString()} />
|
||||
<StatItem title="완료된 주문 수" value={completed_order.length.toString()} />
|
||||
<StatItem title="현금 결제 주문 수" value={cash_order.length.toString()} />
|
||||
<StatItem title="계좌 이체 주문 수" value={account_order.length.toString()} />
|
||||
</div>
|
||||
<hr className="my-2" />
|
||||
<div className="grid grid-cols-2">
|
||||
<StatItem
|
||||
title="총 매출"
|
||||
value={sum(...completed_order.map(order => ordersSum(order.orders)))
|
||||
.toLocaleString("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
})}
|
||||
/>
|
||||
<StatItem
|
||||
title="현금 매출"
|
||||
value={sum(...cash_order.map(order => ordersSum(order.orders)))
|
||||
.toLocaleString("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
})}
|
||||
/>
|
||||
<StatItem
|
||||
title="계좌 매출"
|
||||
value={sum(...account_order.map(order => ordersSum(order.orders)))
|
||||
.toLocaleString("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
8
src/client/tsconfig.json
Normal file
8
src/client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
1
src/client/vite-env.d.ts
vendored
Normal file
1
src/client/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
49
src/db.ts
Normal file
49
src/db.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Insertable, JSONColumnType, Selectable, Updateable } from "kysely";
|
||||
|
||||
export type MenuItem = {
|
||||
name: string;
|
||||
HOT: number | null;
|
||||
COLD: number | null;
|
||||
price: number | null;
|
||||
category: string;
|
||||
};
|
||||
|
||||
export type NewMenuItem = Insertable<MenuItem>;
|
||||
export type MenuItemColumns = Selectable<MenuItem>;
|
||||
export type MenuItemUpdate = Updateable<MenuItem>;
|
||||
|
||||
export interface Order {
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
// SQLite has no Boolean datatype. Use cast(1|0|SqliteBoolean).
|
||||
HOT: 0 | 1;
|
||||
}
|
||||
|
||||
export type PaymentMethod = "account" | "cash";
|
||||
|
||||
export interface OrderState {
|
||||
id: string;
|
||||
orders: JSONColumnType<Order[]>;
|
||||
completed: 0 | 1;
|
||||
payment: PaymentMethod;
|
||||
}
|
||||
|
||||
export type NewOrderState = Insertable<OrderState>;
|
||||
export type OrderStateColumns = Selectable<OrderState>;
|
||||
export type OrderStateUpdate = Updateable<OrderState>;
|
||||
|
||||
export interface OrderNumber {
|
||||
id: string;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export type NewOrderNumber = Insertable<OrderNumber>;
|
||||
export type OrderNumberColumns = Selectable<OrderNumber>;
|
||||
export type OrderNumberUpdate = Updateable<OrderNumber>;
|
||||
|
||||
export interface Database {
|
||||
Order_State: OrderState;
|
||||
Order_Number: OrderNumber;
|
||||
Menu: MenuItem;
|
||||
}
|
47
src/schema.ts
Normal file
47
src/schema.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const addNewMenuItemSchema = z.object({
|
||||
HOT: z.number().nullable(),
|
||||
COLD: z.number().nullable(),
|
||||
price: z.number().nullable(),
|
||||
category: z.string(),
|
||||
});
|
||||
|
||||
export type AddNewMenuItemType = z.infer<typeof addNewMenuItemSchema>;
|
||||
|
||||
export const updateMenuItemSchema = z.object({
|
||||
HOT: z.number().nullable().optional(),
|
||||
COLD: z.number().nullable().optional(),
|
||||
price: z.number().nullable().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UpdateMenuItemType = z.infer<typeof updateMenuItemSchema>;
|
||||
|
||||
const paymentMethodSchema = z.enum(["account", "cash"]);
|
||||
|
||||
export type PaymentMethod = z.infer<typeof paymentMethodSchema>;
|
||||
|
||||
/**
|
||||
* SQLite has no Boolean datatype. Use cast(true|false|SqliteBoolean).
|
||||
* https://www.sqlite.org/quirks.html#no_separate_boolean_datatype
|
||||
*/
|
||||
export const SqliteBooleanSchema = z
|
||||
.union([z.literal(0), z.literal(1)]);
|
||||
export type SqliteBoolean = z.infer<typeof SqliteBooleanSchema>;
|
||||
|
||||
const orderSchema = z.object({
|
||||
name: z.string(),
|
||||
quantity: z.number(),
|
||||
price: z.number(),
|
||||
HOT: SqliteBooleanSchema,
|
||||
});
|
||||
|
||||
export type OrderType = z.infer<typeof orderSchema>;
|
||||
|
||||
export const addNewOrderSchema = z.object({
|
||||
orders: z.array(orderSchema),
|
||||
payment: paymentMethodSchema,
|
||||
});
|
||||
|
||||
export type AddNewOrderType = z.infer<typeof addNewOrderSchema>;
|
182
src/server/db.ts
Normal file
182
src/server/db.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import "dotenv/config";
|
||||
import SQLite from "better-sqlite3";
|
||||
import { existsSync } from "fs";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { Database, MenuItemUpdate, NewMenuItem, NewOrderState, Order, PaymentMethod } from "../db.ts"; // this is the Database interface we defined earlier
|
||||
|
||||
let migration = 0;
|
||||
const dbName = process.env.DB_PATH || "order.sqlite3";
|
||||
if (existsSync(dbName)) {
|
||||
console.log("Database exists");
|
||||
} else {
|
||||
migration = 1;
|
||||
}
|
||||
|
||||
const sqlite3DB = new SQLite(dbName);
|
||||
|
||||
if (migration > 0) {
|
||||
sqlite3DB.exec(`
|
||||
CREATE TABLE Order_State (
|
||||
id TEXT PRIMARY KEY,
|
||||
orders TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL,
|
||||
payment TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE Order_Number (
|
||||
id TEXT PRIMARY KEY,
|
||||
number INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE Menu (
|
||||
name TEXT PRIMARY KEY,
|
||||
HOT INTEGER,
|
||||
COLD INTEGER,
|
||||
price INTEGER,
|
||||
category TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO Menu (name, HOT, COLD, category, price) VALUES
|
||||
('아메리카노', 1.5, 2, '커피',NULL),
|
||||
('헤이즐넛 아메리카노', 1.5, 2, '커피',NULL),
|
||||
('바닐라 아메리카노', 1.5, 2, '커피',NULL),
|
||||
('꿀단지 커피', 2.5, 3, '커피',NULL),
|
||||
('곰다방커피', NULL, 2, '커피',NULL),
|
||||
('콜드브루', NULL, 2, '커피',NULL),
|
||||
('카페모카', 2, 2.5, '커피',NULL),
|
||||
('카페라떼', 3, 3.5, '카페라떼',NULL),
|
||||
('헤이즐넛 카페라떼', 3, 3.5, '카페라떼',NULL),
|
||||
('바닐라 카페라떼', 3, 3.5, '카페라떼',NULL),
|
||||
('초코라떼', 3, 3.5, '카페라떼',NULL),
|
||||
('딸기라떼', 3, 3.5, '카페라떼',NULL),
|
||||
('사과유자차', 3, 3.5, '차',NULL),
|
||||
('꿀유자차', 2, 2.5, '차',NULL),
|
||||
('폴라복숭아', NULL, 2.5, '차',NULL),
|
||||
('허니자몽 블랙티', 2.5, 3, '차',NULL),
|
||||
('오미자 차', 2, 2.5, '차',NULL),
|
||||
('달곰상곰 딸기레몬', 3.5, 3.5, '탄산',NULL),
|
||||
('청포도 에이드', 3, 3, '탄산',NULL),
|
||||
('자몽에이드', 3, 3, '탄산',NULL),
|
||||
('레몬에이드', 3, 3, '탄산',NULL),
|
||||
('오미자 에이드', 3, 3, '탄산',NULL),
|
||||
('허니브레드', NULL, NULL, '빵', 3),
|
||||
('소금빵', NULL, NULL, '빵', 3),
|
||||
('크로크무슈', NULL, NULL, '빵', 3.5);
|
||||
`);
|
||||
}
|
||||
|
||||
const dialect = new SqliteDialect({
|
||||
database: sqlite3DB,
|
||||
});
|
||||
|
||||
const db = new Kysely<Database>({
|
||||
dialect,
|
||||
});
|
||||
|
||||
export async function requestOrderNumber(): Promise<number> {
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
|
||||
return await db.transaction().execute(async trx => {
|
||||
const order_number = await trx.selectFrom("Order_Number")
|
||||
.where("id", "==", date)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
if (!order_number) {
|
||||
await trx.insertInto("Order_Number")
|
||||
.values({
|
||||
id: date,
|
||||
number: 1,
|
||||
})
|
||||
.execute();
|
||||
return 1;
|
||||
}
|
||||
const num = order_number.number + 1;
|
||||
await trx.updateTable("Order_Number")
|
||||
.set("number", num)
|
||||
.where("id", "==", date)
|
||||
.execute();
|
||||
return num;
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveOrder(order: Order[], payment: PaymentMethod) {
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const num = await requestOrderNumber();
|
||||
const id = `${date}/${num}`;
|
||||
await db.insertInto("Order_State")
|
||||
.values({
|
||||
id,
|
||||
orders: JSON.stringify(order),
|
||||
completed: 0,
|
||||
payment,
|
||||
} as NewOrderState)
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function loadOrder() {
|
||||
const orders = (await db.selectFrom("Order_State")
|
||||
.selectAll()
|
||||
.orderBy(["completed", "id desc"])
|
||||
.execute())
|
||||
.map(order => ({
|
||||
...order,
|
||||
orders: JSON.parse(order.orders as unknown as string) as Order[],
|
||||
}));
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
export async function clearOrder() {
|
||||
await db.deleteFrom("Order_State")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function cancelOrder(order_id: string) {
|
||||
await db.deleteFrom("Order_State")
|
||||
.where("id", "==", order_id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function completeOrder(uid: string, completed: 0 | 1 = 1) {
|
||||
await db.updateTable("Order_State")
|
||||
.set("completed", completed)
|
||||
.where("id", "==", uid)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function loadOrderById(uid: string) {
|
||||
const order = await db.selectFrom("Order_State")
|
||||
.where("id", "==", uid)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
return order
|
||||
? {
|
||||
...order,
|
||||
orders: JSON.parse(order.orders as unknown as string) as Order[],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function addNewMenuItem(item: NewMenuItem) {
|
||||
await db.insertInto("Menu")
|
||||
.values(item)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function listMenuItems() {
|
||||
return await db.selectFrom("Menu")
|
||||
.selectAll()
|
||||
.orderBy(["category", "name"])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteMenuItem(name: string) {
|
||||
await db.deleteFrom("Menu")
|
||||
.where("name", "==", name)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function updateMenuItem(name: string, item: MenuItemUpdate) {
|
||||
await db.updateTable("Menu")
|
||||
.set(item)
|
||||
.where("name", "==", name)
|
||||
.execute();
|
||||
}
|
32
src/server/error_handler.ts
Normal file
32
src/server/error_handler.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function errorWrapper(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await fn(req, res, next);
|
||||
} catch (e) {
|
||||
errorHandler(e, req, res, next);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({
|
||||
error: err.errors,
|
||||
errorType: "ZodError",
|
||||
message: `Invalid request body: ${err.message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
} else {
|
||||
// This should never happen. If it does, it's a bug in the server code.
|
||||
// error must be an instance of Error.
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "An unknown error occurred" });
|
||||
}
|
||||
}
|
27
src/server/main.ts
Normal file
27
src/server/main.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import express from "express";
|
||||
import ViteExpress from "vite-express";
|
||||
import MenuRouter from "./menu.ts";
|
||||
import OrderRouter from "./order.ts";
|
||||
import "dotenv/config";
|
||||
import { errorHandler } from "./error_handler.ts";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("etag", false);
|
||||
app.use("/api", express.urlencoded({ extended: true }));
|
||||
app.use("/api", express.json());
|
||||
app.use("/api", MenuRouter);
|
||||
app.use("/api", OrderRouter);
|
||||
app.get("/api/alive", (_, res) => {
|
||||
res.send("Alive!");
|
||||
});
|
||||
app.use(errorHandler);
|
||||
|
||||
ViteExpress.config({
|
||||
ignorePaths: /\/api/,
|
||||
});
|
||||
|
||||
const portNum = parseInt(process.env.SERVER_PORT ?? "NaN");
|
||||
const port = isNaN(portNum) ? 3000 : portNum;
|
||||
|
||||
ViteExpress.listen(app, port, () => console.log(`Server is listening on port ${port}...`));
|
44
src/server/menu.ts
Normal file
44
src/server/menu.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Router } from "express";
|
||||
import { updateMenuItemSchema } from "src/schema.ts";
|
||||
import { addNewMenuItemSchema } from "../schema.ts";
|
||||
import { addNewMenuItem, deleteMenuItem, listMenuItems, updateMenuItem } from "./db.ts";
|
||||
import { errorWrapper } from "./error_handler.ts";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/menu",
|
||||
errorWrapper(async (req, res) => {
|
||||
res.json(await listMenuItems());
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/menu/:name",
|
||||
errorWrapper(async (req, res, _) => {
|
||||
const name = req.params.name;
|
||||
const item = addNewMenuItemSchema.parse(req.body);
|
||||
await addNewMenuItem({ ...item, name });
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/menu/:name",
|
||||
errorWrapper(async (req, res) => {
|
||||
const name = req.params.name;
|
||||
const item = updateMenuItemSchema.parse(req.body);
|
||||
await updateMenuItem(name, item);
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/menu/:name",
|
||||
errorWrapper(async (req, res) => {
|
||||
await deleteMenuItem(req.params.name);
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
125
src/server/order.ts
Normal file
125
src/server/order.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { Router } from "express";
|
||||
import { addNewOrderSchema } from "src/schema.ts";
|
||||
import * as api from "./db.ts";
|
||||
import { errorWrapper } from "./error_handler.ts";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function addOrderListener(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
function removeOrderListener(listener: () => void) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
|
||||
function emitOrder() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/order",
|
||||
errorWrapper(async (req, res) => {
|
||||
res.json(await api.loadOrder());
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/order/notify",
|
||||
errorWrapper(async (req, res) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.flushHeaders();
|
||||
|
||||
const listener = () => {
|
||||
res.write(
|
||||
`data: ${
|
||||
JSON.stringify({
|
||||
change: "order",
|
||||
})
|
||||
}\n\n`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
// we could not send the event to the client anymore
|
||||
// so we could not call next()
|
||||
// only log the error
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
addOrderListener(listener);
|
||||
|
||||
req.on("close", () => {
|
||||
removeOrderListener(listener);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/order/:id",
|
||||
errorWrapper(async (req, res) => {
|
||||
const order = await api.loadOrderById(req.params.id);
|
||||
if (order) {
|
||||
res.json(order);
|
||||
} else {
|
||||
res.status(404).json({ error: "Order not found" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/order",
|
||||
errorWrapper(async (req, res) => {
|
||||
const orderReq = addNewOrderSchema.parse(req.body);
|
||||
const orders = orderReq.orders;
|
||||
const payment = orderReq.payment;
|
||||
const id = await api.saveOrder(orders, payment);
|
||||
emitOrder();
|
||||
res.json({ id });
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/order/:id",
|
||||
errorWrapper(async (req, res) => {
|
||||
await api.cancelOrder(req.params.id);
|
||||
emitOrder();
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/order/:id/complete",
|
||||
errorWrapper(async (req, res) => {
|
||||
await api.completeOrder(req.params.id, 1);
|
||||
emitOrder();
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/order/:id/cancel-complete",
|
||||
errorWrapper(async (req, res) => {
|
||||
await api.completeOrder(req.params.id, 0);
|
||||
emitOrder();
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/order",
|
||||
errorWrapper(async (req, res) => {
|
||||
await api.clearOrder();
|
||||
emitOrder();
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
77
tailwind.config.js
Normal file
77
tailwind.config.js
Normal file
@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/client/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src/client"),
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Reference in New Issue
Block a user