Сделал первые функции под MRP

This commit is contained in:
2026-05-06 23:15:50 +03:00
parent 5417092796
commit 3b7a740081
27 changed files with 2996 additions and 767 deletions
+4 -3
View File
@@ -3,9 +3,11 @@ package database
import ( import (
"log" "log"
"os" "os"
"viplight-mrp/models" // Замени viplight-mrp на имя своего модуля из go.mod
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"viplight-mrp/models"
) )
var DB *gorm.DB var DB *gorm.DB
@@ -18,6 +20,5 @@ func InitDB() {
log.Fatal("Failed to connect to database:", err) log.Fatal("Failed to connect to database:", err)
} }
DB.AutoMigrate(&models.Part{}) DB.AutoMigrate(&models.Order{}, &models.Part{})
} }
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"viplight-mrp/database"
"viplight-mrp/models"
)
func GetsOrders(c *gin.Context) {
var orders []models.Order
if err := database.DB.Preload("Parts").Find(&orders).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при получении заказов"})
return
}
c.JSON(http.StatusOK, orders)
}
+74 -42
View File
@@ -2,9 +2,11 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin"
"viplight-mrp/database" "viplight-mrp/database"
"viplight-mrp/models" "viplight-mrp/models"
"github.com/gin-gonic/gin"
) )
func GetPart(c *gin.Context) { func GetPart(c *gin.Context) {
@@ -29,64 +31,94 @@ func CreatePart(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
database.DB.Create(&newPart) if newPart.OrderNo != "" {
var order models.Order
database.DB.Where(models.Order{OrderNo: newPart.OrderNo}).FirstOrCreate(&order)
}
if err := database.DB.Create(&newPart).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, newPart) c.JSON(http.StatusCreated, newPart)
} }
func UpdateStatus(c *gin.Context) { func UpdateStatus(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var input struct { Status string `json:"status" binding:"required"` } var input struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Status is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Неверные данные"})
} }
result := database.DB.Model(&models.Part{}).Where("id = ?", id).Update("status", input.Status) if err := database.DB.Model(&models.Part{}).Where("id = ?", id).Update("status", input.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось обновить статус"})
if result.RowsAffected == 0{ return
c.JSON(http.StatusNotFound, gin.H{"error" : "Деталь не найдена"}) }
}
c.JSON(http.StatusOK, gin.H{"status": "updated", "new_status": input.Status}) c.JSON(http.StatusOK, gin.H{"status": "updated", "new_status": input.Status})
} }
func DeletePart(c *gin.Context){ func DeletePart(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := database.DB.Delete(&models.Part{}, "id = ?", id).Error; err != nil { if err := database.DB.Delete(&models.Part{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось удалить элемент"}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "Удалено"}) c.JSON(http.StatusOK, gin.H{"message": "Удалено"})
} }
func CreatePartsBulk(c *gin.Context){ func CreatePartsBulk(c *gin.Context) {
var parts []models.Part var parts []models.Part
if err := c.ShouldBindJSON(&parts); err != nil { if err := c.ShouldBindJSON(&parts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if err := database.DB.Create(&parts).Error; err != nil { ordersMap := make(map[string]bool)
c.JSON(http.StatusInternalServerError, gin.H{"error" : "Не могу создать деталь"}) for _, p := range parts {
return if p.OrderNo != "" {
} ordersMap[p.OrderNo] = true
}
}
c.JSON(http.StatusCreated, parts) for orderNo := range ordersMap {
var order models.Order
database.DB.Where(models.Order{OrderNo: orderNo}).FirstOrCreate(&order)
}
if err := database.DB.Create(&parts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, parts)
} }
func ImportParts(c *gin.Context) { func ImportParts(c *gin.Context) {
var parts []models.Part var parts []models.Part
if err := c.ShouldBindJSON(&parts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// GORM сделает один быстрый INSERT для всех деталей if err := c.ShouldBindJSON(&parts); err != nil {
if err := database.DB.Create(&parts).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка импорта"}) return
return }
}
c.JSON(http.StatusOK, gin.H{"message": "Импортировано", "count": len(parts)}) if len(parts) > 0 {
orderNo := parts[0].OrderNo
var order models.Order
if err := database.DB.Where(models.Order{OrderNo: orderNo}).FirstOrCreate(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка создания заголовка заказа"})
return
}
}
if err := database.DB.Create(&parts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка при сохранении заказа"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Данные успешно импортированы", "count": len(parts), "order_no": parts[0].OrderNo})
} }
+3 -3
View File
@@ -1,9 +1,10 @@
package main package main
import ( import (
"github.com/gin-gonic/gin"
"viplight-mrp/database" "viplight-mrp/database"
"viplight-mrp/handlers" "viplight-mrp/handlers"
"github.com/gin-gonic/gin"
) )
func main() { func main() {
@@ -26,12 +27,11 @@ func main() {
// Роуты теперь вызывают функции из handlers // Роуты теперь вызывают функции из handlers
r.GET("/api/parts/:id", handlers.GetPart) r.GET("/api/parts/:id", handlers.GetPart)
r.GET("/api/parts", handlers.GetAllParts) r.GET("/api/parts", handlers.GetAllParts)
r.GET("/api/orders", handlers.GetsOrders)
r.POST("/api/parts", handlers.CreatePart) r.POST("/api/parts", handlers.CreatePart)
r.POST("/api/parts/bulk", handlers.ImportParts) r.POST("/api/parts/bulk", handlers.ImportParts)
r.PATCH("/api/parts/:id/status", handlers.UpdateStatus) r.PATCH("/api/parts/:id/status", handlers.UpdateStatus)
r.DELETE("/api/parts/:id", handlers.DeletePart) r.DELETE("/api/parts/:id", handlers.DeletePart)
r.Run(":8090") r.Run(":8090")
} }
+13
View File
@@ -0,0 +1,13 @@
package models
import "gorm.io/gorm"
type Order struct {
gorm.Model
OrderNo string `gorm:"uniqueIndex;not null" json:"order_no"`
ClientName string `json:"client_name"`
Deadline string `json:"deadline"`
Status string `gorm:"default:'Новый'" json:"status"`
Parts []Part `gorm:"foreignKey:OrderNo;references:OrderNo" json:"parts"`
}
+56 -58
View File
@@ -1,68 +1,66 @@
package models package models
const ( const (
StatusImport = "Запланировано" StatusImport = "Запланировано"
StatusWarehouse = "Склад" StatusWarehouse = "Склад"
StatusSawing = "Пила" StatusSawing = "Пила"
StatusEdging = "Кромка" StatusEdging = "Кромка"
StatusEdgingHand = "Кромка ручная" StatusEdgingHand = "Кромка ручная"
StatusCNCDelta = "ЧПУ Дельта" StatusCNCDelta = "ЧПУ Дельта"
StatusCNCTrepan = "ЧПУ Трепан" StatusCNCTrepan = "ЧПУ Трепан"
StatusHandDrilling = "Присадка на ручном станке" StatusHandDrilling = "Присадка на ручном станке"
StatusSawHand = "Циркулярная пила" StatusSawHand = "Циркулярная пила"
StatusAssembly = "Сборка" StatusAssembly = "Сборка"
StatusAbrasive = "Шлифовка" StatusAbrasive = "Шлифовка"
StatusDrying = "Сушка после малярки" StatusDrying = "Сушка после малярки"
StatusPainting = "Малярная камера" StatusPainting = "Малярная камера"
StatusGlass = "Стекольный цех" StatusGlass = "Стекольный цех"
StatusMetalSelf = "Сварочный цех" StatusMetalSelf = "Сварочный цех"
StatusPaintingOutsource = "Порошковая покраска" StatusPaintingOutsource = "Порошковая покраска"
StatusAdv = "Рекламный участок" StatusAdv = "Рекламный участок"
StatusLight = "Светодиодный участок" StatusLight = "Светодиодный участок"
StatusSemiFinished = "Упаковка полуфабриката" StatusSemiFinished = "Упаковка полуфабриката"
StatusFinished = "Упаковка изделия" StatusFinished = "Упаковка изделия"
StatusShipment = "Отгружено на монтаж" StatusShipment = "Отгружено на монтаж"
StatusInstallation = "Монтаж оборудования" StatusInstallation = "Монтаж оборудования"
StatusRejected = "Отбраковано/сломано" StatusRejected = "Отбраковано/сломано"
) )
func IsValidStatus(s string) bool { func IsValidStatus(s string) bool {
switch s { switch s {
case StatusWarehouse, StatusSawing, StatusEdging, StatusEdgingHand, case StatusWarehouse, StatusSawing, StatusEdging, StatusEdgingHand,
StatusCNCDelta, StatusCNCTrepan, StatusHandDrilling, StatusSawHand, StatusCNCDelta, StatusCNCTrepan, StatusHandDrilling, StatusSawHand,
StatusAssembly, StatusAbrasive, StatusDrying, StatusPainting, StatusAssembly, StatusAbrasive, StatusDrying, StatusPainting,
StatusGlass, StatusMetalSelf, StatusPaintingOutsource, StatusAdv, StatusGlass, StatusMetalSelf, StatusPaintingOutsource, StatusAdv,
StatusLight, StatusSemiFinished, StatusFinished, StatusShipment, StatusLight, StatusSemiFinished, StatusFinished, StatusShipment,
StatusInstallation, StatusRejected: StatusInstallation, StatusRejected:
return true return true
} }
return false return false
} }
type Part struct { type Part struct {
ID string `gorm:"primaryKey" json:"id" binding:"required"` // № или Обозначение ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Destignation string `json:"destignation" binding:"required"` // № или Обозначение Designation string `json:"designation" binding:"required"`
OrderNo string `json:"order_no" binding:"required"` // Заказ изделия OrderNo string `json:"order_no" binding:"required"`
Name string `json:"name" binding:"required"` // Наименование детали Name string `json:"name" binding:"required"`
Material string `json:"material" binding:"required"` // Наименование материала Material string `json:"material" binding:"required"`
Thickness float64 `json:"thickness" binding:"required"` // Толщина Thickness float64 `json:"thickness" binding:"required"`
Quantity int `json:"quantity" binding:"required"` // Количество Quantity int `json:"quantity" binding:"required"`
// Размеры (используем Готовую деталь как основной стандарт)
Length float64 `json:"length" binding:"required"` // Готовая деталь [L]
Width float64 `json:"width" binding:"required"` // Готовая деталь [W]
// Кромка
EdgeL1 string `json:"edge_l1"` // Обозначение кромки [L1]
EdgeL2 string `json:"edge_l2"` // Обозначение кромки [L2]
EdgeW1 string `json:"edge_w1"` // Обозначение кромки [W1]
EdgeW2 string `json:"edge_w2"` // Обозначение кромки [W2]
// Доп. инфо
Groove string `json:"groove"` // Паз
Note string `json:"note"` // Примечание
ProductName string `json:"product_name" binding:"required"` // Наимен. изделия
Status string `json:"status" gorm:"default:Создан"`
}
Length float64 `json:"length" binding:"required"`
Width float64 `json:"width" binding:"required"`
LengthFirst float64 `json:"length_first" binding:"required"`
WidthFirst float64 `json:"width_first" binding:"required"`
EdgeL1 string `json:"edge_l1"`
EdgeL2 string `json:"edge_l2"`
EdgeW1 string `json:"edge_w1"`
EdgeW2 string `json:"edge_w2"`
Groove string `json:"groove"`
Note string `json:"note"`
ProductName string `json:"product_name" binding:"required"`
Status string `json:"status" gorm:"default:Создан"`
}
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install --legacy-peer-deps
COPY . . COPY . .
RUN npm run build RUN npm run build
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+25 -16
View File
@@ -1,21 +1,30 @@
import js from '@eslint/js' import js from "@eslint/js";
import globals from 'globals' import pluginReact from "eslint-plugin-react";
import reactHooks from 'eslint-plugin-react-hooks' import globals from "globals";
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default [
globalIgnores(['dist']), js.configs.recommended,
{ {
files: ['**/*.{js,jsx}'], files: ["**/*.jsx", "**/*.tsx"],
extends: [ plugins: {
js.configs.recommended, react: pluginReact,
reactHooks.configs.flat.recommended, },
reactRefresh.configs.vite,
],
languageOptions: { languageOptions: {
globals: globals.browser, globals: {
parserOptions: { ecmaFeatures: { jsx: true } }, ...globals.browser,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
// Подключаем рекомендуемые правила вручную, если импорт конфига не работает
...pluginReact.configs.recommended.rules,
// ОТКЛЮЧАЕМ те самые правила для React 17+
"react/react-in-jsx-scope": "off",
"react/jsx-uses-react": "off",
}, },
}, },
]) ];
+2 -3
View File
@@ -1,10 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Viplight MRP</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2066 -89
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -24,10 +24,11 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"eslint": "^10.2.1", "eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.6.0",
"postcss": "^8.5.12", "postcss": "^8.5.12",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"vite": "^8.0.10" "vite": "^8.0.10"
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+12 -83
View File
@@ -1,87 +1,16 @@
import { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route } from "react-router-dom";
import * as api from './api'; import Dashboard from "./pages/Dashboard";
import PartsTable from './components/PartsTable'; import PartDetail from "./pages/PartDetail";
import Scanner from './components/Scanner'; import AddPartPage from "./pages/AddPartPage";
import CsvImporter from './components/CsvImporter';
function App() {
const [isScannerOpen, setIsScannerOpen] = useState(false);
const [selectedPart, setSelectedPart] = useState(null); // Добавили стейт
const [parts, setParts] = useState([]);
const loadData = async () => {
try{
const res = await api.getAllParts();
setParts(res.data || []);
}catch(e){
console.error(e);
}
};
useEffect(()=>{
loadData();
}, []);
const handleScan = async (id) => {
try {
const res = await api.getPart(id);
if (res.data) {
setIsScannerOpen(false);
setSelectedPart(res.data); // Теперь стейт существует
alert(`Деталь ${res.data.name || id} найдена!`); // res.data обычно объект, выводим поле name
}
} catch (err) {
alert(`Деталь ${id} не найдена`);
setIsScannerOpen(false); // Закрываем, чтобы не зациклиться на ошибке
}
};
const handleDelete = async (id) => {
if(window.confirm("Удалить эту панель?")){
await api.deletePart(id);
loadData();
}
};
export default function App() {
return ( return (
<div className="min-h-screen bg-[#0f172a] text-slate-200 p-6 flex flex-col items-center"> <BrowserRouter>
<header className="w-full max-w-4xl mb-12 flex justify-between items-center"> <Routes>
<h1 className="text-xl font-bold tracking-tight">VIPLIGHT <span className="text-blue-500">MRP</span></h1> <Route path="/" element={<Dashboard />} />
<button <Route path="/part/:id" element={<PartDetail />} />
onClick={() => setIsScannerOpen(true)} <Route path="/add" element={<AddPartPage />} />
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2" </Routes>
> </BrowserRouter>
<span>📷</span> Сканировать QR
</button>
<CsvImporter onImported={loadData} />
</header>
<main className="w-full max-w-4xl space-y-8">
{isScannerOpen && (
<Scanner
onScan={handleScan}
onClose={() => setIsScannerOpen(false)}
/>
)}
{selectedPart && (
<div className="bg-blue-900/30 border border-blue-500 p-4 rounded-lg flex justify-between items-center">
<div>
<p className="text-sm text-blue-400">Текущий выбор:</p>
<p className="font-bold">{selectedPart.name} ({selectedPart.order_id})</p>
</div>
<button onClick={() => setSelectedPart(null)} className="text-slate-400 hover:text-white"></button>
</div>
)}
<PartForm onCreated={loadData} />
<PartsTable parts={parts} onRefresh={loadData} onDelete={handleDelete} />
</main>
</div>
); );
} }
export default App;
+48 -31
View File
@@ -1,51 +1,68 @@
import Papa from 'papaparse'; import axios from "axios";
import axios from 'axios'; import Papa from "papaparse";
// Используем относительный путь, так как работаем через Traefik const API_URL = "/api";
const API_URL = '/api';
export const getPart = (id) => axios.get(`${API_URL}/parts/${id}`); // Базовые CRUD операции
export const getAllParts = () => axios.get(`${API_URL}/parts`); export const getAllParts = () => axios.get(`${API_URL}/parts`);
export const getAllOrders = () => axios.get(`${API_URL}/orders`);
export const getPart = (id) => axios.get(`${API_URL}/parts/${id}`);
export const createPart = (data) => axios.post(`${API_URL}/parts`, data); export const createPart = (data) => axios.post(`${API_URL}/parts`, data);
export const updatePartStatus = (id, status) => axios.patch(`${API_URL}/parts/${id}/status`, { status }); export const updatePartStatus = (id, status) =>
axios.patch(`${API_URL}/parts/${id}/status`, { status });
export const deletePart = (id) => axios.delete(`${API_URL}/parts/${id}`); export const deletePart = (id) => axios.delete(`${API_URL}/parts/${id}`);
export const createPartBulk = (data) => axios.post(`${API_URL}/parts/bulk`, data); export const createPartsBulk = (data) =>
axios.post(`${API_URL}/parts/bulk`, data);
export const parseAndImportCsv = (file, onProgress) => { // Функция импорта CSV
export const parseAndImportCsv = (file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Papa.parse(file, { Papa.parse(file, {
header: true, header: true,
skipEmptyLines: true, skipEmptyLines: true,
encoding: "CP1251", // Стандарт для РФ выгрузок encoding: "CP1251",
complete: async (results) => { complete: async (results) => {
const mappedParts = results.data.map(row => ({ const mappedParts = results.data
id: row['№'] , .filter((row) => row["Наименование детали"]) // Очистка от пустых строк
destignation: row['Обозначение детали'], .map((row) => ({
order_no: row['Заказ изделия'], designation: row["Обозначение детали"] || "",
name: row['Наименование детали'], order_no: row["Заказ изделия"] || "",
material: row['Наименование материала'], name: row["Наименование детали"] || "",
thickness: parseFloat(row['Толщина с учетом облицовки пласти']?.replace(',', '.') || 0), material: row["Наименование материала"] || "",
quantity: parseInt(row['Количество'] || 1), thickness: parseFloat(
length: parseFloat(row['Готовая деталь [L]']?.replace(',', '.') || 0), row["Толщина с учетом облицовки пласти"]?.replace(",", ".") || 0,
width: parseFloat(row['Готовая деталь [W]']?.replace(',', '.') || 0), ),
edge_l1: row['Обозначение облицовки кромки [L1]'], quantity: parseInt(row["Количество"]) || 1,
edge_l2: row['Обозначение облицовки кромки [L2]'], length: parseFloat(
edge_w1: row['Обозначение облицовки кромки [W1]'], row["Готовая деталь [L]"]?.replace(",", ".") || 0,
edge_w2: row['Обозначение облицовки кромки [W2]'], ),
groove: row['Паз'], width: parseFloat(
note: row['Примечание'], row["Готовая деталь [W]"]?.replace(",", ".") || 0,
product_name: row['Наимен. изделия'], ),
status: "Пила" // Стартовый статус по твоим константам length_first: parseFloat(
})); row["Заготовка [L]"]?.replace(",", ".") || 0,
),
width_first: parseFloat(
row["Заготовка [W]"]?.replace(",", ".") || 0,
),
edge_l1: row["Обозначение облицовки кромки [L1]"] || "",
edge_l2: row["Обозначение облицовки кромки [L2]"] || "",
edge_w1: row["Обозначение облицовки кромки [W1]"] || "",
edge_w2: row["Обозначение облицовки кромки [W2]"] || "",
product_name: row["Наимен. изделия"] || "Без названия",
status: "Запланировано",
}));
try { try {
const response = await createPartBulk(mappedParts); const response = await createPartsBulk(mappedParts);
resolve(response.data); resolve(response.data);
} catch (err) { } catch (err) {
reject(err.response?.data?.error || "Ошибка сервера"); reject(
err.response?.data?.error || "Ошибка сервера при bulk-запросе",
);
} }
}, },
error: (err) => reject(err.message) error: (err) => reject(err.message),
}); });
}); });
}; };
+15 -19
View File
@@ -1,5 +1,5 @@
import React, { useRef } from 'react'; import React, { useRef } from "react";
import { parseAndImportCsv } from '../api'; import { parseAndImportCsv } from "../api";
export default function CsvImporter({ onImported }) { export default function CsvImporter({ onImported }) {
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
@@ -13,34 +13,30 @@ export default function CsvImporter({ onImported }) {
alert(`Успешно! Загружено деталей: ${result.count}`); alert(`Успешно! Загружено деталей: ${result.count}`);
if (onImported) onImported(); // Обновляем список деталей на странице if (onImported) onImported(); // Обновляем список деталей на странице
} catch (err) { } catch (err) {
alert("Ошибка импорта: " + err); alert(`Ошибка импорта: ${err}`);
} finally { } finally {
// Очищаем инпут, чтобы можно было загрузить тот же файл повторно // Очищаем инпут, чтобы можно было загрузить тот же файл повторно
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = "";
} }
}; };
return ( return (
<div className="bg-slate-800/50 border-2 border-dashed border-slate-700 rounded-3xl p-6 mb-8 flex flex-col items-center justify-center transition-all hover:border-blue-500/50"> <div className="flex items-center">
<h3 className="text-white font-bold mb-2">Импорт заказа из Базиса</h3> <input
<p className="text-slate-400 text-sm mb-4">Выберите .csv файл для массовой загрузки</p> type="file"
accept=".csv"
<input onChange={handleUpload}
type="file" className="hidden"
ref={fileInputRef}
onChange={handleUpload}
accept=".csv"
className="hidden"
id="csv-upload" id="csv-upload"
/> />
<label
<label
htmlFor="csv-upload" htmlFor="csv-upload"
className="bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 px-8 rounded-xl cursor-pointer transition-all shadow-lg active:scale-95" className="h-9 md:h-10 px-4 md:px-6 bg-state-800 hover:bg-state-700 text white rounded-lg md:rounded-xl cursor-pointer transition-all border boder-slate-700 flex items-center justify-center gap-2 text-[10px] md:text-xs font-bold shadow-sm"
> >
ВЫБРАТЬ ФАЙЛ <span className="text-blue-400 text-sm">📁</span>
<span className="hidden lg:inline">ИМПОРТ БАЗИС</span>
<span className="lg:hidden">ИМПОРТ</span>
</label> </label>
</div> </div>
); );
} }
+36
View File
@@ -0,0 +1,36 @@
import CsvImporter from "./CsvImporter";
import { useNavigate } from "react-router-dom";
export default function Header({ onRefresh, onOpenScanner }) {
const navigate = useNavigate();
return (
<header className="w-full border-b border-slate-800 bg-slate-950/60 backdrop-blur-xl sticky top-0 z-50">
<div className="max-w-[1440px] mx-auto px-4 md:px-8 h-16 md:h-20 flex justify-between items-center">
<div className="flex flex-col">
<h1 className="text-lg md:text-xl font-black text-white tracking-tighter">
VIPLIGHT <span className="text-blue-500">MRP</span>
</h1>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:block">
<CsvImporter onImported={onRefresh} />
</div>
<button
onClick={() => navigate("/add")}
className="hidden md:flex h-10 px-6 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-bold text-xs items-center gap-2"
>
<span>+</span> НОВАЯ ПАНЕЛЬ
</button>
<button
onClick={onOpenScanner}
className="h-9 px-4 md:h-10 md:px-6 bg-blue-600 hover:bg-blue-500 text-white rounded-lg md:rounded-xl font-bold text-xs flex items-center gap-2"
>
<span>📷</span> СКАНЕР
</button>
</div>
</div>
</header>
);
}
+18
View File
@@ -0,0 +1,18 @@
export default function OrderCard({ order, isSelected, onClick, partsCount }) {
return (
<div
onClick={onClick}
className={`p-4 rounded-2xl border transition-all cursor-pointer ${isSelected ? "border-blue-500 bg-blue-500/10 shadow-lg shadow-blue-500/20" : "border-slate-800 bg-slate-800 bg-slate-900/50 hover:border-slate-600"}`}
>
<div className="text-[10px] text-slate-500 font-bold uppercase mb-1">
Заказ
</div>
<div className="text-lg font-black text-white truncate">
#{order.order_no}
</div>
<div className="text-[10px] text-blue-400 mt-4 font-mono">
{partsCount} панелей
</div>
</div>
);
}
+131 -30
View File
@@ -1,11 +1,20 @@
import { useState } from 'react'; import { useState } from "react";
import { createPart } from '../api'; import { createPart } from "../api";
export default function PartForm({ onCreated }) { export default function PartForm({ onCreated }) {
const initialState = { const initialState = {
id: '', destignation: '', order_no: '', material: '', designation: "",
length: 0, width: 0, thickness: 0, quantity: 1, order_no: "",
product_name: '', status: 'Создан', name: '' material: "",
length: 0,
width: 0,
length_first: 0,
width_first: 0,
thickness: 0,
quantity: 1,
product_name: "",
status: "Создан",
name: "",
}; };
const [formData, setFormData] = useState(initialState); const [formData, setFormData] = useState(initialState);
@@ -18,41 +27,133 @@ export default function PartForm({ onCreated }) {
...formData, ...formData,
length: parseFloat(formData.length), length: parseFloat(formData.length),
width: parseFloat(formData.width), width: parseFloat(formData.width),
length_first: parseFloat(formData.length_first),
width_first: parseFloat(formData.width_first),
thickness: parseFloat(formData.thickness), thickness: parseFloat(formData.thickness),
quantity: parseInt(formData.quantity) quantity: parseInt(formData.quantity),
}; };
await createPart(dataToSend); await createPart(dataToSend);
setFormData(initialState); setFormData(initialState);
onCreated(); onCreated();
} catch (err) { alert("Ошибка: " + err.response?.data?.error); } } catch (err) {
alert("Ошибка: " + err.response?.data?.error);
}
}; };
return ( return (
<section className="bg-slate-800/80 rounded-3xl border border-slate-700 p-8 shadow-xl mb-8"> <section className="bg-slate-800/80 rounded-3xl border border-slate-700 p-8 shadow-xl mb-8">
<h3 className="text-lg font-bold text-white mb-6">Регистрация новой панели</h3> <h3 className="text-lg font-bold text-white mb-6">
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4"> Регистрация новой панели
<input placeholder="ID" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" </h3>
value={formData.id} onChange={e => setFormData({...formData, id: e.target.value})} required /> <form
<input placeholder="Заказ №" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" onSubmit={handleSubmit}
value={formData.order_no} onChange={e => setFormData({...formData, order_no: e.target.value})} required /> className="grid grid-cols-1 md:grid-cols-4 gap-4"
<input placeholder="Обозначение" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" >
value={formData.destignation} onChange={e => setFormData({...formData, destignation: e.target.value})} required /> <input
<input placeholder="Наименование" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" placeholder="Заказ №"
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} required /> className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
<input placeholder="Изделие" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" value={formData.order_no}
value={formData.product_name} onChange={e => setFormData({...formData, product_name: e.target.value})} required /> onChange={(e) =>
setFormData({ ...formData, order_no: e.target.value })
}
required
/>
<input
placeholder="Обозначение"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.designation}
onChange={(e) =>
setFormData({ ...formData, designation: e.target.value })
}
required
/>
<input
placeholder="Наименование"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<input
placeholder="Изделие"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.product_name}
onChange={(e) =>
setFormData({ ...formData, product_name: e.target.value })
}
required
/>
<input
placeholder="Материал"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.material}
onChange={(e) =>
setFormData({ ...formData, material: e.target.value })
}
required
/>
{/* Числовые поля */} {/* Числовые поля */}
<input type="number" placeholder="L (Длина)" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" <input
onChange={e => setFormData({...formData, length: e.target.value})} required /> type="number"
<input type="number" placeholder="W (Ширина)" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" placeholder="L (Длина)"
onChange={e => setFormData({...formData, width: e.target.value})} required /> className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
<input type="number" placeholder="Кол-во" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white" onChange={(e) => setFormData({ ...formData, length: e.target.value })}
value={formData.quantity} onChange={e => setFormData({...formData, quantity: e.target.value})} required /> required
/>
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 rounded-xl transition-all">ДОБАВИТЬ</button> <input
type="number"
placeholder="W (Ширина)"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
onChange={(e) => setFormData({ ...formData, width: e.target.value })}
required
/>
<input
type="number"
placeholder="L (Длина заготовки)"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
onChange={(e) =>
setFormData({ ...formData, length_first: e.target.value })
}
required
/>
<input
type="number"
placeholder="W (Ширина заготовки)"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
onChange={(e) =>
setFormData({ ...formData, width_first: e.target.value })
}
required
/>
<input
type="number"
placeholder="Толщина"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.thickness}
onChange={(e) =>
setFormData({ ...formData, thickness: e.target.value })
}
required
/>
<input
type="number"
placeholder="Кол-во"
className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: e.target.value })
}
required
/>
<button
type="submit"
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 rounded-xl transition-all"
>
ДОБАВИТЬ
</button>
</form> </form>
</section> </section>
); );
} }
+91 -45
View File
@@ -1,52 +1,98 @@
import { useNavigate } from "react-router-dom";
export default function PartsTable({ parts, onRefresh, onDelete }) { export default function PartsTable({ parts, onRefresh, onDelete }) {
const navigate = useNavigate();
return ( return (
<section className="bg-slate-800/50 rounded-3xl border border-slate-700 p-6 shadow-xl"> <section className="bg-[#0f172a]/60 rounded-[2rem] border border-slate-800 p-4 md:p-8 shadow-2xl backdrop-blur-xl">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-10 px-2">
<h3 className="text-lg font-bold text-white text-[10px] uppercase tracking-widest">Цех: Поток деталей</h3> <div className="space-y-1">
<button onClick={onRefresh} className="text-xs text-blue-500 font-bold uppercase">Обновить </button> <h3 className="text-white text-[12px] font-black uppercase tracking-[0.3em] opacity-90">
Цех: поток деталей
</h3>
<div className="h-1 w-12 bg-blue-500 rounded-full"></div>
</div>
<button
onClick={onRefresh}
className="text-[11px] text-blue-400 font-black uppercase flex items-center gap-2 hover:text-blue-300 transition-all bg-blue-500/10 px-4 py-2 rounded-full border border-blue-500/20"
>
Обновить <span className="text-lg"></span>
</button>
</div> </div>
<div className="overflow-x-auto">
<table className="w-full text-left"> <div className="flex flex-col gap-6">
<thead> {parts.map((p) => (
<tr className="text-slate-500 text-[10px] uppercase border-b border-slate-700"> <div
<th className="pb-4">Заказ / ID</th> key={p.id}
<th className="pb-4">Деталь</th> onClick={() => navigate(`/part/${p.id}`)}
<th className="pb-4">Размеры (L x W)</th> className="group relative bg-slate-900/40 border border-slate-800/50 rounded-[1.5rem] p-6 hover:bg-slate-800/40 transition-all cursor-pointer hover:border-blue-500/30 hover:shadow-[0_0_30px_rgba(59,130,246,0.05)]"
<th className="pb-4">Статус</th> >
<th className="pb-4">Действие</th> {/* Верхняя строка: ID и Номер заказа */}
</tr> <div className="flex justify-between items-start mb-4">
</thead> <div className="flex items-center gap-3">
<tbody className="divide-y divide-slate-700/50"> <span className="font-mono text-blue-500 text-sm font-black bg-blue-500/10 px-2 py-0.5 rounded">
{parts.map(p => ( {p.id}
<tr key={p.id} className="hover:bg-white/5 transition-colors"> </span>
<td className="py-4"> <div className="text-[13px] text-slate-300 font-bold tracking-tight">
<div className="font-mono text-blue-400 text-xs">{p.id}</div> {p.order_no}
<div className="text-[10px] text-slate-500">{p.order_no}</div> </div>
<div className="text-[10px] text-slate-500">{p.destignation}</div> </div>
</td> <button
<td className="py-4"> onClick={(e) => {
<div className="font-bold text-sm text-white">{p.name}</div> e.stopPropagation();
<div className="text-[10px] text-slate-500">{p.material}</div> onDelete(p.id);
</td> }}
<td className="py-4 font-mono text-emerald-400 text-sm"> className="text-slate-600 hover:text-red-500 transition-colors p-1"
{p.length} × {p.width} <span className="text-[10px] text-slate-600">x{p.quantity}</span> >
</td>
<td className="py-4"> </button>
<span className="px-2 py-1 rounded text-[9px] font-black uppercase border border-emerald-500/50 text-emerald-500 bg-emerald-500/10"> </div>
{p.status}
</span> {/* Название и Материал */}
</td> <div className="mb-6">
<td className="py-4"> <h4 className="text-2xl font-black text-white tracking-tight group-hover:text-blue-400 transition-colors">
<button onClick={() => onDelete(p.id)} className="hover:scale-125 transition-transform text-red-500"> {p.name}
</h4>
</button> <p className="text-[11px] text-slate-500 font-medium mt-1 leading-relaxed max-w-[90%]">
</td> {p.material}
</tr> </p>
))} </div>
</tbody>
</table> {/* Блок Размеров */}
<div className="grid grid-cols-2 gap-4 py-4 border-y border-slate-800/50 mb-6">
<div className="space-y-1">
<p className="text-[10px] text-slate-500 uppercase font-black tracking-widest">Готовая</p>
<p className="text-2xl font-black text-white">
{p.length} <span className="text-slate-600 text-xs font-normal">×</span> {p.width}
</p>
</div>
<div className="space-y-1 border-l border-slate-800/50 pl-4">
<p className="text-[10px] text-slate-500 uppercase font-black tracking-widest">Заготовка</p>
<p className="text-lg font-bold text-slate-400 italic">
{p.length_first} × {p.width_first}
</p>
</div>
</div>
{/* Нижняя строка: Кол-во и Статус */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<span className="text-[9px] text-slate-500 uppercase font-black">Кол-во</span>
<span className="text-xl font-black text-white">{p.quantity}</span>
</div>
<div className="h-8 w-[1px] bg-slate-800"></div>
<div className="px-4 py-1.5 rounded-full text-[10px] font-black uppercase bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 shadow-[0_10px_20px_rgba(16,185,129,0.05)]">
{p.status}
</div>
</div>
<div className="text-[10px] text-slate-600 font-bold uppercase tracking-widest group-hover:text-blue-500/50">
Детали
</div>
</div>
</div>
))}
</div> </div>
</section> </section>
); );
} }
+20 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { Html5Qrcode } from 'html5-qrcode'; import { Html5Qrcode } from "html5-qrcode";
const Scanner = ({ onScan, onClose }) => { const Scanner = ({ onScan, onClose }) => {
const scannerRef = useRef(null); const scannerRef = useRef(null);
@@ -14,18 +14,17 @@ const Scanner = ({ onScan, onClose }) => {
const start = async () => { const start = async () => {
// Если уже запущен или компонент размонтирован — ничего не делаем // Если уже запущен или компонент размонтирован — ничего не делаем
if (isStarted.current) return; if (isStarted.current) return;
try { try {
await html5QrCode.start( await html5QrCode.start(
{ facingMode: "environment" }, { facingMode: "environment" },
{ fps: 10, qrbox: 250 }, { fps: 10, qrbox: 250 },
(text) => { (text) => {
// При успешном сканировании
if (isStarted.current) { if (isStarted.current) {
isStarted.current = false; isStarted.current = false;
html5QrCode.stop().then(() => onScan(text)); html5QrCode.stop().then(() => onScan(text));
} }
} },
); );
isStarted.current = true; isStarted.current = true;
} catch (e) { } catch (e) {
@@ -40,9 +39,10 @@ const Scanner = ({ onScan, onClose }) => {
isStarted.current = false; isStarted.current = false;
if (scannerRef.current) { if (scannerRef.current) {
if (scannerRef.current.isScanning) { if (scannerRef.current.isScanning) {
scannerRef.current.stop() scannerRef.current
.stop()
.then(() => scannerRef.current.clear()) .then(() => scannerRef.current.clear())
.catch(e => console.warn("Cleanup error:", e)); .catch((e) => console.warn("Cleanup error:", e));
} else { } else {
// На случай если камера еще не успела стартовать, а компонент уже закрыли // На случай если камера еще не успела стартовать, а компонент уже закрыли
scannerRef.current.clear(); scannerRef.current.clear();
@@ -53,15 +53,23 @@ const Scanner = ({ onScan, onClose }) => {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4">
{/* Твоя верстка остается без изменений */}
<div className="bg-slate-800 p-6 rounded-xl w-full max-w-md relative border border-slate-700"> <div className="bg-slate-800 p-6 rounded-xl w-full max-w-md relative border border-slate-700">
<button onClick={onClose} className="absolute -top-12 right-0 text-white bg-slate-700 w-10 h-10 rounded-full flex items-center justify-center">×</button> <button
<h2 className="text-lg font-semibold mb-4 text-center text-white">Сканер штрих-кодов</h2> onClick={onClose}
<div id="reader" className="overflow-hidden rounded-xl bg-black min-h-[250px]"></div> className="absolute -top-12 right-0 text-white bg-slate-700 w-10 h-10 rounded-full flex items-center justify-center"
>
×
</button>
<h2 className="text-lg font-semibold mb-4 text-center text-white">
Сканер штрих-кодов
</h2>
<div
id="reader"
className="overflow-hidden rounded-xl bg-black min-h-[250px]"
></div>
</div> </div>
</div> </div>
); );
}; };
export default Scanner; export default Scanner;
+14 -105
View File
@@ -1,112 +1,21 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; @layer base {
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans); *,
letter-spacing: 0.18px; ::after,
color-scheme: light dark; ::before {
color: var(--text); margin: 0;
background: var(--bg); padding: 0;
font-synthesis: none; box-sizing: border-box;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
} }
#social .button-icon { html,
filter: invert(1) brightness(2); body,
#root {
width: 100%;
min-height: 100vh;
background-color: #0f172a;
/* Твой темный фон */
} }
} }
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+42
View File
@@ -0,0 +1,42 @@
import { useNavigate } from "react-router-dom";
import PartForm from "../components/PartForm";
export default function AddPartPage() {
const navigate = useNavigate();
const handleAfterCreate = () => {
navigate("/");
};
return (
<div className="min-h-screen bg-[#0f172a] text-slate-200 flex flex-col items-center justify-center p-4 md:p-8">
<div className="w-full max-w-4xl">
<button
onClick={() => navigate("/")}
className="group mb-8 flex items-center gap-2 text-slate-500 hover:text-white transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
>
<span className="text-lg group-hover:-translate-x-1 transition-transform"></span>
Вернуться в поток
</button>
<div className="mb-10">
<h2 className="text-3xl font-black text-white tracking-tighter uppercase">
Новая <span className="text-blue-500">Панель</span>
</h2>
<p className="text-slate-500 text-xs mt-2 uppercase tracking-widest">
Ручной ввод данных в систему MRP
</p>
</div>
<div className="shadow-2xl shadow-blue-900/10">
<PartForm onCreated={handleAfterCreate} />
</div>
<p className="mt-8 text-center text-slate-600 text-[10px] uppercase tracking-widest leading-loose">
После нажатия кнопки "Добавить" деталь мгновенно <br />
появится в общем списке на главной панели.
</p>
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { useState, useEffect } from "react";
import * as api from "../api"; // Импортируем все функции из api/index.js
import PartsTable from "../components/PartsTable";
import Scanner from "../components/Scanner";
import { useNavigate } from "react-router-dom";
import Header from "../components/Header";
import OrderCard from "../components/OrderCard";
export default function Dashboard() {
// --- СОСТОЯНИЕ (Память компонента) ---
const [parts, setParts] = useState([]); // Список всех панелей
const [orders, setOrders] = useState([]);
const [selectedOrder, setSelectedOrder] = useState(null);
const [isScannerOpen, setIsScannerOpen] = useState(false); // Открыта ли камера
const navigate = useNavigate();
// --- ЛОГИКА (Функции-курьеры) ---
// Загрузка данных с бэкенда
const loadData = async () => {
try {
const [partsRes, ordersRes] = await Promise.all([
api.getAllParts(),
api.getAllOrders(),
]);
setParts(partsRes.data || []);
setOrders(ordersRes.data || []);
} catch (e) {
console.error("Ошибка при получении данных:", e);
}
};
// Выполняется один раз при открытии страницы
useEffect(() => {
loadData();
}, []);
const filteredParts = selectedOrder
? parts.filter((p) => String(p.order_no) === String(selectedOrder))
: parts;
// Что делать, когда сканер распознал ID
const handleScan = (id) => {
setIsScannerOpen(false);
// Просто переходим на страницу детали
window.location.href = `/part/${id}`;
};
// Удаление панели
const handleDelete = async (id) => {
if (window.confirm("Удалить эту панель из системы?")) {
try {
await api.deletePart(id);
loadData(); // Перезагружаем список после удаления
} catch (e) {
alert("Не удалось удалить деталь", e);
}
}
};
// --- ПРЕДСТАВЛЕНИЕ (Интерфейс) ---
return (
<div className="min-h-screen bg-[#0f172a] text-slate-200 flex flex-col">
{/* Шапка */}
<Header
onRefresh={loadData}
onOpenScanner={() => setIsScannerOpen(true)}
/>
<main className="w-full max-w-[1440px] mx-auto px-4 md:px-4 py-10 space-y-10">
{/* Модальное окно сканера */}
{isScannerOpen && (
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4">
<div className="w-full max-w-lg">
<Scanner
onScan={handleScan}
onClose={() => setIsScannerOpen(false)}
/>
<button
onClick={() => setIsScannerOpen(false)}
className="w-full mt-4 text-slate-400 font-bold uppercase text-xs"
>
Закрыть камеру
</button>
</div>
</div>
)}
<section>
<div className="flex justify-between items-end mb-6 text-sm font-bold text-slate-500 uppercase tracking-widest">
<h2>Активные заказы</h2>
{selectedOrder && (
<button
onClick={() =>
setSelectedOrder(
selectedOrder === orders.order_no ? null : orders.order_no,
)
}
className="text-blue-400 text-xs hover:underline"
>
Сбросить фильтр
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{orders.map((order) => (
<OrderCard
key={order.ID}
order={order}
isSelected={selectedOrder === order.order_no}
onClick={() =>
setSelectedOrder(
selectedOrder === order.order_no ? null : order.order_no,
)
}
partsCount={
parts.filter((p) => p.order_no === order.order_no).length
}
/>
))}
</div>
</section>
<PartsTable
parts={filteredParts}
onRefresh={loadData}
onDelete={handleDelete}
/>
</main>
<footer className="mt-20 pb-10 text-slate-600 text-[10px] uppercase tracking-widest">
Система управления потоком v1.0
</footer>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import * as api from "../api";
export default function PartDetail() {
const { id } = useParams(); // Получаем ID из URL /part/:id
const navigate = useNavigate();
const [part, setPart] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const res = await api.getPart(id);
setPart(res.data);
} catch (err) {
console.error("Ошибка загрузки детали:", err);
} finally {
setLoading(false);
}
};
fetchData();
}, [id]);
if (loading)
return (
<div className="p-10 text-white animate-pulse">Загрузка данных...</div>
);
if (!part)
return (
<div className="p-10 text-white">
Деталь не найдена :( <span></span>
<button onClick={() => navigate("/")}>Назад</button>
</div>
);
return (
<div className="min-h-screen bg-[#0f172a] text-slate-200 p-4 md:p-8">
<div className="max-w-4xl mx-auto">
{/* Кнопка назад */}
<button
onClick={() => navigate("/")}
className="mb-8 flex items-center gap-2 text-slate-500 hover:text-blue-400 transition-colors uppercase text-sm font-black"
>
Вернуться в список
</button>
<div className="bg-slate-800/50 border border-slate-700 rounded-[2.5rem] p-6 md:p-10 shadow-2xl">
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10">
<div className="flex flex-col">
<InterDate
label="Заказ"
state={part.order_no}
color="text-white"
/>
<InterDate
label="Обозначение"
state={part.designation}
color="text-slate-500"
/>
<InterDate
label="Название"
state={part.name}
color="text-slate-500"
/>
<InterDate
label="Изделие"
state={part.product_name}
color="text-slate-500"
/>
<InterDate
label="Материал"
state={part.material}
color="text-slate-500"
/>
</div>
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-2 rounded-2xl">
<p className="text-[10px] text-blue-500 uppercase font-black">
Текущее состояние
</p>
<p className="text-base font-bold text-white uppercase ">
{part.status}
</p>
</div>
</header>
{/* Основная сетка параметров (L, W, T) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<DataBox
label="Длина готовая (заготовка)(L)"
value={`${part.length} (${part.length_first})`}
sub="мм"
color="blue"
/>
<DataBox
label="Ширина готовая (заготовка)(W)"
value={`${part.width} (${part.width_first})`}
sub="мм"
color="blue"
/>
<DataBox
label="Толщина"
value={part.thickness}
sub="мм"
color="slate"
/>
</div>
{/* Секция Кромки (L1, L2, W1, W2) */}
<div className="bg-slate-900/50 rounded-3xl p-6 border border-slate-700/50">
<h3 className="text-[10px] text-slate-500 uppercase font-black mb-4 tracking-widest">
Облицовка кромок
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<EdgeBox label="L1" value={part.edge_l1} />
<EdgeBox label="L2" value={part.edge_l2} />
<EdgeBox label="W1" value={part.edge_w1} />
<EdgeBox label="W2" value={part.edge_w2} />
</div>
</div>
{/* Доп. информация */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-900/30 p-6 rounded-3xl border border-slate-800">
<p className="text-[10px] text-slate-500 uppercase mb-2">
Примечание
</p>
<p className="text-sm italic">{part.note || "Нет"}</p>
</div>
</div>
</div>
</div>
</div>
);
}
// Мини-компоненты для верстки
const DataBox = ({ label, value, sub, color }) => (
<div
className={`p-3 rounded-2xl border ${color === "blue" ? "bg-blue-500/5 border-blue-500/20" : "bg-slate-700/10 border-slate-700"}`}
>
<p className="text-[10px] text-slate-500 uppercase m-1 font-bold">
{label}
</p>
<p className="text-2xl font-mono font-black text-white">
{value}
<span className="text-sm font-normal text-slate-600 ml-1 ">{sub}</span>
</p>
</div>
);
const InterDate = ({ label, state, color }) => (
<div
className={`text-base font-black ${color} tracking-tighter flex flex-row`}
>
{label}
{": "} <div className="font-medium ml-1">{state}</div>
</div>
);
const EdgeBox = ({ label, value }) => (
<div className="bg-slate-800 p-3 rounded-xl border border-slate-700">
<p className="text-[9px] text-slate-500 uppercase mb-1">{label}</p>
<p className="text-xs font-bold text-slate-300 truncate">{value || "—"}</p>
</div>
);