Сделал первые функции под 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 (
"log"
"os"
"viplight-mrp/models" // Замени viplight-mrp на имя своего модуля из go.mod
"gorm.io/driver/postgres"
"gorm.io/gorm"
"viplight-mrp/models"
)
var DB *gorm.DB
@@ -18,6 +20,5 @@ func InitDB() {
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 (
"net/http"
"github.com/gin-gonic/gin"
"viplight-mrp/database"
"viplight-mrp/models"
"github.com/gin-gonic/gin"
)
func GetPart(c *gin.Context) {
@@ -29,64 +31,94 @@ func CreatePart(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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)
}
func UpdateStatus(c *gin.Context) {
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Status is required"})
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверные данные"})
}
result := database.DB.Model(&models.Part{}).Where("id = ?", id).Update("status", input.Status)
if result.RowsAffected == 0{
c.JSON(http.StatusNotFound, gin.H{"error" : "Деталь не найдена"})
}
if err := database.DB.Model(&models.Part{}).Where("id = ?", id).Update("status", input.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось обновить статус"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated", "new_status": input.Status})
}
func DeletePart(c *gin.Context){
id := c.Param("id")
if err := database.DB.Delete(&models.Part{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось удалить элемент"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Удалено"})
func DeletePart(c *gin.Context) {
id := c.Param("id")
if err := database.DB.Delete(&models.Part{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Удалено"})
}
func CreatePartsBulk(c *gin.Context){
var parts []models.Part
if err := c.ShouldBindJSON(&parts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
func CreatePartsBulk(c *gin.Context) {
var parts []models.Part
if err := c.ShouldBindJSON(&parts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := database.DB.Create(&parts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error" : "Не могу создать деталь"})
return
}
ordersMap := make(map[string]bool)
for _, p := range parts {
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) {
var parts []models.Part
if err := c.ShouldBindJSON(&parts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var parts []models.Part
// GORM сделает один быстрый INSERT для всех деталей
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)})
if err := c.ShouldBindJSON(&parts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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
import (
"github.com/gin-gonic/gin"
"viplight-mrp/database"
"viplight-mrp/handlers"
"github.com/gin-gonic/gin"
)
func main() {
@@ -26,12 +27,11 @@ func main() {
// Роуты теперь вызывают функции из handlers
r.GET("/api/parts/:id", handlers.GetPart)
r.GET("/api/parts", handlers.GetAllParts)
r.GET("/api/orders", handlers.GetsOrders)
r.POST("/api/parts", handlers.CreatePart)
r.POST("/api/parts/bulk", handlers.ImportParts)
r.PATCH("/api/parts/:id/status", handlers.UpdateStatus)
r.DELETE("/api/parts/:id", handlers.DeletePart)
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"`
}
+52 -54
View File
@@ -1,68 +1,66 @@
package models
const (
StatusImport = "Запланировано"
StatusWarehouse = "Склад"
StatusSawing = "Пила"
StatusEdging = "Кромка"
StatusEdgingHand = "Кромка ручная"
StatusCNCDelta = "ЧПУ Дельта"
StatusCNCTrepan = "ЧПУ Трепан"
StatusHandDrilling = "Присадка на ручном станке"
StatusSawHand = "Циркулярная пила"
StatusAssembly = "Сборка"
StatusAbrasive = "Шлифовка"
StatusDrying = "Сушка после малярки"
StatusPainting = "Малярная камера"
StatusGlass = "Стекольный цех"
StatusMetalSelf = "Сварочный цех"
StatusPaintingOutsource = "Порошковая покраска"
StatusAdv = "Рекламный участок"
StatusLight = "Светодиодный участок"
StatusSemiFinished = "Упаковка полуфабриката"
StatusFinished = "Упаковка изделия"
StatusShipment = "Отгружено на монтаж"
StatusInstallation = "Монтаж оборудования"
StatusRejected = "Отбраковано/сломано"
StatusImport = "Запланировано"
StatusWarehouse = "Склад"
StatusSawing = "Пила"
StatusEdging = "Кромка"
StatusEdgingHand = "Кромка ручная"
StatusCNCDelta = "ЧПУ Дельта"
StatusCNCTrepan = "ЧПУ Трепан"
StatusHandDrilling = "Присадка на ручном станке"
StatusSawHand = "Циркулярная пила"
StatusAssembly = "Сборка"
StatusAbrasive = "Шлифовка"
StatusDrying = "Сушка после малярки"
StatusPainting = "Малярная камера"
StatusGlass = "Стекольный цех"
StatusMetalSelf = "Сварочный цех"
StatusPaintingOutsource = "Порошковая покраска"
StatusAdv = "Рекламный участок"
StatusLight = "Светодиодный участок"
StatusSemiFinished = "Упаковка полуфабриката"
StatusFinished = "Упаковка изделия"
StatusShipment = "Отгружено на монтаж"
StatusInstallation = "Монтаж оборудования"
StatusRejected = "Отбраковано/сломано"
)
func IsValidStatus(s string) bool {
switch s {
case StatusWarehouse, StatusSawing, StatusEdging, StatusEdgingHand,
StatusCNCDelta, StatusCNCTrepan, StatusHandDrilling, StatusSawHand,
StatusAssembly, StatusAbrasive, StatusDrying, StatusPainting,
StatusGlass, StatusMetalSelf, StatusPaintingOutsource, StatusAdv,
StatusLight, StatusSemiFinished, StatusFinished, StatusShipment,
StatusInstallation, StatusRejected:
return true
}
return false
switch s {
case StatusWarehouse, StatusSawing, StatusEdging, StatusEdgingHand,
StatusCNCDelta, StatusCNCTrepan, StatusHandDrilling, StatusSawHand,
StatusAssembly, StatusAbrasive, StatusDrying, StatusPainting,
StatusGlass, StatusMetalSelf, StatusPaintingOutsource, StatusAdv,
StatusLight, StatusSemiFinished, StatusFinished, StatusShipment,
StatusInstallation, StatusRejected:
return true
}
return false
}
type Part struct {
ID string `gorm:"primaryKey" json:"id" binding:"required"` // № или Обозначение
Destignation string `json:"destignation" binding:"required"` // № или Обозначение
OrderNo string `json:"order_no" binding:"required"` // Заказ изделия
Name string `json:"name" binding:"required"` // Наименование детали
Material string `json:"material" binding:"required"` // Наименование материала
Thickness float64 `json:"thickness" binding:"required"` // Толщина
Quantity int `json:"quantity" binding:"required"` // Количество
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Designation string `json:"designation" binding:"required"`
OrderNo string `json:"order_no" binding:"required"`
Name string `json:"name" binding:"required"`
Material string `json:"material" binding:"required"`
Thickness float64 `json:"thickness" binding:"required"`
Quantity int `json:"quantity" binding:"required"`
// Размеры (используем Готовую деталь как основной стандарт)
Length float64 `json:"length" binding:"required"` // Готовая деталь [L]
Width float64 `json:"width" binding:"required"` // Готовая деталь [W]
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"` // Обозначение кромки [L1]
EdgeL2 string `json:"edge_l2"` // Обозначение кромки [L2]
EdgeW1 string `json:"edge_w1"` // Обозначение кромки [W1]
EdgeW2 string `json:"edge_w2"` // Обозначение кромки [W2]
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"` // Наимен. изделия
Groove string `json:"groove"`
Note string `json:"note"`
ProductName string `json:"product_name" binding:"required"`
Status string `json:"status" gorm:"default:Создан"`
Status string `json:"status" gorm:"default:Создан"`
}
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm install --legacy-peer-deps
COPY . .
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 globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
export default defineConfig([
globalIgnores(['dist']),
export default [
js.configs.recommended,
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
files: ["**/*.jsx", "**/*.tsx"],
plugins: {
react: pluginReact,
},
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
globals: {
...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>
<html lang="en">
<html lang="ru">
<head>
<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" />
<title>frontend</title>
<title>Viplight MRP</title>
</head>
<body>
<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",
"@vitejs/plugin-react": "^6.0.1",
"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-refresh": "^0.5.2",
"globals": "^17.5.0",
"globals": "^17.6.0",
"postcss": "^8.5.12",
"tailwindcss": "^4.2.4",
"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 * as api from './api';
import PartsTable from './components/PartsTable';
import Scanner from './components/Scanner';
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();
}
};
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Dashboard from "./pages/Dashboard";
import PartDetail from "./pages/PartDetail";
import AddPartPage from "./pages/AddPartPage";
export default function App() {
return (
<div className="min-h-screen bg-[#0f172a] text-slate-200 p-6 flex flex-col items-center">
<header className="w-full max-w-4xl mb-12 flex justify-between items-center">
<h1 className="text-xl font-bold tracking-tight">VIPLIGHT <span className="text-blue-500">MRP</span></h1>
<button
onClick={() => setIsScannerOpen(true)}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2"
>
<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>
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/part/:id" element={<PartDetail />} />
<Route path="/add" element={<AddPartPage />} />
</Routes>
</BrowserRouter>
);
}
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 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 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 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) => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
encoding: "CP1251", // Стандарт для РФ выгрузок
encoding: "CP1251",
complete: async (results) => {
const mappedParts = results.data.map(row => ({
id: row['№'] ,
destignation: row['Обозначение детали'],
order_no: row['Заказ изделия'],
name: row['Наименование детали'],
material: row['Наименование материала'],
thickness: parseFloat(row['Толщина с учетом облицовки пласти']?.replace(',', '.') || 0),
quantity: parseInt(row['Количество'] || 1),
length: parseFloat(row['Готовая деталь [L]']?.replace(',', '.') || 0),
width: parseFloat(row['Готовая деталь [W]']?.replace(',', '.') || 0),
edge_l1: row['Обозначение облицовки кромки [L1]'],
edge_l2: row['Обозначение облицовки кромки [L2]'],
edge_w1: row['Обозначение облицовки кромки [W1]'],
edge_w2: row['Обозначение облицовки кромки [W2]'],
groove: row['Паз'],
note: row['Примечание'],
product_name: row['Наимен. изделия'],
status: "Пила" // Стартовый статус по твоим константам
}));
const mappedParts = results.data
.filter((row) => row["Наименование детали"]) // Очистка от пустых строк
.map((row) => ({
designation: row["Обозначение детали"] || "",
order_no: row["Заказ изделия"] || "",
name: row["Наименование детали"] || "",
material: row["Наименование материала"] || "",
thickness: parseFloat(
row["Толщина с учетом облицовки пласти"]?.replace(",", ".") || 0,
),
quantity: parseInt(row["Количество"]) || 1,
length: parseFloat(
row["Готовая деталь [L]"]?.replace(",", ".") || 0,
),
width: parseFloat(
row["Готовая деталь [W]"]?.replace(",", ".") || 0,
),
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 {
const response = await createPartBulk(mappedParts);
const response = await createPartsBulk(mappedParts);
resolve(response.data);
} catch (err) {
reject(err.response?.data?.error || "Ошибка сервера");
reject(
err.response?.data?.error || "Ошибка сервера при bulk-запросе",
);
}
},
error: (err) => reject(err.message)
error: (err) => reject(err.message),
});
});
};
+10 -14
View File
@@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import { parseAndImportCsv } from '../api';
import React, { useRef } from "react";
import { parseAndImportCsv } from "../api";
export default function CsvImporter({ onImported }) {
const fileInputRef = useRef(null);
@@ -13,34 +13,30 @@ export default function CsvImporter({ onImported }) {
alert(`Успешно! Загружено деталей: ${result.count}`);
if (onImported) onImported(); // Обновляем список деталей на странице
} catch (err) {
alert("Ошибка импорта: " + err);
alert(`Ошибка импорта: ${err}`);
} finally {
// Очищаем инпут, чтобы можно было загрузить тот же файл повторно
if (fileInputRef.current) fileInputRef.current.value = '';
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
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">
<h3 className="text-white font-bold mb-2">Импорт заказа из Базиса</h3>
<p className="text-slate-400 text-sm mb-4">Выберите .csv файл для массовой загрузки</p>
<div className="flex items-center">
<input
type="file"
ref={fileInputRef}
onChange={handleUpload}
accept=".csv"
onChange={handleUpload}
className="hidden"
id="csv-upload"
/>
<label
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>
</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>
);
}
+128 -27
View File
@@ -1,11 +1,20 @@
import { useState } from 'react';
import { createPart } from '../api';
import { useState } from "react";
import { createPart } from "../api";
export default function PartForm({ onCreated }) {
const initialState = {
id: '', destignation: '', order_no: '', material: '',
length: 0, width: 0, thickness: 0, quantity: 1,
product_name: '', status: 'Создан', name: ''
designation: "",
order_no: "",
material: "",
length: 0,
width: 0,
length_first: 0,
width_first: 0,
thickness: 0,
quantity: 1,
product_name: "",
status: "Создан",
name: "",
};
const [formData, setFormData] = useState(initialState);
@@ -18,41 +27,133 @@ export default function PartForm({ onCreated }) {
...formData,
length: parseFloat(formData.length),
width: parseFloat(formData.width),
length_first: parseFloat(formData.length_first),
width_first: parseFloat(formData.width_first),
thickness: parseFloat(formData.thickness),
quantity: parseInt(formData.quantity)
quantity: parseInt(formData.quantity),
};
await createPart(dataToSend);
setFormData(initialState);
onCreated();
} catch (err) { alert("Ошибка: " + err.response?.data?.error); }
} catch (err) {
alert("Ошибка: " + err.response?.data?.error);
}
};
return (
<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>
<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"
value={formData.id} onChange={e => setFormData({...formData, id: e.target.value})} required />
<input placeholder="Заказ №" className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white"
value={formData.order_no} 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.destignation} onChange={e => setFormData({...formData, destignation: 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 />
<h3 className="text-lg font-bold text-white mb-6">
Регистрация новой панели
</h3>
<form
onSubmit={handleSubmit}
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.order_no}
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"
onChange={e => setFormData({...formData, length: 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: 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 />
<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: 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: 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>
<button
type="submit"
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 rounded-xl transition-all"
>
ДОБАВИТЬ
</button>
</form>
</section>
);
}
+91 -45
View File
@@ -1,52 +1,98 @@
import { useNavigate } from "react-router-dom";
export default function PartsTable({ parts, onRefresh, onDelete }) {
const navigate = useNavigate();
return (
<section className="bg-slate-800/50 rounded-3xl border border-slate-700 p-6 shadow-xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-bold text-white text-[10px] uppercase tracking-widest">Цех: Поток деталей</h3>
<button onClick={onRefresh} className="text-xs text-blue-500 font-bold uppercase">Обновить </button>
<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-10 px-2">
<div className="space-y-1">
<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 className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="text-slate-500 text-[10px] uppercase border-b border-slate-700">
<th className="pb-4">Заказ / ID</th>
<th className="pb-4">Деталь</th>
<th className="pb-4">Размеры (L x W)</th>
<th className="pb-4">Статус</th>
<th className="pb-4">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{parts.map(p => (
<tr key={p.id} className="hover:bg-white/5 transition-colors">
<td className="py-4">
<div className="font-mono text-blue-400 text-xs">{p.id}</div>
<div className="text-[10px] text-slate-500">{p.order_no}</div>
<div className="text-[10px] text-slate-500">{p.destignation}</div>
</td>
<td className="py-4">
<div className="font-bold text-sm text-white">{p.name}</div>
<div className="text-[10px] text-slate-500">{p.material}</div>
</td>
<td className="py-4 font-mono text-emerald-400 text-sm">
{p.length} × {p.width} <span className="text-[10px] text-slate-600">x{p.quantity}</span>
</td>
<td className="py-4">
<span className="px-2 py-1 rounded text-[9px] font-black uppercase border border-emerald-500/50 text-emerald-500 bg-emerald-500/10">
{p.status}
</span>
</td>
<td className="py-4">
<button onClick={() => onDelete(p.id)} className="hover:scale-125 transition-transform text-red-500">
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-col gap-6">
{parts.map((p) => (
<div
key={p.id}
onClick={() => navigate(`/part/${p.id}`)}
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)]"
>
{/* Верхняя строка: ID и Номер заказа */}
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<span className="font-mono text-blue-500 text-sm font-black bg-blue-500/10 px-2 py-0.5 rounded">
{p.id}
</span>
<div className="text-[13px] text-slate-300 font-bold tracking-tight">
{p.order_no}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="text-slate-600 hover:text-red-500 transition-colors p-1"
>
</button>
</div>
{/* Название и Материал */}
<div className="mb-6">
<h4 className="text-2xl font-black text-white tracking-tight group-hover:text-blue-400 transition-colors">
{p.name}
</h4>
<p className="text-[11px] text-slate-500 font-medium mt-1 leading-relaxed max-w-[90%]">
{p.material}
</p>
</div>
{/* Блок Размеров */}
<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>
</section>
);
}
+19 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
import { useEffect, useRef } from "react";
import { Html5Qrcode } from "html5-qrcode";
const Scanner = ({ onScan, onClose }) => {
const scannerRef = useRef(null);
@@ -20,12 +20,11 @@ const Scanner = ({ onScan, onClose }) => {
{ facingMode: "environment" },
{ fps: 10, qrbox: 250 },
(text) => {
// При успешном сканировании
if (isStarted.current) {
isStarted.current = false;
html5QrCode.stop().then(() => onScan(text));
}
}
},
);
isStarted.current = true;
} catch (e) {
@@ -40,9 +39,10 @@ const Scanner = ({ onScan, onClose }) => {
isStarted.current = false;
if (scannerRef.current) {
if (scannerRef.current.isScanning) {
scannerRef.current.stop()
scannerRef.current
.stop()
.then(() => scannerRef.current.clear())
.catch(e => console.warn("Cleanup error:", e));
.catch((e) => console.warn("Cleanup error:", e));
} else {
// На случай если камера еще не успела стартовать, а компонент уже закрыли
scannerRef.current.clear();
@@ -53,15 +53,23 @@ const Scanner = ({ onScan, onClose }) => {
return (
<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">
<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>
<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>
<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>
<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>
);
};
export default Scanner;
+14 -105
View File
@@ -1,112 +1,21 @@
@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;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
@layer base {
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
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;
*,
::after,
::before {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#social .button-icon {
filter: invert(1) brightness(2);
html,
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>
);