QT-CART
This commit is contained in:
1 parent
e429161a89
commit
0c55b8f1aa
134 files changed
+6071
-5801
No files matched your search
@@ -0,0 +1,62 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/dashboard",
|
||||
tags=["Dashboard"]
|
||||
)
|
||||
|
||||
bot_state = {
|
||||
"status": "OFFLINE",
|
||||
"last_updated": datetime.now(),
|
||||
"battery": 0.0,
|
||||
"speed": 0.0
|
||||
}
|
||||
|
||||
BOT_TIMEOUT_SEC = 5 # 5초간 보고 없으면 오프라인
|
||||
|
||||
class BotReport(BaseModel):
|
||||
status: str
|
||||
battery: float = 0.0
|
||||
speed: float = 0.0
|
||||
|
||||
class BotConnectionInfo(BaseModel):
|
||||
bot_status: str
|
||||
battery: float
|
||||
speed: float
|
||||
last_update: str
|
||||
|
||||
# 로봇 -> 서버
|
||||
@router.post("/bot/report")
|
||||
def report_bot_status(report: BotReport):
|
||||
global bot_state
|
||||
bot_state["status"] = report.status
|
||||
bot_state["last_updated"] = datetime.now()
|
||||
bot_state["battery"] = report.battery
|
||||
bot_state["speed"] = report.speed
|
||||
return {"msg": "status report received"}
|
||||
|
||||
# 서버 -> 프론트엔드
|
||||
@router.get("/bot/check", response_model=BotConnectionInfo)
|
||||
def check_bot_connection():
|
||||
global bot_state
|
||||
|
||||
elapsed = (datetime.now() - bot_state["last_updated"]).total_seconds()
|
||||
|
||||
current_status = bot_state["status"]
|
||||
if elapsed > BOT_TIMEOUT_SEC:
|
||||
current_status = "OFFLINE"
|
||||
bot_state["speed"] = 0.0
|
||||
|
||||
return {
|
||||
"bot_status": current_status,
|
||||
"battery": bot_state["battery"],
|
||||
"speed": bot_state["speed"],
|
||||
"last_update": bot_state["last_updated"].strftime("%H:%M:%S")
|
||||
}
|
||||
|
||||
# 서버 연결 확인
|
||||
@router.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "time": datetime.now().isoformat()}
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./shopping_cart.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,74 @@
|
||||
import lgpio
|
||||
import time
|
||||
|
||||
|
||||
class HX711:
|
||||
def __init__(self, dout, pd_sck, gain=128):
|
||||
self.DOUT = dout
|
||||
self.PD_SCK = pd_sck
|
||||
|
||||
self.chip = lgpio.gpiochip_open(0)
|
||||
|
||||
lgpio.gpio_claim_input(self.chip, self.DOUT)
|
||||
lgpio.gpio_claim_output(self.chip, self.PD_SCK)
|
||||
|
||||
self.GAIN = 1 if gain == 128 else 3 if gain == 64 else 2
|
||||
|
||||
self.OFFSET = 0
|
||||
self.REFERENCE_UNIT = 1
|
||||
|
||||
self.set_gain(gain)
|
||||
|
||||
def is_ready(self):
|
||||
return lgpio.gpio_read(self.chip, self.DOUT) == 0
|
||||
|
||||
def set_gain(self, gain):
|
||||
if gain == 128:
|
||||
self.GAIN = 1
|
||||
elif gain == 64:
|
||||
self.GAIN = 3
|
||||
elif gain == 32:
|
||||
self.GAIN = 2
|
||||
|
||||
self.read_raw()
|
||||
|
||||
def read_raw(self):
|
||||
while not self.is_ready():
|
||||
pass
|
||||
|
||||
data = 0
|
||||
for _ in range(24):
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 1)
|
||||
data = (data << 1) | lgpio.gpio_read(self.chip, self.DOUT)
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
|
||||
|
||||
for _ in range(self.GAIN):
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 1)
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
|
||||
|
||||
if data & 0x800000:
|
||||
data -= 0x1000000
|
||||
|
||||
return data
|
||||
|
||||
def set_offset(self, offset):
|
||||
self.OFFSET = offset
|
||||
|
||||
def set_reference_unit(self, value):
|
||||
self.REFERENCE_UNIT = value
|
||||
|
||||
def get_value(self, times=5):
|
||||
return sum(self.read_raw() for _ in range(times)) / times
|
||||
|
||||
def get_weight(self, times=5):
|
||||
value = self.get_value(times) - self.OFFSET
|
||||
return value / self.REFERENCE_UNIT
|
||||
|
||||
def power_down(self):
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 1)
|
||||
time.sleep(0.0001)
|
||||
|
||||
def power_up(self):
|
||||
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
|
||||
time.sleep(0.0001)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"offset": 449326.96,
|
||||
"scale": 311.0220000000001
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
from hx711 import HX711
|
||||
import time, json
|
||||
|
||||
DT_PIN = 5
|
||||
SCK_PIN = 6
|
||||
|
||||
hx = HX711(DT_PIN, SCK_PIN)
|
||||
|
||||
print("HX711 보정 시작")
|
||||
time.sleep(2)
|
||||
|
||||
# 1) 빈 상태
|
||||
print("1️⃣ 아무것도 안 올리고 기다리세요...")
|
||||
N = 30
|
||||
empty = sum(hx.get_value(5) for _ in range(N)) / N
|
||||
print("OFFSET =", empty)
|
||||
|
||||
known = float(input("2️⃣ 기준 무게(g): "))
|
||||
|
||||
print("3️⃣ 기준 무게 올리고 기다리세요...")
|
||||
|
||||
raw = sum(hx.get_value(5) for _ in range(N)) / N
|
||||
|
||||
scale = (raw - empty) / known
|
||||
|
||||
print("\n보정 완료")
|
||||
print("offset =", empty)
|
||||
print("scale =", scale)
|
||||
|
||||
calib = {
|
||||
"offset": empty,
|
||||
"scale": scale
|
||||
}
|
||||
|
||||
with open("hx711_calib.json", "w") as f:
|
||||
json.dump(calib, f, indent=4)
|
||||
|
||||
print("✔ hx711_calib.json 저장 완료")
|
||||
@@ -0,0 +1,23 @@
|
||||
from hx711 import HX711
|
||||
import json, time
|
||||
|
||||
DT_PIN = 5
|
||||
SCK_PIN = 6
|
||||
|
||||
with open("hx711_calib.json") as f:
|
||||
calib = json.load(f)
|
||||
|
||||
OFFSET = calib["offset"]
|
||||
SCALE = calib["scale"]
|
||||
|
||||
hx = HX711(DT_PIN, SCK_PIN)
|
||||
|
||||
hx.set_offset(OFFSET)
|
||||
hx.set_reference_unit(SCALE)
|
||||
|
||||
print(" 무게 측정 시작")
|
||||
|
||||
while True:
|
||||
weight = hx.get_weight(10)
|
||||
print(f"무게: {weight:.1f} g")
|
||||
time.sleep(0.5)
|
||||
@@ -0,0 +1,200 @@
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
import models, database
|
||||
import dashboard
|
||||
import logging
|
||||
from read_weight import read_cart_weight
|
||||
|
||||
CART_ITEMS = []
|
||||
EXPECTED_WEIGHT = 0.0
|
||||
|
||||
app = FastAPI()
|
||||
models.Base.metadata.create_all(bind=database.engine)
|
||||
|
||||
# 대시보드 라우터 등록
|
||||
app.include_router(dashboard.router)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
def get_db():
|
||||
db = database.SessionLocal()
|
||||
try: yield db
|
||||
finally: db.close()
|
||||
|
||||
# 무게 검증 로직
|
||||
def is_movable(expected, real):
|
||||
if expected <= 0: return True
|
||||
diff_ratio = abs(real - expected) / expected
|
||||
return diff_ratio <= 0.2
|
||||
|
||||
# 페이지 라우팅
|
||||
@app.get("/dashboard")
|
||||
async def dashboard_view(): return FileResponse("templates/dashboard.html")
|
||||
|
||||
@app.get("/inventory")
|
||||
async def inventory_view(): return FileResponse("templates/inventory.html")
|
||||
|
||||
# 카트 무게 기준값 설정
|
||||
@app.post("/cart/tare")
|
||||
def tare_cart():
|
||||
global EXPECTED_WEIGHT
|
||||
EXPECTED_WEIGHT = read_cart_weight()
|
||||
return {"expected_weight": EXPECTED_WEIGHT}
|
||||
|
||||
@app.get("/items", response_model=List[models.ItemSchema])
|
||||
def read_all_items(db: Session = Depends(get_db)):
|
||||
return db.query(models.Item).all()
|
||||
|
||||
# 상품 추가
|
||||
@app.post("/cart/add/{item_id}")
|
||||
def add_item(item_id: int, db: Session = Depends(get_db)):
|
||||
global EXPECTED_WEIGHT, CART_ITEMS
|
||||
|
||||
item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item not found")
|
||||
|
||||
CART_ITEMS.append({
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"price": item.price,
|
||||
"weight": item.weight
|
||||
})
|
||||
|
||||
EXPECTED_WEIGHT += item.weight
|
||||
|
||||
real = read_cart_weight()
|
||||
movable = is_movable(EXPECTED_WEIGHT, real)
|
||||
|
||||
return {
|
||||
"action": "add",
|
||||
"id": item.id,
|
||||
"item": item.name,
|
||||
"price": item.price,
|
||||
"expected_weight": EXPECTED_WEIGHT,
|
||||
"real_weight": real,
|
||||
"diff": abs(real - EXPECTED_WEIGHT),
|
||||
"movable": movable
|
||||
}
|
||||
|
||||
# 상품 제거
|
||||
@app.post("/cart/remove/{item_id}")
|
||||
def remove_item(item_id: int):
|
||||
global EXPECTED_WEIGHT, CART_ITEMS
|
||||
|
||||
for i, item in enumerate(CART_ITEMS):
|
||||
if item["id"] == item_id:
|
||||
removed = CART_ITEMS.pop(i)
|
||||
EXPECTED_WEIGHT -= removed["weight"]
|
||||
break
|
||||
else:
|
||||
raise HTTPException(404, "Item not in cart")
|
||||
|
||||
EXPECTED_WEIGHT = max(EXPECTED_WEIGHT, 0)
|
||||
|
||||
real = read_cart_weight()
|
||||
movable = is_movable(EXPECTED_WEIGHT, real)
|
||||
|
||||
return {
|
||||
"action": "remove",
|
||||
"id": removed["id"],
|
||||
"item": removed["name"],
|
||||
"price": removed["price"],
|
||||
"expected_weight": EXPECTED_WEIGHT,
|
||||
"real_weight": real,
|
||||
"movable": movable
|
||||
}
|
||||
|
||||
# 장바구니 조회
|
||||
@app.get("/cart")
|
||||
def get_cart():
|
||||
total_price = sum(item["price"] for item in CART_ITEMS)
|
||||
|
||||
return {
|
||||
"items": CART_ITEMS,
|
||||
"total_count": len(CART_ITEMS),
|
||||
"total_price": total_price,
|
||||
"expected_weight": EXPECTED_WEIGHT
|
||||
}
|
||||
|
||||
# 무게 체크
|
||||
@app.get("/cart/check_weight")
|
||||
def check_cart():
|
||||
real = read_cart_weight()
|
||||
movable = is_movable(EXPECTED_WEIGHT, real)
|
||||
|
||||
return {
|
||||
"expected_weight": EXPECTED_WEIGHT,
|
||||
"real_weight": real,
|
||||
"diff": abs(real - EXPECTED_WEIGHT),
|
||||
"movable": movable
|
||||
}
|
||||
|
||||
# 대시보드 상태 조회
|
||||
@app.get("/cart/status")
|
||||
def get_cart_status_for_dashboard():
|
||||
global EXPECTED_WEIGHT, CART_ITEMS
|
||||
|
||||
real = read_cart_weight()
|
||||
movable = is_movable(EXPECTED_WEIGHT, real)
|
||||
|
||||
system_status = "NORMAL" if movable else "WARNING_WEIGHT_MISMATCH"
|
||||
|
||||
return {
|
||||
"real_weight": real,
|
||||
"expected_weight": EXPECTED_WEIGHT,
|
||||
"cart_items": CART_ITEMS,
|
||||
"total_price": sum(item["price"] for item in CART_ITEMS),
|
||||
"total_count": len(CART_ITEMS),
|
||||
"system_status": system_status,
|
||||
"movable": movable
|
||||
}
|
||||
|
||||
# 결제
|
||||
@app.post("/cart/pay")
|
||||
def pay_cart(db: Session = Depends(get_db)):
|
||||
global CART_ITEMS, EXPECTED_WEIGHT
|
||||
|
||||
if not CART_ITEMS:
|
||||
raise HTTPException(status_code=400, detail="장바구니가 비어있습니다.")
|
||||
|
||||
total_price = sum(item["price"] for item in CART_ITEMS)
|
||||
|
||||
new_pay = models.Pay(
|
||||
total_price=total_price,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
db.add(new_pay)
|
||||
db.commit()
|
||||
|
||||
reset_cart()
|
||||
|
||||
return {"msg": "결제 완료", "amount": total_price}
|
||||
|
||||
# 결제 내역 조회
|
||||
@app.get("/payments", response_model=List[models.PaySchema])
|
||||
def get_payment_history(db: Session = Depends(get_db)):
|
||||
return db.query(models.Pay).order_by(models.Pay.created_at.desc()).all()
|
||||
|
||||
# 카트 초기화
|
||||
@app.post("/cart/reset")
|
||||
def reset_cart():
|
||||
global EXPECTED_WEIGHT, CART_ITEMS
|
||||
EXPECTED_WEIGHT = 0.0
|
||||
CART_ITEMS.clear()
|
||||
return {"ok": True}
|
||||
|
||||
# 로그 필터
|
||||
class EndpointFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return all(x not in msg for x in ["/bot/check", "/bot/report", "/cart/status"])
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,74 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime
|
||||
from database import Base
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
# 상품 테이블
|
||||
class Item(Base):
|
||||
__tablename__ = "items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
price = Column(Float)
|
||||
stock = Column(Integer)
|
||||
weight = Column(Float) # 무게 로직용
|
||||
expiry_date = Column(String) # 대시보드 유통기한 관리용
|
||||
|
||||
# 결제 내역 테이블
|
||||
class Pay(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
total_price = Column(Float)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
# 상품 생성 요청
|
||||
class ItemCreate(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
stock: int
|
||||
weight: float
|
||||
expiry_date: str
|
||||
|
||||
# 상품 조회 응답
|
||||
class ItemSchema(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
price: float
|
||||
stock: int
|
||||
weight: float
|
||||
expiry_date: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# 결제 내역 응답 (대시보드 차트용)
|
||||
class PaySchema(BaseModel):
|
||||
id: int
|
||||
total_price: float
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CartScanResponse(BaseModel):
|
||||
item: ItemSchema
|
||||
cart_weight: float
|
||||
expected_weight: float
|
||||
weight_ok: bool
|
||||
diff: float
|
||||
|
||||
allowed: float
|
||||
|
||||
# 무게 검증 응답
|
||||
class CartScanResponse(BaseModel):
|
||||
item: ItemSchema # 스캔된 상품 정보
|
||||
cart_weight: float # 현재 실제 무게
|
||||
expected_weight: float # 예상 무게
|
||||
weight_ok: bool # 무게 일치 여부
|
||||
diff: float # 오차
|
||||
allowed: float # 허용 오차 범위
|
||||
|
||||
# 무게 포함 아이템 정보
|
||||
class ItemWithWeight(ItemSchema):
|
||||
cart_weight: float
|
||||
@@ -0,0 +1,33 @@
|
||||
from hx711 import HX711
|
||||
import json, time, threading
|
||||
|
||||
# --- 보정값 로드 ---
|
||||
with open("hx711_calib.json", "r") as f:
|
||||
calib = json.load(f)
|
||||
|
||||
OFFSET = calib["offset"]
|
||||
SCALE = calib["scale"]
|
||||
|
||||
hx = HX711(5, 6)
|
||||
|
||||
hx_lock = threading.Lock()
|
||||
|
||||
def read_cart_weight():
|
||||
"""현재 카트 무게(g)를 반환"""
|
||||
with hx_lock:
|
||||
# 여러 번 평균 내서 노이즈 줄이기
|
||||
samples = 10
|
||||
total = 0
|
||||
|
||||
for _ in range(samples):
|
||||
total += hx.read_raw()
|
||||
time.sleep(0.02)
|
||||
|
||||
raw = total / samples
|
||||
weight = (raw - OFFSET) / SCALE
|
||||
|
||||
# 음수/미세값 보호
|
||||
if weight < 0:
|
||||
weight = 0
|
||||
|
||||
return round(weight, 2)
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
SQLAlchemy
|
||||
pydantic
|
||||
@@ -0,0 +1,310 @@
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.action import ActionClient
|
||||
from geometry_msgs.msg import TwistStamped, PoseWithCovarianceStamped
|
||||
from nav2_msgs.action import NavigateToPose
|
||||
from sensor_msgs.msg import BatteryState
|
||||
from nav_msgs.msg import Odometry
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy
|
||||
|
||||
import socket
|
||||
import time
|
||||
import math
|
||||
import requests
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
UDP_PORT_UWB = 44444
|
||||
UDP_PORT_QT = 55555
|
||||
BUFFER_SIZE = 1024
|
||||
SERVER_IP = "192.168.123.43"
|
||||
API_REPORT_URL = f"http://{SERVER_IP}:8000/dashboard/bot/report"
|
||||
API_WEIGHT_URL = f"http://{SERVER_IP}:8000/cart/check_weight"
|
||||
|
||||
TAG_HEIGHT = 1.85
|
||||
ANCHOR_Z = 2.37
|
||||
H_DIFF = abs(ANCHOR_Z - TAG_HEIGHT)
|
||||
|
||||
# 고정 오프셋 보정값
|
||||
FIXED_OFFSETS = {0: -7.00, 1: -7.00, 2: -7.50}
|
||||
# 앵커 절대 좌표
|
||||
ANCHORS_POS = {
|
||||
0: {'x': 0.00, 'y': 0.00},
|
||||
1: {'x': 6.00, 'y': 0.00},
|
||||
2: {'x': 3.00, 'y': 15.00}
|
||||
}
|
||||
MIN_UPDATE_DIST = 1.2
|
||||
NAV_UPDATE_INTERVAL = 2.0
|
||||
|
||||
class IntegratedRosController(Node):
|
||||
def __init__(self):
|
||||
super().__init__('integrated_ros_controller_node')
|
||||
|
||||
amcl_qos = QoSProfile(
|
||||
depth=1,
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||
history=HistoryPolicy.KEEP_LAST
|
||||
)
|
||||
|
||||
self.cmd_vel_pub = self.create_publisher(TwistStamped, '/cmd_vel', 10)
|
||||
self.battery_sub = self.create_subscription(BatteryState, '/battery_state', self.battery_callback, 10)
|
||||
self.odom_sub = self.create_subscription(Odometry, '/odom', self.odom_callback, 10)
|
||||
self.amcl_sub = self.create_subscription(
|
||||
PoseWithCovarianceStamped,
|
||||
'/amcl_pose',
|
||||
self.amcl_callback,
|
||||
amcl_qos
|
||||
)
|
||||
|
||||
self.nav_to_pose_client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
|
||||
|
||||
self.sock_uwb = self.create_udp_socket(UDP_PORT_UWB)
|
||||
self.sock_qt = self.create_udp_socket(UDP_PORT_QT)
|
||||
|
||||
self.current_mode = 0
|
||||
self.battery_level = 0.0
|
||||
self.current_speed = 0.0
|
||||
self.qt_addr = None
|
||||
|
||||
self.robot_x = 0.0
|
||||
self.robot_y = 0.0
|
||||
self.human_x = 0.0
|
||||
self.human_y = 0.0
|
||||
|
||||
self.x_his = deque(maxlen=15)
|
||||
self.y_his = deque(maxlen=5)
|
||||
self.uwb_dists = [0.0, 0.0, 0.0]
|
||||
self.uwb_last_update = [0.0, 0.0, 0.0]
|
||||
|
||||
self.nav_goal_handle = None
|
||||
self.last_goal_time = 0.0
|
||||
self.last_target_pos = [0.0, 0.0]
|
||||
|
||||
self.is_weight_ok = True
|
||||
self.last_weight_check_time = 0.0
|
||||
self.weight_check_interval = 3.0
|
||||
|
||||
self.get_logger().info("Waiting for Nav2 Server...")
|
||||
if not self.nav_to_pose_client.wait_for_server(timeout_sec=5.0):
|
||||
self.get_logger().warn("Nav2 Server not ready yet.")
|
||||
|
||||
self.create_timer(0.05, self.control_loop)
|
||||
self.create_timer(1.0, self.send_bot_status)
|
||||
|
||||
self.get_logger().info("ROS Controller Started")
|
||||
|
||||
def create_udp_socket(self, port):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('0.0.0.0', port))
|
||||
sock.setblocking(False)
|
||||
return sock
|
||||
|
||||
# 무게 체크
|
||||
def trigger_async_weight_check(self):
|
||||
def _check():
|
||||
try:
|
||||
response = requests.get(API_WEIGHT_URL, timeout=0.5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
movable = data.get("movable", True)
|
||||
self.is_weight_ok = movable
|
||||
|
||||
if not movable:
|
||||
self.send_udp_to_qt("ERROR:WEIGHT_MISMATCH")
|
||||
except: pass
|
||||
|
||||
threading.Thread(target=_check, daemon=True).start()
|
||||
|
||||
def send_udp_to_qt(self, message):
|
||||
if self.qt_addr:
|
||||
try: self.sock_qt.sendto(message.encode(), self.qt_addr)
|
||||
except: pass
|
||||
|
||||
def battery_callback(self, msg):
|
||||
self.battery_level = int(msg.percentage) if msg.percentage >= 0 else 0.0
|
||||
|
||||
def odom_callback(self, msg):
|
||||
self.current_speed = msg.twist.twist.linear.x
|
||||
|
||||
def amcl_callback(self, msg):
|
||||
self.robot_x = msg.pose.pose.position.x
|
||||
self.robot_y = msg.pose.pose.position.y
|
||||
|
||||
# 로봇 상태 전송
|
||||
def send_bot_status(self):
|
||||
def _post():
|
||||
try:
|
||||
payload = {
|
||||
"status": "ONLINE",
|
||||
"mode": self.current_mode,
|
||||
"battery": round(self.battery_level, 1),
|
||||
"speed": round(self.current_speed, 2),
|
||||
"x": self.robot_x,
|
||||
"y": self.robot_y,
|
||||
"human_x": self.human_x,
|
||||
"human_y": self.human_y
|
||||
}
|
||||
requests.post(API_REPORT_URL, json=payload, timeout=2.0)
|
||||
except: pass
|
||||
|
||||
# 스레드 비동기 전송 후 종료
|
||||
threading.Thread(target=_post, daemon=True).start()
|
||||
|
||||
# 삼변측량 함수
|
||||
def calculate_trilateration(self, distances):
|
||||
try:
|
||||
x1, y1, r1 = ANCHORS_POS[0]['x'], ANCHORS_POS[0]['y'], distances[0]
|
||||
x2, y2, r2 = ANCHORS_POS[1]['x'], ANCHORS_POS[1]['y'], distances[1]
|
||||
x3, y3, r3 = ANCHORS_POS[2]['x'], ANCHORS_POS[2]['y'], distances[2]
|
||||
A, B = 2*(x2-x1), 2*(y2-y1)
|
||||
C = r1**2 - r2**2 - x1**2 + x2**2 - y1**2 + y2**2
|
||||
D, E = 2*(x3-x2), 2*(y3-y2)
|
||||
F = r2**2 - r3**2 - x2**2 + x3**2 - y2**2 + y3**2
|
||||
den = (A * E) - (B * D)
|
||||
if abs(den) < 0.0001: return None
|
||||
return ((C * E) - (B * F)) / den, ((A * F) - (C * D)) / den
|
||||
except: return None
|
||||
|
||||
def transform_to_map(self, ux, uy):
|
||||
mx = 3.25 + (ux * -0.9725)
|
||||
my = -7.35 + (uy * 0.5966)
|
||||
return mx, my
|
||||
|
||||
# UWB 데이터 수신
|
||||
def receive_uwb_data(self):
|
||||
try:
|
||||
for _ in range(10):
|
||||
data, _ = self.sock_uwb.recvfrom(BUFFER_SIZE)
|
||||
msg = data.decode('utf-8').strip()
|
||||
idx = -1
|
||||
if msg.startswith("A1:"): idx = 0
|
||||
elif msg.startswith("A2:"): idx = 1
|
||||
elif msg.startswith("A3:"): idx = 2
|
||||
|
||||
if idx != -1:
|
||||
raw_val = float(msg.split(":")[1])
|
||||
corrected_dist = raw_val - FIXED_OFFSETS[idx]
|
||||
h_dist = math.sqrt(corrected_dist**2 - H_DIFF**2) if corrected_dist > H_DIFF else 0.1
|
||||
self.uwb_dists[idx] = h_dist
|
||||
self.uwb_last_update[idx] = time.time()
|
||||
except BlockingIOError: pass
|
||||
except: pass
|
||||
|
||||
def receive_qt_command(self):
|
||||
try:
|
||||
while True:
|
||||
data, addr = self.sock_qt.recvfrom(BUFFER_SIZE)
|
||||
self.qt_addr = addr
|
||||
text = data.decode('utf-8').strip()
|
||||
if text.startswith("MODE:"): self.change_mode(int(text.split(":")[1]))
|
||||
elif text.startswith("GOAL:"):
|
||||
if self.current_mode != 2: self.change_mode(2)
|
||||
p = text.split(":")[1].split(",")
|
||||
self.start_navigation(float(p[0]), float(p[1]))
|
||||
elif text.startswith("STOP"): self.change_mode(0)
|
||||
except BlockingIOError: pass
|
||||
except: pass
|
||||
|
||||
def control_loop(self):
|
||||
self.receive_uwb_data()
|
||||
self.receive_qt_command()
|
||||
|
||||
now = time.time()
|
||||
|
||||
if now - self.last_weight_check_time > self.weight_check_interval:
|
||||
self.trigger_async_weight_check()
|
||||
self.last_weight_check_time = now
|
||||
|
||||
if all(now - self.uwb_last_update[i] < 1.0 for i in range(3)):
|
||||
pos = self.calculate_trilateration(self.uwb_dists)
|
||||
if pos:
|
||||
raw_x = max(0, min(pos[0], 6.0))
|
||||
raw_y = max(0, min(pos[1], 15.0))
|
||||
self.x_his.append(raw_x)
|
||||
self.y_his.append(raw_y)
|
||||
avg_x = sum(self.x_his) / len(self.x_his)
|
||||
avg_y = sum(self.y_his) / len(self.y_his)
|
||||
|
||||
final_uwb_x = (avg_x / 6.0) * (3.80 - 0.10) + 0.10
|
||||
final_uwb_y = avg_y
|
||||
|
||||
mx, my = self.transform_to_map(final_uwb_x, final_uwb_y)
|
||||
self.human_x = mx
|
||||
self.human_y = my
|
||||
|
||||
if self.current_mode == 1:
|
||||
dist_diff = math.sqrt((mx - self.last_target_pos[0])**2 + (my - self.last_target_pos[1])**2)
|
||||
|
||||
if self.is_weight_ok:
|
||||
if (now - self.last_goal_time > NAV_UPDATE_INTERVAL) and (dist_diff > MIN_UPDATE_DIST):
|
||||
self.get_logger().info(f"Target Moved -> Goal: ({mx:.2f}, {my:.2f})")
|
||||
self.start_navigation(mx, my)
|
||||
self.last_goal_time = now
|
||||
self.last_target_pos = [mx, my]
|
||||
|
||||
if self.current_mode == 0:
|
||||
if self.nav_goal_handle: self.cancel_navigation()
|
||||
self.stop_robot()
|
||||
|
||||
def change_mode(self, new_mode):
|
||||
if self.current_mode == new_mode: return
|
||||
self.get_logger().info(f"Mode Switching: {self.current_mode} -> {new_mode}")
|
||||
self.cancel_navigation()
|
||||
self.stop_robot()
|
||||
self.current_mode = new_mode
|
||||
self.last_target_pos = [0.0, 0.0]
|
||||
self.trigger_async_weight_check()
|
||||
|
||||
# 네비게이션 실행
|
||||
def start_navigation(self, x, y):
|
||||
if not self.is_weight_ok: return
|
||||
|
||||
if not self.nav_to_pose_client.server_is_ready():
|
||||
self.get_logger().error("Nav2 Server not ready!")
|
||||
return
|
||||
|
||||
goal_msg = NavigateToPose.Goal()
|
||||
goal_msg.pose.header.frame_id = 'map'
|
||||
goal_msg.pose.header.stamp = self.get_clock().now().to_msg()
|
||||
goal_msg.pose.pose.position.x = x
|
||||
goal_msg.pose.pose.position.y = y
|
||||
goal_msg.pose.pose.orientation.w = 1.0
|
||||
self._send_goal_future = self.nav_to_pose_client.send_goal_async(goal_msg)
|
||||
self._send_goal_future.add_done_callback(self.goal_response_callback)
|
||||
|
||||
def goal_response_callback(self, future):
|
||||
handle = future.result()
|
||||
if handle.accepted:
|
||||
self.nav_goal_handle = handle
|
||||
self._get_result_future = handle.get_result_async()
|
||||
self._get_result_future.add_done_callback(self.get_result_callback)
|
||||
|
||||
def get_result_callback(self, future):
|
||||
result = future.result()
|
||||
if result.status == 4:
|
||||
self.send_udp_to_qt("STATUS:ARRIVED")
|
||||
self.nav_goal_handle = None
|
||||
|
||||
def cancel_navigation(self):
|
||||
if self.nav_goal_handle:
|
||||
self.nav_goal_handle.cancel_goal_async()
|
||||
self.nav_goal_handle = None
|
||||
|
||||
def stop_robot(self):
|
||||
msg = TwistStamped()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
msg.header.frame_id = 'base_link'
|
||||
self.cmd_vel_pub.publish(msg)
|
||||
|
||||
def main():
|
||||
rclpy.init()
|
||||
node = IntegratedRosController()
|
||||
try: rclpy.spin(node)
|
||||
except KeyboardInterrupt: pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,253 @@
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #f1f5f9;
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 헤더 스타일 */
|
||||
header {
|
||||
background-color: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding: 0 30px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo { font-weight: 800; font-size: 1.3rem; margin-right: 40px; color: #fff; }
|
||||
|
||||
nav { display: flex; gap: 10px; flex: 1; }
|
||||
|
||||
.nav-btn {
|
||||
text-decoration: none;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav-btn:hover { background-color: #334155; color: #fff; }
|
||||
.nav-btn.active { background-color: #3b82f6; color: #fff; box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
|
||||
|
||||
/* 상단 상태 배지 */
|
||||
.status-group { display: flex; gap: 10px; }
|
||||
.status-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 99px;
|
||||
background-color: #334155;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
display: flex; align-items: center; gap: 6px; border: 1px solid #475569;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background-color: #94a3b8; }
|
||||
.status-badge.on { background-color: #064e3b; color: #34d399; border-color: #059669; }
|
||||
.status-badge.on .dot { background-color: #34d399; box-shadow: 0 0 4px #34d399; }
|
||||
.status-badge.off { background-color: #450a0a; color: #f87171; border-color: #b91c1c; }
|
||||
.status-badge.off .dot { background-color: #f87171; box-shadow: 0 0 4px #f87171; }
|
||||
|
||||
/* --- 메인 레이아웃 --- */
|
||||
main {
|
||||
padding: 20px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 상단 카트 요약 그리드 */
|
||||
.cart-select-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-summary-card {
|
||||
background-color: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cart-summary-card:hover { transform: translateY(-2px); border-color: #64748b; }
|
||||
|
||||
.cart-summary-card.selected {
|
||||
border-color: #3b82f6;
|
||||
background-color: #1e293b;
|
||||
box-shadow: 0 0 15px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.summary-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.summary-name { font-weight: 700; color: #fff; font-size: 1rem; }
|
||||
.summary-status-dot { width: 8px; height: 8px; border-radius: 50%; background-color: #475569; }
|
||||
.summary-status-dot.online { background-color: #10b981; box-shadow: 0 0 5px #10b981; }
|
||||
.summary-status-dot.warning { background-color: #e45d0f; box-shadow: 0 0 5px #e45d0f; }
|
||||
.summary-status-dot.offline { background-color: #fa0303; box-shadow: 0 0 5px #e45d0f;}
|
||||
|
||||
.summary-row { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; }
|
||||
.summary-val { color: #f1f5f9; font-weight: 600; }
|
||||
|
||||
/* 하단 상세 패널 */
|
||||
.cart-panel {
|
||||
flex: 1;
|
||||
background-color: #1e293b;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
|
||||
border: 2px solid #334155;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background-color: #0f172a;
|
||||
padding: 15px 25px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #334155;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cart-info { display: flex; align-items: center; gap: 10px; }
|
||||
.cart-icon { font-size: 1.5rem; }
|
||||
.cart-name { font-size: 1.2rem; font-weight: 800; color: #fff; }
|
||||
.panel-status { font-weight: 700; font-size: 0.95rem; color: #64748b; }
|
||||
|
||||
/* --- 패널 내부 비율 (4:6) --- */
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* 왼쪽 영역 (정보) */
|
||||
.panel-left {
|
||||
flex: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #334155;
|
||||
min-width: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 오른쪽 영역 (지도) */
|
||||
.panel-right {
|
||||
flex: 6;
|
||||
background-color: #0f172a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 센서 그리드 */
|
||||
.sensor-grid {
|
||||
padding: 25px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 15px;
|
||||
border-bottom: 1px solid #334155;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.data-box {
|
||||
background-color: #28364d;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
.data-label { font-size: 0.9rem; color: #94a3b8; margin-bottom: 5px; font-weight: 600; }
|
||||
.data-value { font-size: 1.8rem; font-weight: 800; color: #fff; }
|
||||
.unit { font-size: 0.9rem; color: #64748b; font-weight: 500; margin-left: 3px; }
|
||||
|
||||
.val-normal { color: #38bdf8; }
|
||||
.val-warning { color: #f87171; }
|
||||
.val-success { color: #34d399; }
|
||||
.val-offline { color: #64748b; }
|
||||
|
||||
/* 아이템 리스트 */
|
||||
.items-section {
|
||||
padding: 25px;
|
||||
background-color: #1e293b;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.items-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||
.items-title { font-size: 1.1rem; font-weight: 700; color: #e2e8f0; }
|
||||
.items-summary { font-size: 0.95rem; color: #94a3b8; font-weight: 600; }
|
||||
.total-price { color: #34d399; font-weight: 800; font-size: 1.1rem; margin-left: 8px; }
|
||||
|
||||
.table-scroll-box {
|
||||
flex: 1; overflow-y: auto;
|
||||
border: 1px solid #334155; border-radius: 8px;
|
||||
background-color: #1e293b;
|
||||
min-height: 150px;
|
||||
}
|
||||
.table-scroll-box::-webkit-scrollbar { width: 8px; }
|
||||
.table-scroll-box::-webkit-scrollbar-track { background: #1e293b; }
|
||||
.table-scroll-box::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||
|
||||
.items-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
||||
.items-table th {
|
||||
text-align: left; color: #94a3b8; padding: 12px 20px; background-color: #0f172a;
|
||||
position: sticky; top: 0; z-index: 1; border-bottom: 1px solid #334155; font-size: 0.85rem; font-weight: 700;
|
||||
}
|
||||
.items-table td { padding: 12px 20px; border-bottom: 1px solid #334155; color: #f1f5f9; }
|
||||
.items-table tr:hover td { background-color: #334155; }
|
||||
|
||||
/* 지도 헤더 */
|
||||
.map-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-title { font-size: 1.1rem; font-weight: 700; color: #e2e8f0; }
|
||||
.map-coords { font-family: monospace; color: #34d399; font-size: 0.9rem; }
|
||||
|
||||
/* 캔버스 컨테이너 */
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
background-color: #020617;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
canvas {
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* --- 다크 모드 기본 스타일 --- */
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #f1f5f9;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- 헤더 --- */
|
||||
header {
|
||||
background-color: #1e293b;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding: 0 30px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo { font-weight: 800; font-size: 1.3rem; margin-right: 40px; }
|
||||
|
||||
nav { display: flex; gap: 10px; flex: 1; }
|
||||
.nav-btn { text-decoration: none; color: #94a3b8; font-weight: 600; padding: 8px 16px; border-radius: 8px; transition: all 0.2s; font-size: 0.9rem; }
|
||||
.nav-btn:hover { background-color: #334155; color: #fff; }
|
||||
.nav-btn.active { background-color: #3b82f6; color: #fff; box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
|
||||
|
||||
.container { display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px; gap: 20px; box-sizing: border-box; }
|
||||
|
||||
/* 다크 모드 섹션 스타일 */
|
||||
.inventory-section {
|
||||
flex: 1;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #334155;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #e2e8f0;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.table-wrapper { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* 다크 모드 테이블 */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
||||
th {
|
||||
position: sticky; top: 0;
|
||||
background-color: #0f172a;
|
||||
color: #94a3b8;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #334155;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
z-index: 10;
|
||||
}
|
||||
td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #334155;
|
||||
color: #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:hover td { background-color: #334155; }
|
||||
|
||||
.sortable { cursor: pointer; user-select: none; transition: color 0.2s; }
|
||||
.sortable:hover { color: #3b82f6; background-color: #1e293b; }
|
||||
|
||||
/* 다크 모드 배지 스타일 */
|
||||
.badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 700; display: inline-block; min-width: 80px; text-align: center; }
|
||||
|
||||
.expiry-expired { background-color: #450a0a; color: #f87171; border: 1px solid #b91c1c; } /* 만료 */
|
||||
.expiry-warning { background-color: #422006; color: #fbbf24; border: 1px solid #b45309; } /* 임박 */
|
||||
.expiry-good { background-color: #064e3b; color: #34d399; border: 1px solid #059669; } /* 안전 */
|
||||
|
||||
.stock-badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 700; display: inline-block; text-align: center; }
|
||||
.stock-low { background-color: #450a0a; color: #f87171; border: 1px solid #b91c1c; }
|
||||
.stock-normal { background-color: #334155; color: #94a3b8; border: 1px solid #475569; }
|
||||
|
||||
/* 매출 섹션 */
|
||||
.sales-section { flex: 1; display: flex; gap: 20px; min-height: 0; }
|
||||
.kpi-container { display: flex; flex-direction: column; gap: 15px; width: 250px; flex-shrink: 0; }
|
||||
|
||||
.kpi-card {
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #334155;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.kpi-title { font-size: 0.9rem; color: #94a3b8; margin-bottom: 5px; font-weight: 600; }
|
||||
.kpi-value { font-size: 1.8rem; font-weight: 800; color: #fff; }
|
||||
.kpi-icon { font-size: 2rem; margin-bottom: 10px; }
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #0f172a; }
|
||||
::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
@@ -0,0 +1,341 @@
|
||||
let currentCartId = 1;
|
||||
|
||||
const mapImg = new Image();
|
||||
mapImg.src = "/static/map601.png";
|
||||
|
||||
const MAP_BOUNDS = {
|
||||
LEFT_X: -0.67,
|
||||
RIGHT_X: 3.34,
|
||||
TOP_Y: 1.66,
|
||||
BOTTOM_Y: -7.4
|
||||
};
|
||||
|
||||
const allCartsData = {
|
||||
1: { id: 1, name: "QTCART 1호차", isOnline: false, battery: 0, speed: 0, weight: 0, expected: 0, items: [], status: "OFFLINE", x: 0.0, y: 0.0, human_x: 0.0, human_y: 0.0 },
|
||||
2: { id: 2, name: "QTCART 2호차", isOnline: true, battery: 92, speed: 50, weight: 710, expected: 700, items: generateMockItems(3), status: "ONLINE", x: 1.5, y: 1.0 },
|
||||
3: { id: 3, name: "QTCART 3호차", isOnline: true, battery: 34, speed: 0, weight: 0, expected: 0, items: [], status: "ONLINE", x: -0.67, y: 1.66 },
|
||||
4: { id: 4, name: "QTCART 4호차", isOnline: true, battery: 65, speed: 0, weight: 0, expected: 0, items: [], status: "ONLINE", x: 3.34, y: -7.4 }
|
||||
};
|
||||
|
||||
function generateMockItems(count) {
|
||||
const mockDb = [
|
||||
{name: "코카콜라 500ml", price: 2000, weight: 500},
|
||||
{name: "포카칩 오리지널", price: 1500, weight: 100},
|
||||
{name: "제주 삼다수 2L", price: 1000, weight: 2000},
|
||||
{name: "신라면 멀티팩", price: 4500, weight: 600}
|
||||
];
|
||||
let items = [];
|
||||
for(let i=0; i<count; i++) {
|
||||
const rand = mockDb[Math.floor(Math.random() * mockDb.length)];
|
||||
items.push({...rand, id: 100+i});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// 주기적으로 데이터 갱신
|
||||
async function updateDashboard() {
|
||||
const apiBadge = document.getElementById('apiBadge');
|
||||
|
||||
try {
|
||||
// 로봇 상태 조회
|
||||
const botRes = await fetch('/dashboard/bot/check');
|
||||
const botData = await botRes.json();
|
||||
|
||||
// 카트 상태 조회
|
||||
let cartData = null;
|
||||
try {
|
||||
const cRes = await fetch('/cart/status');
|
||||
if(cRes.ok) cartData = await cRes.json();
|
||||
} catch(e) {}
|
||||
|
||||
apiBadge.className = 'status-badge on';
|
||||
apiBadge.innerHTML = '<span class="dot"></span> 서버 연결됨';
|
||||
|
||||
const c1 = allCartsData[1];
|
||||
|
||||
if(botData.bot_status === 'ONLINE') {
|
||||
c1.isOnline = true;
|
||||
c1.status = "ONLINE";
|
||||
|
||||
const batVal = parseFloat(botData.battery);
|
||||
c1.battery = isNaN(batVal) ? 0 : Math.round(batVal);
|
||||
|
||||
c1.speed = Math.abs(Math.round(botData.speed * 100));
|
||||
|
||||
// 좌표 업데이트
|
||||
c1.x = botData.x !== undefined ? botData.x : 0.0;
|
||||
c1.y = botData.y !== undefined ? botData.y : 0.0;
|
||||
c1.human_x = botData.human_x !== undefined ? botData.human_x : 0.0;
|
||||
c1.human_y = botData.human_y !== undefined ? botData.human_y : 0.0;
|
||||
|
||||
// 카트 데이터 업데이트
|
||||
if(cartData) {
|
||||
c1.weight = cartData.real_weight;
|
||||
c1.expected = cartData.expected_weight;
|
||||
c1.items = cartData.cart_items || [];
|
||||
|
||||
if(cartData.system_status === "WARNING_WEIGHT_MISMATCH") {
|
||||
c1.status = "WARNING";
|
||||
} else {
|
||||
c1.status = "ONLINE";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c1.isOnline = false;
|
||||
c1.status = "OFFLINE";
|
||||
c1.speed = 0;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
apiBadge.className = 'status-badge off';
|
||||
apiBadge.innerHTML = '<span class="dot"></span> 서버 끊김';
|
||||
|
||||
allCartsData[1].isOnline = false;
|
||||
allCartsData[1].status = "OFFLINE";
|
||||
}
|
||||
|
||||
updateMockData(2);
|
||||
updateMockData(3);
|
||||
updateMockData(4);
|
||||
|
||||
renderGrid();
|
||||
renderDetailPanel();
|
||||
drawMap();
|
||||
}
|
||||
|
||||
function updateMockData(id) {
|
||||
const c = allCartsData[id];
|
||||
if(!c.isOnline) return;
|
||||
if(Math.random() > 0.98) c.battery = Math.max(0, c.battery - 1);
|
||||
if(Math.random() > 0.7) c.speed = Math.floor(Math.random() * 80);
|
||||
c.x += (Math.random() - 0.5) * 0.1;
|
||||
c.y += (Math.random() - 0.5) * 0.1;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('cartGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
for(let i=1; i<=4; i++) {
|
||||
const data = allCartsData[i];
|
||||
const isSelected = (i === currentCartId) ? 'selected' : '';
|
||||
let dotClass = 'offline';
|
||||
let statusColor = '#64748b';
|
||||
|
||||
if(data.isOnline) {
|
||||
if(data.status === 'WARNING') {
|
||||
dotClass = 'warning';
|
||||
statusColor = '#fbbf24';
|
||||
} else {
|
||||
dotClass = 'online';
|
||||
statusColor = '#34d399';
|
||||
}
|
||||
}
|
||||
|
||||
const speedDisplay = data.isOnline ? `${data.speed} cm/s` : '--';
|
||||
|
||||
const html = `
|
||||
<div class="cart-summary-card ${isSelected}" onclick="selectCart(${i})">
|
||||
<div class="summary-header">
|
||||
<div class="summary-name">${data.name}</div>
|
||||
<div class="summary-status-dot ${dotClass}" style="${dotClass === 'warning' ? 'background-color:#f97316; box-shadow:0 0 5px #f97316;' : ''}"></div>
|
||||
</div>
|
||||
<div class="summary-row" style="margin-top:5px;">
|
||||
<span>배터리</span>
|
||||
<span class="summary-val" style="color:${statusColor}">${data.isOnline ? data.battery+'%' : '-'}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>현재 속도</span> <span class="summary-val">${speedDisplay}</span> </div>
|
||||
</div>`;
|
||||
grid.innerHTML += html;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetailPanel() {
|
||||
const data = allCartsData[currentCartId];
|
||||
const statusText = document.getElementById('detailStatusText');
|
||||
const panel = document.getElementById('detailPanel');
|
||||
|
||||
document.getElementById('detailName').innerText = data.name;
|
||||
|
||||
if(data.isOnline) {
|
||||
if(data.status === 'WARNING') {
|
||||
statusText.innerText = '● 무게 불일치 경고';
|
||||
statusText.style.color = '#fbbf24';
|
||||
panel.style.borderColor = '#fbbf24';
|
||||
} else {
|
||||
statusText.innerText = '● 온라인';
|
||||
statusText.style.color = '#34d399';
|
||||
panel.style.borderColor = '#334155';
|
||||
}
|
||||
} else {
|
||||
statusText.innerText = '● 오프라인';
|
||||
statusText.style.color = '#f87171';
|
||||
panel.style.borderColor = '#ef4444';
|
||||
}
|
||||
|
||||
const elReal = document.getElementById('detailReal');
|
||||
const elExp = document.getElementById('detailExpected');
|
||||
const elBat = document.getElementById('detailBattery');
|
||||
const elSpd = document.getElementById('detailSpeed');
|
||||
|
||||
if(data.isOnline) {
|
||||
elReal.innerText = typeof data.weight === 'number' ? data.weight.toFixed(1) : data.weight;
|
||||
elExp.innerText = typeof data.expected === 'number' ? data.expected.toFixed(1) : data.expected;
|
||||
elBat.innerText = data.battery;
|
||||
elSpd.innerText = data.speed;
|
||||
|
||||
elBat.parentElement.className = data.battery < 20 ? "data-value val-warning" : "data-value val-success";
|
||||
if(data.status === 'WARNING') elReal.parentElement.className = "data-value val-warning";
|
||||
else elReal.parentElement.className = "data-value val-normal";
|
||||
} else {
|
||||
elReal.innerText = '--';
|
||||
elExp.innerText = '--';
|
||||
elBat.innerText = '--';
|
||||
elSpd.innerText = '--';
|
||||
elReal.parentElement.className = "data-value val-offline";
|
||||
elBat.parentElement.className = "data-value val-offline";
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('detailListBody');
|
||||
const countEl = document.getElementById('detailCount');
|
||||
const priceEl = document.getElementById('detailPrice');
|
||||
|
||||
if(!data.isOnline || data.items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #64748b; padding: 30px;">데이터 없음</td></tr>';
|
||||
countEl.innerText = '0';
|
||||
priceEl.innerText = '0원';
|
||||
} else {
|
||||
const agg = {};
|
||||
let totalP = 0;
|
||||
let totalC = 0;
|
||||
data.items.forEach(item => {
|
||||
if(agg[item.name]) agg[item.name].qty++;
|
||||
else agg[item.name] = {...item, qty: 1};
|
||||
totalP += item.price;
|
||||
totalC++;
|
||||
});
|
||||
let html = '';
|
||||
Object.values(agg).forEach(item => {
|
||||
html += `<tr>
|
||||
<td style="font-weight:600;">${item.name}</td>
|
||||
<td style="text-align: right;">${item.price.toLocaleString()}원</td>
|
||||
<td style="text-align: right;">${item.weight}g</td>
|
||||
<td style="text-align: right; font-weight: bold; color: #38bdf8;">${item.qty}</td>
|
||||
</tr>`;
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
countEl.innerText = totalC;
|
||||
priceEl.innerText = totalP.toLocaleString() + '원';
|
||||
}
|
||||
}
|
||||
|
||||
function drawMap() {
|
||||
const canvas = document.getElementById('robotMap');
|
||||
if(!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const data = allCartsData[currentCartId];
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if(canvas.width !== container.clientWidth || canvas.height !== container.clientHeight) {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
}
|
||||
const cvsW = canvas.width;
|
||||
const cvsH = canvas.height;
|
||||
ctx.clearRect(0, 0, cvsW, cvsH);
|
||||
|
||||
// 이미지 로딩 체크
|
||||
if (!mapImg.complete || mapImg.naturalWidth === 0) {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillText("Loading Map...", 20, 30);
|
||||
return;
|
||||
}
|
||||
|
||||
const imgW = mapImg.naturalWidth;
|
||||
const imgH = mapImg.naturalHeight;
|
||||
const imgRatio = imgW / imgH;
|
||||
const cvsRatio = cvsW / cvsH;
|
||||
|
||||
let drawW, drawH, drawX, drawY;
|
||||
|
||||
if (cvsRatio > imgRatio) {
|
||||
drawH = cvsH;
|
||||
drawW = cvsH * imgRatio;
|
||||
drawX = (cvsW - drawW) / 2;
|
||||
drawY = 0;
|
||||
} else {
|
||||
drawW = cvsW;
|
||||
drawH = cvsW / imgRatio;
|
||||
drawX = 0;
|
||||
drawY = (cvsH - drawH) / 2;
|
||||
}
|
||||
ctx.drawImage(mapImg, drawX, drawY, drawW, drawH);
|
||||
|
||||
const toPixel = (rosX, rosY) => {
|
||||
const xRange = MAP_BOUNDS.RIGHT_X - MAP_BOUNDS.LEFT_X;
|
||||
let ratioX = (rosX - MAP_BOUNDS.LEFT_X) / xRange;
|
||||
|
||||
const yRange = MAP_BOUNDS.BOTTOM_Y - MAP_BOUNDS.TOP_Y;
|
||||
let ratioY = (rosY - MAP_BOUNDS.TOP_Y) / yRange;
|
||||
|
||||
let finalX = drawX + (ratioX * drawW);
|
||||
let finalY = drawY + (ratioY * drawH);
|
||||
return { x: finalX, y: finalY };
|
||||
};
|
||||
|
||||
if (data.isOnline) {
|
||||
console.log(`Robot(${data.x}, ${data.y})`);
|
||||
|
||||
const botPos = toPixel(data.x, data.y);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(botPos.x, botPos.y, 6, 0, Math.PI * 2);
|
||||
|
||||
let color = (currentCartId === 1) ? '#ef4444' : (data.status === 'WARNING' ? '#fbbf24' : '#34d399');
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.fillText("BOT", botPos.x + 10, botPos.y + 4);
|
||||
|
||||
if (currentCartId === 1) {
|
||||
const humanPos = toPixel(data.human_x, data.human_y);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(humanPos.x, humanPos.y, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
ctx.fillText("ME", humanPos.x + 10, humanPos.y + 4);
|
||||
}
|
||||
|
||||
const coordEl = document.getElementById('mapCoords');
|
||||
if(coordEl) coordEl.innerText = `Robot: (${data.x.toFixed(2)}, ${data.y.toFixed(2)})`;
|
||||
} else {
|
||||
const coordEl = document.getElementById('mapCoords');
|
||||
if(coordEl) coordEl.innerText = "OFFLINE";
|
||||
}
|
||||
}
|
||||
|
||||
function selectCart(id) {
|
||||
currentCartId = id;
|
||||
renderGrid();
|
||||
renderDetailPanel();
|
||||
drawMap();
|
||||
}
|
||||
|
||||
// 1초마다 갱신
|
||||
setInterval(updateDashboard, 1000);
|
||||
updateDashboard();
|
||||
@@ -0,0 +1,151 @@
|
||||
let currentItems = [];
|
||||
|
||||
function refreshAll() {
|
||||
fetchInventory();
|
||||
fetchSalesStats();
|
||||
}
|
||||
|
||||
async function fetchInventory() {
|
||||
try {
|
||||
const res = await fetch('/items');
|
||||
if (res.ok) {
|
||||
currentItems = await res.json();
|
||||
renderTable('expiry');
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function renderTable(sortKey) {
|
||||
const tbody = document.getElementById('inventoryBody');
|
||||
|
||||
if (sortKey === 'expiry') {
|
||||
currentItems.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||||
} else if (sortKey === 'stock') {
|
||||
currentItems.sort((a, b) => a.stock - b.stock);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0,0,0,0);
|
||||
|
||||
let html = currentItems.map(item => {
|
||||
const expiryDate = new Date(item.expiry_date);
|
||||
const diffTime = expiryDate - today;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
let dateClass = 'expiry-good';
|
||||
let dateText = `D-${diffDays}`;
|
||||
|
||||
if (diffDays < 0) {
|
||||
dateClass = 'expiry-expired';
|
||||
dateText = '만료됨';
|
||||
} else if (diffDays === 0) {
|
||||
dateClass = 'expiry-warning';
|
||||
dateText = '오늘 만료';
|
||||
} else if (diffDays <= 3) {
|
||||
dateClass = 'expiry-warning';
|
||||
dateText = `${diffDays}일 남음`;
|
||||
}
|
||||
|
||||
let stockHtml = '';
|
||||
if (item.stock === 0) {
|
||||
stockHtml = `<span class="stock-badge stock-low">부족 (${item.stock})</span>`;
|
||||
} else {
|
||||
stockHtml = `<span class="stock-badge stock-normal">${item.stock}개</span>`;
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td style="color:#94a3b8;">#${item.id}</td>
|
||||
<td style="font-weight: bold;">${item.name}</td>
|
||||
<td>${item.price.toLocaleString()}원</td>
|
||||
<td>
|
||||
<span class="badge ${dateClass}">${item.expiry_date} (${dateText})</span>
|
||||
</td>
|
||||
<td>${stockHtml}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.innerHTML = html || '<tr><td colspan="5" style="text-align:center; padding:30px; color:#64748b;">등록된 상품이 없습니다.</td></tr>';
|
||||
}
|
||||
|
||||
let myChart = null;
|
||||
|
||||
// ----- 결제 정보 -----
|
||||
async function fetchSalesStats() {
|
||||
try {
|
||||
const res = await fetch('/payments');
|
||||
if (!res.ok) return;
|
||||
const payments = await res.json();
|
||||
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
let monthTotal = 0;
|
||||
let monthCount = 0;
|
||||
const dailySales = {};
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date();
|
||||
d.setDate(today.getDate() - i);
|
||||
dailySales[d.toISOString().split('T')[0]] = 0;
|
||||
}
|
||||
|
||||
payments.forEach(pay => {
|
||||
const payDate = new Date(pay.created_at);
|
||||
if (payDate >= thirtyDaysAgo) {
|
||||
monthTotal += pay.total_price;
|
||||
monthCount += 1;
|
||||
const dateStr = pay.created_at.split('T')[0];
|
||||
if (dailySales[dateStr] !== undefined) dailySales[dateStr] += pay.total_price;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('monthTotal').innerText = monthTotal.toLocaleString() + '원';
|
||||
document.getElementById('monthCount').innerText = monthCount + '건';
|
||||
updateChart(dailySales);
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function updateChart(dailyData) {
|
||||
const sortedDates = Object.keys(dailyData).sort();
|
||||
const dataValues = sortedDates.map(date => dailyData[date]);
|
||||
const labels = sortedDates.map(date => date.slice(5));
|
||||
|
||||
const ctx = document.getElementById('salesChart').getContext('2d');
|
||||
if (myChart) myChart.destroy();
|
||||
|
||||
myChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '매출 (원)',
|
||||
data: dataValues,
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { borderDash: [5, 5], color: '#334155' },
|
||||
ticks: { color: '#94a3b8' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#94a3b8' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QTCART 관제 시스템</title>
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">🛒 QT-CART</div>
|
||||
<nav>
|
||||
<a href="/dashboard" class="nav-btn active">관제 모니터링</a>
|
||||
<a href="/inventory" class="nav-btn">통합 관리</a>
|
||||
</nav>
|
||||
<div class="status-group">
|
||||
<div id="apiBadge" class="status-badge off"><span class="dot"></span> 서버 연결</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="cart-select-grid" id="cartGrid">
|
||||
</div>
|
||||
|
||||
<div id="detailPanel" class="cart-panel">
|
||||
<div class="panel-header">
|
||||
<div class="cart-info">
|
||||
<div class="cart-icon">🛒</div>
|
||||
<div class="cart-name" id="detailName">QTCART 1호차</div>
|
||||
</div>
|
||||
<div id="detailStatusText" class="panel-status">● 데이터 로딩중</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="panel-left">
|
||||
<div class="sensor-grid">
|
||||
<div class="data-box">
|
||||
<div class="data-label">배터리 잔량</div>
|
||||
<div class="data-value"><span id="detailBattery">--</span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="data-box">
|
||||
<div class="data-label">현재 속도</div>
|
||||
<div class="data-value"><span id="detailSpeed">--</span><span class="unit">cm/s</span></div>
|
||||
</div>
|
||||
<div class="data-box">
|
||||
<div class="data-label">기대 무게</div>
|
||||
<div class="data-value" style="color:#fbbf24;"><span id="detailExpected">0</span><span class="unit">g</span></div>
|
||||
</div>
|
||||
<div class="data-box">
|
||||
<div class="data-label">실제 무게</div>
|
||||
<div class="data-value"><span id="detailReal">--</span><span class="unit">g</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<div class="items-header">
|
||||
<div class="items-title">📦 담긴 상품 목록</div>
|
||||
<div class="items-summary">
|
||||
총 <span id="detailCount">0</span>개
|
||||
<span class="total-price" id="detailPrice">0원</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll-box">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>상품명</th>
|
||||
<th style="text-align: right;">단가</th>
|
||||
<th style="text-align: right;">무게</th>
|
||||
<th style="text-align: right;">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailListBody">
|
||||
<tr><td colspan="4" style="text-align: center; color: #64748b; padding: 30px;">데이터 없음</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-right">
|
||||
<div class="map-header">
|
||||
<div class="map-title">🗺️ 실시간 위치</div>
|
||||
<div class="map-coords" id="mapCoords">X: 0.0, Y: 0.0</div>
|
||||
</div>
|
||||
<div class="canvas-container">
|
||||
<canvas id="robotMap" width="500" height="400"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QTCART 통합 관리</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/inventory.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">🛒 QT-CART</div>
|
||||
<nav>
|
||||
<a href="/dashboard" class="nav-btn">관제 모니터링</a>
|
||||
<a href="/inventory" class="nav-btn active">통합 관리</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="inventory-section">
|
||||
<div class="section-header">
|
||||
📦 재고 및 유통기한 관리
|
||||
<button onclick="refreshAll()" style="background:none; border:none; color:#3b82f6; cursor:pointer; font-weight:600;">🔄 새로고침</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="10%">ID</th>
|
||||
<th width="30%">상품명</th>
|
||||
<th width="20%">가격</th>
|
||||
<th width="25%" class="sortable" onclick="renderTable('expiry')">유통기한 ▼</th>
|
||||
<th width="15%" class="sortable" onclick="renderTable('stock')">재고량 ▼</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inventoryBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sales-section">
|
||||
<div class="kpi-container">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon">💰</div>
|
||||
<div class="kpi-title">이번 달 총 매출</div>
|
||||
<div class="kpi-value" id="monthTotal">0원</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon">🧾</div>
|
||||
<div class="kpi-title">이번 달 거래 수</div>
|
||||
<div class="kpi-value" id="monthCount">0건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<div style="font-weight:700; color:#e2e8f0; margin-bottom:10px;">📉 최근 30일 매출 추이</div>
|
||||
<div style="flex:1; position: relative;">
|
||||
<canvas id="salesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/inventory.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user